Stale Work Policy

On this page

TL;DR

A weekly scheduled CI job (triage:stale in .gitlab-ci.yml) flags work that has been idle past these thresholds:

Surface Threshold What happens

Issue

30 days no activity

Auto-labeled ~stale; one-time nudge comment posted

Draft MR

14 days no activity

Auto-labeled ~stale; one-time nudge comment posted

Remote branch

60 days no commits

Listed in the weekly report; never auto-deleted

A markdown report (stale-report.md) is uploaded as a CI artifact each run, retained for 30 days.

Clearing ~stale

When your work gets flagged, you have three responses:

  1. Keep going — push a commit, comment with status, or just remove the label. The job is idempotent; it won’t re-flag until the relevant clock resets.

  2. Pause it — comment explaining why it’s paused and what would unpause it. The label remains; the comment is the audit trail.

  3. Drop it — close the issue or MR. State the reason in the closing comment so future readers don’t re-litigate.

Removing the ~stale label is sufficient on its own. The job re-evaluates inactivity from the most recent activity, not the last label change — so dropping the label without engaging won’t bury the issue.

Why the thresholds

  • Issues 30 days: long enough to absorb a sprint cycle without false positives, short enough that a forgotten issue surfaces before it’s invisible.

  • Draft MRs 14 days: drafts represent in-flight work; two weeks without a push usually means the author has moved on or hit a blocker. Earlier than that punishes thoughtful work; later than that lets context decay.

  • Branches 60 days: branches outlive their MRs sometimes (post-merge cleanup gaps, force-push experiments). Two months is generous enough that the branch has clearly served its purpose.

These are tunable via env vars on the scheduled pipeline (ISSUE_INACTIVE_DAYS, DRAFT_MR_INACTIVE_DAYS, BRANCH_INACTIVE_DAYS). Don’t tune them lower without team consensus — false positives hurt trust.

Why we don’t auto-close

GitLab’s built-in stale rules can auto-close issues. CRAIG’s policy is flag, never close:

  • Auto-close discards context. A stale issue with a thread of comments is a candidate for re-prioritization, not a candidate for the trash.

  • Closing != solving. An auto-closed issue still represents a real gap; closing it just moves the problem off the dashboard.

  • Reversibility matters. Labels are a one-line fix; reopening + re-attributing a closed issue takes more work.

The CI job posts one nudge comment per detection. If the work genuinely should close, a human closes it.

Why we don’t auto-delete branches

For the same reason. A 60-day-old branch might be:

  • A long-running feature that paused (rare but legitimate)

  • A force-pushed branch that someone is mid-rebase on

  • A revert candidate someone is keeping around as evidence

The weekly report lists candidates; the maintainer decides per-branch. After a year, branches without an associated open MR are usually safe to delete via the GitLab UI’s bulk-delete. Default main and protected branches are skipped automatically.

When the job didn’t fire

If you expected to be flagged and weren’t:

  1. Check the pipeline schedule at Settings > CI/CD > Schedules — schedules can be paused without removal.

  2. Confirm GITLAB_TOKEN is set on the schedule (project- or group-scoped, with api + read_repository scopes).

  3. Look at the most recent scheduled pipeline; the triage:stale job’s log shows what it found.

If you were flagged but think the threshold is wrong: the issue/MR should have some activity since the cutoff date in the report. If GitLab’s "updated_at" is stale because of a hidden quirk (e.g., system notes don’t bump it the way comments do), file an issue against the script — that’s a real bug, not policy.

Local invocation

The script lives at tools/triage/stale.sh and runs locally with the same GITLAB_TOKEN your glab CLI uses:

GITLAB_TOKEN=glpat-... DRY_RUN=1 bash tools/triage/stale.sh

DRY_RUN=1 skips the label/comment writes; the report is generated regardless. Use this to preview what the next scheduled run will do.

Implementation

  • Script: tools/triage/stale.sh (bash + curl + python3, no Rust dependency — runs in a 4MB Alpine image)

  • CI job: triage:stale in .gitlab-ci.yml, gated to $CI_PIPELINE_SOURCE == "schedule"

  • Schedule: created via the GitLab UI (CI/CD > Schedules). Recommended cron: 0 13 * * 1 (Mondays 13:00 UTC ≈ 8 AM Atlanta)

  • Token: a masked CI variable GITLAB_TOKEN on the schedule, scoped to api + read_repository

  • Label: ~stale (#A8A8A8); GitLab is the source of truth — the script never assumes the label exists, but doesn’t create it either (a missing label surfaces as an API error in the CI log)

Edit this page · latest