mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 14:00:54 +00:00
f366f762cc
## Description:
# Issue Lifecycle Actions
Adds two GitHub Actions workflows that enforce OpenFront's
issue-lifecycle invariants. No LLM calls — only the default
`GITHUB_TOKEN`. Layer B (Claude-powered triage) will build on this
foundation.
## Summary
- **Stale closer** — daily cron. Unmilestoned issues get warned at 5
days of inactivity, auto-closed at 10. Exempt: milestoned or
`keep-open`. Bot comments don't reset the timer.
- **Assignment invariant** — event + cron backstop. You cannot assign
anyone to an unmilestoned issue. Violators are unassigned automatically
with an explanatory comment.
- **Approval label sync** — event + cron backstop. The `not-approved`
(red) and `approved` (green) labels are derived from milestone state.
These labels are *only* ever touched by this Action.
## Rollout
Both workflows ship gated by `vars.ISSUE_LIFECYCLE_DRY_RUN` (defaults to
`'true'`). They log decisions but do not mutate anything until the
maintainer flips that variable in **Settings → Variables**.
Suggested rollout:
1. Merge with dry-run on.
2. Watch the cron logs for ~1 week. Verify the action list matches
expectations.
3. Flip `ISSUE_LIFECYCLE_DRY_RUN=false` to go live.
## File layout
```
.github/workflows/
issue-lifecycle-cron.yml # daily 06:00 UTC + workflow_dispatch
issue-lifecycle-events.yml # issues: [opened, assigned, milestoned, demilestoned]
scripts/issue-lifecycle/
config.ts # labels, colors, thresholds, comment templates
github.ts # Octokit wrapper, Action applier, label idempotent-creation
rules/
approval-label-sync.ts # pure function — idempotent
assignment-invariant.ts # pure function
stale-closer.ts # async — reads comment history, filters bots
cron.ts # daily sweep orchestrator
events.ts # event-mode dispatcher
index.ts # entrypoint, CLI arg parser
README.md
```
Structure mirrors `scripts/pr-gate/` from Unit 2 — same Octokit/Action
patterns, same dry-run convention.
## Self-installing labels
On every run, the Action ensures the six labels exist (`not-approved`,
`approved`, `stale`, `keep-open`, `needs-info`, `auto-closed-stale`)
with the correct colors and descriptions. No manual setup required.
## Local testing
```bash
cd scripts/issue-lifecycle
npm install
export GITHUB_TOKEN=ghp_...
# Full cron sweep, dry-run (default for CLI):
npx tsx index.ts --mode cron
# Simulate an event:
EVENT_NAME=assigned npx tsx index.ts --mode event --issue 1234
```
CLI invocations are dry-run unless `--no-dry-run` is passed explicitly.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
evan
86 lines
2.5 KiB
TypeScript
86 lines
2.5 KiB
TypeScript
import { runCron } from "./cron";
|
|
import { runEvent } from "./events";
|
|
import { ensureAllLabels, makeOctokit } from "./github";
|
|
|
|
type ParsedArgs = {
|
|
mode: "cron" | "event" | null;
|
|
issueNumber: number | null;
|
|
dryRunOverride: boolean | null;
|
|
};
|
|
|
|
function parseArgs(argv: string[]): ParsedArgs {
|
|
let mode: "cron" | "event" | null = null;
|
|
let issueNumber: number | null = null;
|
|
let dryRunOverride: boolean | null = null;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === "--mode") {
|
|
const next = argv[i + 1];
|
|
if (next === "cron" || next === "event") mode = next;
|
|
i++;
|
|
} else if (arg === "--issue") {
|
|
const next = argv[i + 1];
|
|
if (next) issueNumber = parseInt(next, 10);
|
|
i++;
|
|
} else if (arg === "--dry-run") {
|
|
dryRunOverride = true;
|
|
} else if (arg === "--no-dry-run") {
|
|
dryRunOverride = false;
|
|
}
|
|
}
|
|
return { mode, issueNumber, dryRunOverride };
|
|
}
|
|
|
|
function resolveDryRun(cliFlag: boolean | null, fromCli: boolean): boolean {
|
|
if (cliFlag !== null) return cliFlag;
|
|
if (fromCli) return true;
|
|
return (process.env.DRY_RUN ?? "true").toLowerCase() !== "false";
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
if (args.mode === null) {
|
|
throw new Error("--mode <cron|event> is required");
|
|
}
|
|
|
|
const token = process.env.GITHUB_TOKEN;
|
|
if (!token) throw new Error("GITHUB_TOKEN env var is required");
|
|
|
|
const cliInvocation =
|
|
args.dryRunOverride !== null || args.issueNumber !== null;
|
|
const dryRun = resolveDryRun(args.dryRunOverride, cliInvocation);
|
|
|
|
const octokit = makeOctokit(token);
|
|
|
|
if (!dryRun) await ensureAllLabels(octokit);
|
|
|
|
if (args.mode === "cron") {
|
|
console.log(`[issue-lifecycle] starting cron sweep (dry_run=${dryRun})`);
|
|
await runCron(octokit, dryRun);
|
|
return;
|
|
}
|
|
|
|
const issueNumber =
|
|
args.issueNumber ?? parseInt(process.env.ISSUE_NUMBER ?? "", 10);
|
|
if (!issueNumber || Number.isNaN(issueNumber)) {
|
|
throw new Error(
|
|
"Issue number missing — set ISSUE_NUMBER env or pass --issue <number>",
|
|
);
|
|
}
|
|
const eventName = process.env.EVENT_NAME ?? "";
|
|
if (!eventName) {
|
|
throw new Error("EVENT_NAME env var is required in event mode");
|
|
}
|
|
|
|
console.log(
|
|
`[issue-lifecycle] event "${eventName}" on issue #${issueNumber} (dry_run=${dryRun})`,
|
|
);
|
|
await runEvent(octokit, eventName, issueNumber, dryRun);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[issue-lifecycle] Unexpected error:", err);
|
|
process.exit(1);
|
|
});
|