Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions Governance/policies/STALE_ISSUES.md
Original file line number Diff line number Diff line change
@@ -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.
158 changes: 158 additions & 0 deletions Governance/policies/STALE_ISSUES.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading