Skip to content

Commit 18bc2be

Browse files
CopilotEMaher
andauthored
feat: stale issue management workflow (#180)
* Initial plan * feat: add stale issue management workflow (Closes #179) * fix: address PR review feedback on stale-issues workflow - Remove "Ensure stale label exists" step; workflow now fails if label is missing (sync-squad-labels creates it on schedule) - Define PROTECTED_LABELS once as workflow-level env var (JSON array); both jobs parse it via JSON.parse(process.env.PROTECTED_LABELS) — no duplication - Generate search query -label: exclude clauses dynamically from PROTECTED_LABELS so query and code guard are always in sync - Update warn comment: "30 days" → "60 days of inactivity" to match close behavior - Remove stale label when closing an issue - Rename sync-squad-labels.yml: "Sync Squad Labels" → "Sync Labels" - Add weekly schedule (Mondays 08:00 UTC) to sync-squad-labels.yml * chore: temporarily enable push trigger for stale workflow * chore: remove temporary push trigger from stale workflow * docs: update history and contributing documentation for stale issue management --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Elizabeth Maher <enewman@microsoft.com>
1 parent 780d890 commit 18bc2be

4 files changed

Lines changed: 285 additions & 3 deletions

File tree

.github/workflows/stale-issues.yml

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
name: Stale Issue Management
2+
3+
# Trigger: daily schedule
4+
# Scans open issues with no activity for 30+ days and closes those stale 60+ days.
5+
#
6+
# Protected labels — issues carrying ANY of these are NEVER touched:
7+
# priority:p0 | priority:p1 | security-review
8+
#
9+
# Exclusion is enforced in four overlapping layers (defense in depth):
10+
# 1. Search query — protected-label issues are excluded from the query result set.
11+
# 2. Code guard — every candidate is skipped at the start of the loop body if a
12+
# protected label is still present (catches search-index lag).
13+
# 3. Structural — add-labels only allows [stale]; close requires [stale] already
14+
# present, guaranteeing at least one warning cycle before close.
15+
# 4. Post-check — the close-stale job re-fetches every candidate's labels via the
16+
# API immediately before closing and aborts if a protected label is
17+
# found. This layer survives any other failure.
18+
19+
on:
20+
schedule:
21+
- cron: '0 9 * * *' # Daily at 09:00 UTC
22+
workflow_dispatch:
23+
24+
# Protected labels, thresholds, and workflow configuration defined at workflow level.
25+
env:
26+
PROTECTED_LABELS: '["priority:p0","priority:p1","security-review"]'
27+
STALE_DAYS: '30'
28+
STALE_CLOSE_DAYS: '60'
29+
30+
permissions:
31+
issues: write
32+
contents: read
33+
34+
jobs:
35+
# ──────────────────────────────────────────────────────────────────────────
36+
# Job 1 — Warn issues inactive for 30+ days
37+
# Adds the `stale` label and posts a "last chance" comment.
38+
# Maximum 10 issues per run.
39+
# ──────────────────────────────────────────────────────────────────────────
40+
warn-stale:
41+
name: Warn stale issues (30+ days)
42+
runs-on: ubuntu-latest
43+
44+
steps:
45+
- name: Warn stale issues
46+
uses: actions/github-script@v8
47+
with:
48+
script: |
49+
// Layer 2 — code guard: these labels make an issue untouchable regardless of
50+
// how it reached the candidate list.
51+
// PROTECTED_LABELS is defined at workflow level and shared with close-stale.
52+
const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS);
53+
const MAX_WARN = 20;
54+
const STALE_DAYS = parseInt(process.env.STALE_DAYS, 10);
55+
const STALE_CLOSE_DAYS = parseInt(process.env.STALE_CLOSE_DAYS, 10);
56+
57+
const now = new Date();
58+
const cutoff = new Date(now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000);
59+
const cutoffDate = cutoff.toISOString().split('T')[0];
60+
61+
// Layer 1 — search query: protected-label issues excluded at query time.
62+
// Exclude clauses are generated from PROTECTED_LABELS so the query always
63+
// stays in sync with the code guard above.
64+
const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`);
65+
const query = [
66+
`repo:${context.repo.owner}/${context.repo.repo}`,
67+
'is:issue',
68+
'is:open',
69+
...excludeClauses,
70+
'-label:stale',
71+
`updated:<${cutoffDate}`
72+
].join(' ');
73+
74+
core.info(`Warn query: ${query}`);
75+
76+
const result = await github.rest.search.issuesAndPullRequests({
77+
q: query,
78+
sort: 'updated',
79+
order: 'asc',
80+
per_page: MAX_WARN
81+
});
82+
83+
const candidates = result.data.items;
84+
core.info(`Found ${candidates.length} candidate(s) to warn`);
85+
86+
let warned = 0;
87+
for (const issue of candidates) {
88+
if (warned >= MAX_WARN) break;
89+
90+
// Layer 2 — code guard: skip if a protected label is present in the
91+
// search result payload (guards against search-index staleness).
92+
const labels = issue.labels.map(l => l.name);
93+
const hasProtected = PROTECTED_LABELS.some(l => labels.includes(l));
94+
if (hasProtected) {
95+
core.info(`Skipping #${issue.number} — protected label present`);
96+
continue;
97+
}
98+
99+
// Layer 3 — structural: only `stale` may be added by this workflow.
100+
await github.rest.issues.addLabels({
101+
owner: context.repo.owner,
102+
repo: context.repo.repo,
103+
issue_number: issue.number,
104+
labels: ['stale']
105+
});
106+
107+
const inactiveDays = Math.floor(
108+
(now - new Date(issue.updated_at)) / (24 * 60 * 60 * 1000)
109+
);
110+
111+
await github.rest.issues.createComment({
112+
owner: context.repo.owner,
113+
repo: context.repo.repo,
114+
issue_number: issue.number,
115+
body: [
116+
`⚠️ **Stale issue notice** — This issue has had no activity for **${inactiveDays} day(s)**.`,
117+
'',
118+
`It will be automatically closed after **${STALE_CLOSE_DAYS} days** of inactivity.`,
119+
'',
120+
'To keep this issue open:',
121+
'- Leave a comment describing the current status, or',
122+
'- Remove the `stale` label.',
123+
'',
124+
'> This notice was posted automatically by the stale issue workflow.'
125+
].join('\n')
126+
});
127+
128+
core.info(`Warned #${issue.number} (inactive ${inactiveDays} days)`);
129+
warned++;
130+
}
131+
132+
core.info(`Warn run complete — warned ${warned} issue(s)`);
133+
134+
# ──────────────────────────────────────────────────────────────────────────
135+
# Job 2 — Close issues inactive for 60+ days that already carry `stale`
136+
# Maximum 5 issues per run. Runs after warn-stale to avoid racing on issues
137+
# that were just warned in the same run.
138+
# ──────────────────────────────────────────────────────────────────────────
139+
close-stale:
140+
name: Close stale issues (60+ days)
141+
runs-on: ubuntu-latest
142+
needs: warn-stale
143+
144+
steps:
145+
- name: Close warned stale issues
146+
uses: actions/github-script@v8
147+
with:
148+
script: |
149+
// Layer 2 — code guard.
150+
// PROTECTED_LABELS is defined at workflow level and shared with warn-stale.
151+
const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS);
152+
const MAX_CLOSE = 10;
153+
const STALE_CLOSE_DAYS = parseInt(process.env.STALE_CLOSE_DAYS, 10);
154+
155+
const now = new Date();
156+
const cutoff = new Date(now.getTime() - STALE_CLOSE_DAYS * 24 * 60 * 60 * 1000);
157+
const cutoffDate = cutoff.toISOString().split('T')[0];
158+
159+
// Layer 1 — search query: protected-label issues excluded at query time.
160+
// Layer 3 — structural: `label:stale` is required; issues without it are
161+
// never returned (guarantees at least one warning cycle before close).
162+
// Exclude clauses are generated from PROTECTED_LABELS so the query always
163+
// stays in sync with the code guard above.
164+
const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`);
165+
const query = [
166+
`repo:${context.repo.owner}/${context.repo.repo}`,
167+
'is:issue',
168+
'is:open',
169+
'label:stale',
170+
...excludeClauses,
171+
`updated:<${cutoffDate}`
172+
].join(' ');
173+
174+
core.info(`Close query: ${query}`);
175+
176+
const result = await github.rest.search.issuesAndPullRequests({
177+
q: query,
178+
sort: 'updated',
179+
order: 'asc',
180+
per_page: MAX_CLOSE
181+
});
182+
183+
const candidates = result.data.items;
184+
core.info(`Found ${candidates.length} candidate(s) to close`);
185+
186+
let closed = 0;
187+
for (const issue of candidates) {
188+
if (closed >= MAX_CLOSE) break;
189+
190+
// Layer 4 — deterministic post-check: re-fetch labels from the API
191+
// immediately before closing. This is the only layer that survives a
192+
// fully compromised upstream step — it always reads live data.
193+
const fresh = await github.rest.issues.get({
194+
owner: context.repo.owner,
195+
repo: context.repo.repo,
196+
issue_number: issue.number
197+
});
198+
199+
const freshLabels = fresh.data.labels.map(l => l.name);
200+
201+
// Abort close if any protected label is present on the live issue.
202+
const hasProtected = PROTECTED_LABELS.some(l => freshLabels.includes(l));
203+
if (hasProtected) {
204+
const hit = freshLabels.filter(l => PROTECTED_LABELS.includes(l));
205+
core.warning(
206+
`Skipping close of #${issue.number} — protected label(s) detected: ${hit.join(', ')}`
207+
);
208+
continue;
209+
}
210+
211+
// Layer 3 — structural: stale label must be present on the live issue.
212+
if (!freshLabels.includes('stale')) {
213+
core.info(`Skipping #${issue.number} — stale label was removed`);
214+
continue;
215+
}
216+
217+
// All four layers passed — safe to close.
218+
await github.rest.issues.update({
219+
owner: context.repo.owner,
220+
repo: context.repo.repo,
221+
issue_number: issue.number,
222+
state: 'closed',
223+
state_reason: 'not_planned'
224+
});
225+
226+
// Remove the stale label now that the issue is closed.
227+
await github.rest.issues.removeLabel({
228+
owner: context.repo.owner,
229+
repo: context.repo.repo,
230+
issue_number: issue.number,
231+
name: 'stale'
232+
});
233+
234+
const inactiveDays = Math.floor(
235+
(now - new Date(fresh.data.updated_at)) / (24 * 60 * 60 * 1000)
236+
);
237+
238+
await github.rest.issues.createComment({
239+
owner: context.repo.owner,
240+
repo: context.repo.repo,
241+
issue_number: issue.number,
242+
body: [
243+
`🔒 **Closing as stale** — This issue has had no activity for **${inactiveDays} day(s)**.`,
244+
'',
245+
'It has been automatically closed as `not_planned`.',
246+
'',
247+
'If this issue is still relevant, please:',
248+
'1. Reopen it, and',
249+
'2. Add a comment describing the current status.',
250+
'',
251+
'> This action was taken automatically by the stale issue workflow.'
252+
].join('\n')
253+
});
254+
255+
core.info(`Closed #${issue.number} (inactive ${inactiveDays} days)`);
256+
closed++;
257+
}
258+
259+
core.info(`Close run complete — closed ${closed} issue(s)`);

.github/workflows/sync-squad-labels.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
name: Sync Squad Labels
1+
name: Sync Labels
22

33
on:
4+
schedule:
5+
- cron: '0 8 * * 1' # Weekly on Monday at 08:00 UTC
46
push:
57
paths:
68
- '.squad/team.md'
@@ -103,6 +105,12 @@ jobs:
103105
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
104106
];
105107
108+
// Lifecycle labels managed by automated workflows
109+
const LIFECYCLE_LABELS = [
110+
{ name: 'stale', color: 'aaaaaa', description: 'No activity for 30+ days — will be closed if no further activity' },
111+
{ name: 'security-review', color: 'B60205', description: 'Requires security review — exempt from stale automation' }
112+
];
113+
106114
function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
107115
108116
// Ensure the base "squad" triage label exists
@@ -127,12 +135,13 @@ jobs:
127135
});
128136
}
129137
130-
// Add go:, release:, type:, priority:, and high-signal labels
138+
// Add go:, release:, type:, priority:, high-signal, and lifecycle labels
131139
labels.push(...GO_LABELS);
132140
labels.push(...RELEASE_LABELS);
133141
labels.push(...TYPE_LABELS);
134142
labels.push(...PRIORITY_LABELS);
135143
labels.push(...SIGNAL_LABELS);
144+
labels.push(...LIFECYCLE_LABELS);
136145
137146
// Sync labels (create or update)
138147
for (const label of labels) {

.squad/agents/githubexpert/history.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Learnings
44

5+
### 2026-06-22 — Prefer github-script@v8 in workflows
6+
7+
**Context:** `stale-issues.yml` emitted a warning that Node.js 20 is deprecated because `actions/github-script@v7` targets Node 20 and is forced to run on Node 24.
8+
9+
**Key pattern:** For GitHub workflow scripting steps, use `actions/github-script@v8` by default and avoid `@v7` to prevent deprecation warnings and keep runtime compatibility aligned with current runners.
10+
511
### 2026-07-15 — Merge main into feature branch (shallow clone handling)
612

713
**Context:** Branch `copilot/fix-github-issue-96` was a shallow clone (grafted). Merging main required `git fetch --unshallow` first, then `git fetch origin main:refs/remotes/origin/main` to create the remote tracking ref. PR #93 (`Fix unit test failure in workspace tests`) fixed the failing workspace-extractor test. After merge, all 910 tests pass.

CONTRIBUTING.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ additional questions or comments.
2323

2424
## Getting started
2525

26-
### VS Code (Recommended)
26+
### VS Code
2727

2828
Open the repository in VS Code and click **Reopen in Container** when prompted (requires the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)). This starts a pre-configured environment with Node.js 22, Azure CLI, and GitHub CLI — no local tool installation needed.
2929

@@ -159,6 +159,14 @@ export class ExtractCommand {
159159

160160
5. **Address review feedback** promptly.
161161

162+
## Monitoring stale issues
163+
164+
The stale issue management workflow automatically warns inactive issues (30+ days) and closes them after 60+ days of inactivity, with exceptions for protected labels (`priority:p0`, `priority:p1`, `security-review`).
165+
166+
Relevant queries:
167+
- [Open issues with the stale label](https://github.com/Azure/apiops-cli/issues?q=repo%3AAzure%2Fapiops-cli+is%3Aissue+is%3Aopen+label%3Astale)
168+
- [Issues closed due to staleness (closed with reason not_planned and previously stale)](https://github.com/Azure/apiops-cli/issues?q=repo%3AAzure%2Fapiops-cli+is%3Aissue+is%3Aclosed+reason%3Anot_planned)
169+
162170
## Questions or issues?
163171

164172
Open an issue in the [GitHub issue tracker](https://github.com/Azure/apiops-cli/issues) with a clear description of your question or problem.

0 commit comments

Comments
 (0)