diff --git a/Governance/policies/STALE_ISSUES.md b/Governance/policies/STALE_ISSUES.md new file mode 100644 index 00000000..ce64ccae --- /dev/null +++ b/Governance/policies/STALE_ISSUES.md @@ -0,0 +1,119 @@ +# Stale Issue Policy + +## Purpose + +This policy defines how TeachLink Web marks inactive issues +as stale, warns participants before closing, closes issues +that stay inactive, and reopens them when activity resumes. +It keeps the backlog honest without closing work that is +still wanted, and gives contributors one predictable rule +instead of ad hoc cleanup. + +## Scope + +This policy applies to issues filed in this repository. It +covers the staleness threshold, the warning comment, the +closing step, and the reopen path. It does not cover pull +requests, which are closed by review inactivity under +`Governance/policies/REVIEW_POLICY.md`, nor security reports +handled in private under `Governance/SECURITY_POLICY.md`. + +It works with the triage process in +`Governance/processes/TRIAGE.md` and the labels defined in +`Governance/LABEL_TAXONOMY.md`. Label meaning stays in the +taxonomy; this policy only sets timing and steps. + +## Staleness Threshold + +An issue becomes eligible for staleness handling after +60 calendar days with no activity. Activity means a human +comment, a label change by a maintainer, an assignment +change, a linked pull request, or a reopen event. Automated +bot comments alone do not reset the clock. + +The 60 calendar days threshold matches the stale sweep in +`Governance/processes/TRIAGE.md`. The clock pauses while an +issue waits on a maintainer action recorded in the thread, +and while an issue carries an exempt label below. + +The following issues are exempt from automatic staleness +handling and are never marked stale by automation: + +- Issues labelled `security` or under an active embargo. +- Issues labelled `priority: high` or blocking a release. +- Pinned issues, milestones, and announced roadmap items. +- Issues with an assigned owner and a recorded due date. + +Exempt issues are still reviewed at the monthly backlog +audit, where maintainers confirm the exemption still holds. + +## Warning and Closing Steps + +Staleness handling has two visible steps: warning, then +closing. Both steps are recorded in the issue thread so the +decision is auditable. + +- **Warning.** When the threshold is reached, automation or + a maintainer adds the `triage: stale` status label and + posts a warning comment. The comment states the issue + appears inactive, asks if it is still wanted, and names + the closing date. The warning starts a grace period of + 14 calendar days. +- **Grace period.** Any human activity during the grace + period removes the `triage: stale` label and cancels the + pending close. The 60 calendar days clock restarts from + that activity. +- **Closing.** If no human activity occurs within the + 14 calendar days after the warning, a maintainer or + automation closes the issue. The closing comment links + this policy, states the reason as inactive, and explains + how to reopen. Status labels are removed on close under + `Governance/LABEL_TAXONOMY.md`; type, area, and priority + labels are kept as history. +- **No silent transitions.** Issues are never marked stale + or closed as stale without the thread comments above. A + bulk sweep lists each issue it touches. + +Closing as stale is not a judgement on value. It means no +participant confirmed the work is still wanted in time. + +## Reopen Path + +Any participant may reopen a closed-as-stale issue by +commenting with new context or by reopening it directly. +Reopening removes the closed-as-stale state, clears any +remaining `triage: stale` label, and restarts triage under +`Governance/processes/TRIAGE.md` rather than continuing the +old discussion. + +- A reporter reopens by adding a reproduction, expected + behaviour, or confirmation the problem still exists. +- A maintainer reopens by confirming the work is wanted + and setting type, priority, and status labels again. +- Disposition labels `duplicate`, `invalid`, and `wontfix` + are removed on reopen so history stays searchable. +- An issue closed in error is reopened on request with no + penalty, and the thread records the correction. + +An issue may cycle through warning and closing more than +once. Repeat cycles are a signal at the monthly audit to +re-scope, split, or close the issue with a firmer reason. + +## Ownership and Review + +- Maintainers own this policy, approve exemptions, and + review stale sweeps at the monthly backlog audit so + automation does not close wanted work. +- Changes to this policy are proposed in a pull request + that touches only the `Governance/` folder as + `Governance/README.md` requires. +- This document is versioned with the repository; it + describes the process the project actually follows. + +## Success + +This policy succeeds when no open issue waits 60 calendar +days without a recorded state, every stale warning names a +closing date, every stale close links this policy and its +reopen path, and reopened issues return to triage instead +of stalling again. diff --git a/Governance/policies/STALE_ISSUES.test.ts b/Governance/policies/STALE_ISSUES.test.ts new file mode 100644 index 00000000..801ebd25 --- /dev/null +++ b/Governance/policies/STALE_ISSUES.test.ts @@ -0,0 +1,158 @@ +/** + * Regression tests for the Stale Issue Policy + * (Governance/policies/STALE_ISSUES.md). + * + * The policy is documentation, but it makes concrete, enforceable + * guarantees: a staleness threshold, a warning-then-close sequence, + * and a reopen path. These tests pin those guarantees to the + * checked-in document so they cannot silently regress (for example, + * an edit that drops the threshold or the reopen path fails CI), + * and they keep the document consistent with the rest of Governance. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const POLICY_PATH = path.resolve(__dirname, 'STALE_ISSUES.md'); +const policy = readFileSync(POLICY_PATH, 'utf8'); + +/** Strip Markdown syntax so keyword assertions match prose, not formatting. */ +function plainProse(markdown: string): string { + return markdown + .replace(/`([^`]*)`/g, '$1') // inline code keeps its text + .replace(/\*\*([^*]*)\*\*/g, '$1') // bold keeps its text + .replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2') // links keep text and target + .replace(/\s+/g, ' ') // line wrapping must not affect prose matching + .toLowerCase(); +} + +const prose = plainProse(policy); + +/** Every "## Heading" in the document, in order. */ +const sections = [...policy.matchAll(/^## (.+)$/gm)].map((match) => match[1]); + +/** Extract the body of a single "## Section" (text up to the next heading). */ +function sectionBody(title: string): string { + const start = policy.indexOf(`## ${title}\n`); + expect(start, `section "${title}" is missing`).toBeGreaterThanOrEqual(0); + const next = policy.indexOf('\n## ', start + 1); + const body = next === -1 ? policy.slice(start) : policy.slice(start, next); + return plainProse(body); +} + +describe('STALE_ISSUES policy document structure', () => { + it('is titled "Stale Issue Policy" like every governance document', () => { + expect(policy.startsWith('# Stale Issue Policy\n')).toBe(true); + }); + + it('keeps the canonical governance document sections', () => { + expect(sections).toEqual([ + 'Purpose', + 'Scope', + 'Staleness Threshold', + 'Warning and Closing Steps', + 'Reopen Path', + 'Ownership and Review', + 'Success', + ]); + }); + + it('covers the three areas the issue requires', () => { + expect(sections).toContain('Staleness Threshold'); + expect(sections).toContain('Warning and Closing Steps'); + expect(sections).toContain('Reopen Path'); + }); + + it('has no unresolved template placeholders', () => { + expect(policy).not.toMatch(/TBD|TODO|FIXME|<[a-z-]+>|XXX/); + }); + + it('stays within the house documentation line width (max 82 columns)', () => { + const longest = Math.max(...policy.split('\n').map((line) => line.length)); + expect(longest).toBeLessThanOrEqual(82); + }); +}); + +describe('staleness threshold guarantees', () => { + const body = sectionBody('Staleness Threshold'); + + it('defines the 60 calendar days threshold with no activity', () => { + expect(body).toContain('60 calendar days'); + expect(body).toContain('no activity'); + }); + + it('defines what counts as activity and excludes bot-only noise', () => { + expect(body).toContain('human'); + expect(body).toContain('bot comments alone do not reset'); + }); + + it('lists exempt issues that are never marked stale by automation', () => { + expect(body).toContain('exempt'); + expect(body).toContain('never marked stale'); + expect(body).toContain('security'); + expect(body).toContain('priority: high'); + }); +}); + +describe('warning and closing guarantees', () => { + const body = sectionBody('Warning and Closing Steps'); + + it('requires a warning with the stale label before any close', () => { + expect(body).toContain('warning'); + expect(body).toContain('triage: stale'); + }); + + it('defines a 14 calendar days grace period after the warning', () => { + expect(body).toContain('14 calendar days'); + expect(body).toContain('grace period'); + }); + + it('closes only after the grace period with an auditable comment', () => { + expect(body).toContain('closing'); + expect(body).toContain('auditable'); + expect(body).toContain('how to reopen'); + }); + + it('forbids silent stale transitions', () => { + expect(body).toContain('never marked stale'); + expect(body).toContain('without the thread comments'); + }); +}); + +describe('reopen path guarantees', () => { + const body = sectionBody('Reopen Path'); + + it('lets any participant reopen via comment or direct reopen', () => { + expect(body).toContain('reopen'); + expect(body).toContain('commenting'); + }); + + it('clears stale state and restarts triage on reopen', () => { + expect(body).toContain('restarts triage'); + expect(body).toContain('triage: stale'); + }); + + it('removes terminal disposition labels on reopen', () => { + expect(body).toContain('duplicate'); + expect(body).toContain('invalid'); + expect(body).toContain('wontfix'); + }); +}); + +describe('policy consistency with the governance folder', () => { + it('routes triage and labels to the canonical governance documents', () => { + expect(prose).toContain('governance/processes/triage.md'); + expect(prose).toContain('governance/label_taxonomy.md'); + expect(() => + readFileSync(path.resolve(__dirname, '../processes/TRIAGE.md'), 'utf8'), + ).not.toThrow(); + expect(() => + readFileSync(path.resolve(__dirname, '../README.md'), 'utf8'), + ).not.toThrow(); + }); + + it('mentions the governance-folder-only rule for changes to this policy', () => { + const body = sectionBody('Ownership and Review'); + expect(body).toContain('only the governance/ folder'); + }); +});