Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
260 changes: 260 additions & 0 deletions .github/workflows/stale-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
name: Stale Issue Management

# Trigger: daily schedule
# Scans open issues with no activity for 30+ days and closes those stale 60+ days.
#
# Protected labels — issues carrying ANY of these are NEVER touched:
# priority:p0 | priority:p1 | security-review
#
# Exclusion is enforced in four overlapping layers (defense in depth):
# 1. Search query — protected-label issues are excluded from the query result set.
# 2. Code guard — every candidate is skipped at the start of the loop body if a
# protected label is still present (catches search-index lag).
# 3. Structural — add-labels only allows [stale]; close requires [stale] already
# present, guaranteeing at least one warning cycle before close.
# 4. Post-check — the close-stale job re-fetches every candidate's labels via the
# API immediately before closing and aborts if a protected label is
# found. This layer survives any other failure.

on:
schedule:
- cron: '0 9 * * *' # Daily at 09:00 UTC
push:
branches:
- copilot/branch-management-stale-issue-workflow
workflow_dispatch:

# Protected labels are defined once here and shared by both jobs via the
# PROTECTED_LABELS environment variable (JSON-encoded array).
env:
PROTECTED_LABELS: '["priority:p0","priority:p1","security-review"]'

permissions:
issues: write
contents: read

jobs:
# ──────────────────────────────────────────────────────────────────────────
# Job 1 — Warn issues inactive for 30+ days
# Adds the `stale` label and posts a "last chance" comment.
# Maximum 10 issues per run.
# ──────────────────────────────────────────────────────────────────────────
warn-stale:
name: Warn stale issues (30+ days)
runs-on: ubuntu-latest

steps:
- name: Warn stale issues
uses: actions/github-script@v7
with:
script: |
// Layer 2 — code guard: these labels make an issue untouchable regardless of
// how it reached the candidate list.
// PROTECTED_LABELS is defined at workflow level and shared with close-stale.
const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS);
const MAX_WARN = 10;
const STALE_DAYS = 30;

const now = new Date();
const cutoff = new Date(now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000);
const cutoffDate = cutoff.toISOString().split('T')[0];

// Layer 1 — search query: protected-label issues excluded at query time.
// Exclude clauses are generated from PROTECTED_LABELS so the query always
// stays in sync with the code guard above.
const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`);
const query = [
`repo:${context.repo.owner}/${context.repo.repo}`,
'is:issue',
'is:open',
...excludeClauses,
'-label:stale',
`updated:<${cutoffDate}`
].join(' ');

core.info(`Warn query: ${query}`);

const result = await github.rest.search.issuesAndPullRequests({
q: query,
sort: 'updated',
order: 'asc',
per_page: MAX_WARN
});

const candidates = result.data.items;
core.info(`Found ${candidates.length} candidate(s) to warn`);

let warned = 0;
for (const issue of candidates) {
if (warned >= MAX_WARN) break;

// Layer 2 — code guard: skip if a protected label is present in the
// search result payload (guards against search-index staleness).
const labels = issue.labels.map(l => l.name);
const hasProtected = PROTECTED_LABELS.some(l => labels.includes(l));
if (hasProtected) {
core.info(`Skipping #${issue.number} — protected label present`);
continue;
}

// Layer 3 — structural: only `stale` may be added by this workflow.
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['stale']
});

const inactiveDays = Math.floor(
(now - new Date(issue.updated_at)) / (24 * 60 * 60 * 1000)
);

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
`⚠️ **Stale issue notice** — This issue has had no activity for **${inactiveDays} day(s)**.`,
'',
'It will be automatically closed after **60 days** of inactivity.',
Comment thread
EMaher marked this conversation as resolved.
Outdated
'',
'To keep this issue open:',
'- Leave a comment describing the current status, or',
'- Remove the `stale` label.',
'',
'> This notice was posted automatically by the stale issue workflow.'
].join('\n')
});

core.info(`Warned #${issue.number} (inactive ${inactiveDays} days)`);
warned++;
}

core.info(`Warn run complete — warned ${warned} issue(s)`);

# ──────────────────────────────────────────────────────────────────────────
# Job 2 — Close issues inactive for 60+ days that already carry `stale`
# Maximum 5 issues per run. Runs after warn-stale to avoid racing on issues
# that were just warned in the same run.
# ──────────────────────────────────────────────────────────────────────────
close-stale:
name: Close stale issues (60+ days)
runs-on: ubuntu-latest
needs: warn-stale

steps:
- name: Close warned stale issues
uses: actions/github-script@v7
with:
script: |
// Layer 2 — code guard.
// PROTECTED_LABELS is defined at workflow level and shared with warn-stale.
const PROTECTED_LABELS = JSON.parse(process.env.PROTECTED_LABELS);
const MAX_CLOSE = 5;
const STALE_CLOSE_DAYS = 60;

const now = new Date();
const cutoff = new Date(now.getTime() - STALE_CLOSE_DAYS * 24 * 60 * 60 * 1000);
const cutoffDate = cutoff.toISOString().split('T')[0];

// Layer 1 — search query: protected-label issues excluded at query time.
// Layer 3 — structural: `label:stale` is required; issues without it are
// never returned (guarantees at least one warning cycle before close).
// Exclude clauses are generated from PROTECTED_LABELS so the query always
// stays in sync with the code guard above.
const excludeClauses = PROTECTED_LABELS.map(l => `-label:"${l}"`);
const query = [
`repo:${context.repo.owner}/${context.repo.repo}`,
'is:issue',
'is:open',
'label:stale',
...excludeClauses,
`updated:<${cutoffDate}`
].join(' ');

core.info(`Close query: ${query}`);

const result = await github.rest.search.issuesAndPullRequests({
q: query,
sort: 'updated',
order: 'asc',
per_page: MAX_CLOSE
});

const candidates = result.data.items;
core.info(`Found ${candidates.length} candidate(s) to close`);

let closed = 0;
for (const issue of candidates) {
if (closed >= MAX_CLOSE) break;

// Layer 4 — deterministic post-check: re-fetch labels from the API
// immediately before closing. This is the only layer that survives a
// fully compromised upstream step — it always reads live data.
const fresh = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number
});

const freshLabels = fresh.data.labels.map(l => l.name);

// Abort close if any protected label is present on the live issue.
const hasProtected = PROTECTED_LABELS.some(l => freshLabels.includes(l));
if (hasProtected) {
const hit = freshLabels.filter(l => PROTECTED_LABELS.includes(l));
core.warning(
`Skipping close of #${issue.number} — protected label(s) detected: ${hit.join(', ')}`
);
continue;
}

// Layer 3 — structural: stale label must be present on the live issue.
if (!freshLabels.includes('stale')) {
core.info(`Skipping #${issue.number} — stale label was removed`);
continue;
}

// All four layers passed — safe to close.
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});

// Remove the stale label now that the issue is closed.
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'stale'
});

const inactiveDays = Math.floor(
(now - new Date(fresh.data.updated_at)) / (24 * 60 * 60 * 1000)
);

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
`🔒 **Closing as stale** — This issue has had no activity for **${inactiveDays} day(s)**.`,
'',
'It has been automatically closed as `not_planned`.',
'',
'If this issue is still relevant, please:',
'1. Reopen it, and',
Comment thread
EMaher marked this conversation as resolved.
'2. Add a comment describing the current status.',
'',
'> This action was taken automatically by the stale issue workflow.'
].join('\n')
});

core.info(`Closed #${issue.number} (inactive ${inactiveDays} days)`);
closed++;
}

core.info(`Close run complete — closed ${closed} issue(s)`);
13 changes: 11 additions & 2 deletions .github/workflows/sync-squad-labels.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Sync Squad Labels
name: Sync Labels

on:
schedule:
- cron: '0 8 * * 1' # Weekly on Monday at 08:00 UTC
push:
Comment thread
EMaher marked this conversation as resolved.
paths:
- '.squad/team.md'
Expand Down Expand Up @@ -103,6 +105,12 @@ jobs:
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
];

// Lifecycle labels managed by automated workflows
const LIFECYCLE_LABELS = [
{ name: 'stale', color: 'aaaaaa', description: 'No activity for 30+ days — will be closed if no further activity' },
{ name: 'security-review', color: 'B60205', description: 'Requires security review — exempt from stale automation' }
];

function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }

// Ensure the base "squad" triage label exists
Expand All @@ -127,12 +135,13 @@ jobs:
});
}

// Add go:, release:, type:, priority:, and high-signal labels
// Add go:, release:, type:, priority:, high-signal, and lifecycle labels
labels.push(...GO_LABELS);
labels.push(...RELEASE_LABELS);
labels.push(...TYPE_LABELS);
labels.push(...PRIORITY_LABELS);
labels.push(...SIGNAL_LABELS);
labels.push(...LIFECYCLE_LABELS);

// Sync labels (create or update)
for (const label of labels) {
Expand Down
Loading