mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 18:52:18 +00:00
Issue Lifecycle Actions (#4071)
## 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
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { LABELS } from "../config";
|
||||
import type { Action, Issue } from "../github";
|
||||
|
||||
export function syncApprovalLabel(issue: Issue): Action[] {
|
||||
const hasApproved = issue.labels.includes(LABELS.APPROVED);
|
||||
const hasNotApproved = issue.labels.includes(LABELS.NOT_APPROVED);
|
||||
const milestoned = issue.milestone !== null;
|
||||
const actions: Action[] = [];
|
||||
|
||||
if (milestoned) {
|
||||
if (!hasApproved) {
|
||||
actions.push({ type: "add_label", label: LABELS.APPROVED });
|
||||
}
|
||||
if (hasNotApproved) {
|
||||
actions.push({ type: "remove_label", label: LABELS.NOT_APPROVED });
|
||||
}
|
||||
} else {
|
||||
if (!hasNotApproved) {
|
||||
actions.push({ type: "add_label", label: LABELS.NOT_APPROVED });
|
||||
}
|
||||
if (hasApproved) {
|
||||
actions.push({ type: "remove_label", label: LABELS.APPROVED });
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { COMMENTS } from "../config";
|
||||
import type { Action, Issue } from "../github";
|
||||
|
||||
export function enforceAssignmentInvariant(issue: Issue): Action[] {
|
||||
if (issue.milestone !== null) return [];
|
||||
if (issue.assignees.length === 0) return [];
|
||||
|
||||
const users = issue.assignees.map((a) => a.login);
|
||||
return [
|
||||
{ type: "unassign", users },
|
||||
{ type: "comment", body: COMMENTS.UNASSIGNED_NO_MILESTONE(users) },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { COMMENTS, LABELS, STALE_CLOSE_DAYS, STALE_WARN_DAYS } from "../config";
|
||||
import {
|
||||
type Action,
|
||||
type Issue,
|
||||
type IssueComment,
|
||||
isBotUser,
|
||||
listIssueComments,
|
||||
} from "../github";
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function daysBetween(fromIso: string, now: Date): number {
|
||||
const from = new Date(fromIso).getTime();
|
||||
return (now.getTime() - from) / MS_PER_DAY;
|
||||
}
|
||||
|
||||
export function latestNonBotActivityIso(
|
||||
issue: Issue,
|
||||
comments: IssueComment[],
|
||||
): string {
|
||||
let latest = issue.created_at;
|
||||
for (const c of comments) {
|
||||
if (isBotUser(c.user)) continue;
|
||||
if (new Date(c.created_at).getTime() > new Date(latest).getTime()) {
|
||||
latest = c.created_at;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
export async function checkStale(
|
||||
issue: Issue,
|
||||
octokit: Octokit,
|
||||
now: Date = new Date(),
|
||||
): Promise<Action[]> {
|
||||
if (issue.milestone !== null) return [];
|
||||
if (issue.labels.includes(LABELS.KEEP_OPEN)) return [];
|
||||
|
||||
const comments = await listIssueComments(octokit, issue.number);
|
||||
const lastActivityIso = latestNonBotActivityIso(issue, comments);
|
||||
const daysSinceActivity = daysBetween(lastActivityIso, now);
|
||||
const hasStaleLabel = issue.labels.includes(LABELS.STALE);
|
||||
const authorLogin = issue.user?.login ?? "there";
|
||||
|
||||
if (hasStaleLabel && daysSinceActivity < STALE_WARN_DAYS) {
|
||||
return [{ type: "remove_label", label: LABELS.STALE }];
|
||||
}
|
||||
if (hasStaleLabel && daysSinceActivity >= STALE_CLOSE_DAYS) {
|
||||
return [
|
||||
{ type: "add_label", label: LABELS.AUTO_CLOSED_STALE },
|
||||
{ type: "comment", body: COMMENTS.AUTO_CLOSED_STALE(authorLogin) },
|
||||
{ type: "close", reason: "not_planned" },
|
||||
];
|
||||
}
|
||||
if (!hasStaleLabel && daysSinceActivity >= STALE_WARN_DAYS) {
|
||||
return [
|
||||
{ type: "add_label", label: LABELS.STALE },
|
||||
{ type: "comment", body: COMMENTS.STALE_WARNING(authorLogin) },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
Reference in New Issue
Block a user