From f366f762cca16daf478b31bfb0d1366fee34df60 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 29 May 2026 20:01:57 -0700 Subject: [PATCH] Issue Lifecycle Actions (#4071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .github/workflows/issue-lifecycle-cron.yml | 35 + .github/workflows/issue-lifecycle-events.yml | 36 + scripts/issue-lifecycle/README.md | 61 ++ scripts/issue-lifecycle/config.ts | 62 ++ scripts/issue-lifecycle/cron.ts | 72 ++ scripts/issue-lifecycle/events.ts | 74 ++ scripts/issue-lifecycle/github.ts | 263 +++++++ scripts/issue-lifecycle/index.ts | 85 +++ scripts/issue-lifecycle/package-lock.json | 717 ++++++++++++++++++ scripts/issue-lifecycle/package.json | 11 + .../rules/approval-label-sync.ts | 26 + .../rules/assignment-invariant.ts | 13 + scripts/issue-lifecycle/rules/stale-closer.ts | 63 ++ scripts/issue-lifecycle/tsconfig.json | 8 + 14 files changed, 1526 insertions(+) create mode 100644 .github/workflows/issue-lifecycle-cron.yml create mode 100644 .github/workflows/issue-lifecycle-events.yml create mode 100644 scripts/issue-lifecycle/README.md create mode 100644 scripts/issue-lifecycle/config.ts create mode 100644 scripts/issue-lifecycle/cron.ts create mode 100644 scripts/issue-lifecycle/events.ts create mode 100644 scripts/issue-lifecycle/github.ts create mode 100644 scripts/issue-lifecycle/index.ts create mode 100644 scripts/issue-lifecycle/package-lock.json create mode 100644 scripts/issue-lifecycle/package.json create mode 100644 scripts/issue-lifecycle/rules/approval-label-sync.ts create mode 100644 scripts/issue-lifecycle/rules/assignment-invariant.ts create mode 100644 scripts/issue-lifecycle/rules/stale-closer.ts create mode 100644 scripts/issue-lifecycle/tsconfig.json diff --git a/.github/workflows/issue-lifecycle-cron.yml b/.github/workflows/issue-lifecycle-cron.yml new file mode 100644 index 000000000..b5f3c4327 --- /dev/null +++ b/.github/workflows/issue-lifecycle-cron.yml @@ -0,0 +1,35 @@ +name: 🧹 Issue Lifecycle (cron) + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + issues: write + contents: read + +jobs: + sweep: + name: Daily sweep + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + persist-credentials: false + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install deps + working-directory: scripts/issue-lifecycle + run: npm install --no-audit --no-fund --ignore-scripts + - name: Run daily sweep + working-directory: scripts/issue-lifecycle + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ vars.ISSUE_LIFECYCLE_DRY_RUN || 'true' }} + run: npx tsx index.ts --mode cron diff --git a/.github/workflows/issue-lifecycle-events.yml b/.github/workflows/issue-lifecycle-events.yml new file mode 100644 index 000000000..53f04766d --- /dev/null +++ b/.github/workflows/issue-lifecycle-events.yml @@ -0,0 +1,36 @@ +name: 🪧 Issue Lifecycle (events) + +on: + issues: + types: [opened, assigned, milestoned, demilestoned] + +permissions: + issues: write + contents: read + +jobs: + handle: + name: Handle event + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + persist-credentials: false + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install deps + working-directory: scripts/issue-lifecycle + run: npm install --no-audit --no-fund --ignore-scripts + - name: Handle issue event + working-directory: scripts/issue-lifecycle + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ vars.ISSUE_LIFECYCLE_DRY_RUN || 'true' }} + EVENT_NAME: ${{ github.event.action }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: npx tsx index.ts --mode event diff --git a/scripts/issue-lifecycle/README.md b/scripts/issue-lifecycle/README.md new file mode 100644 index 000000000..77530b33d --- /dev/null +++ b/scripts/issue-lifecycle/README.md @@ -0,0 +1,61 @@ +# Issue Lifecycle + +Deterministic GitHub Actions that enforce OpenFront's issue lifecycle. No LLM calls — only the default `GITHUB_TOKEN`. + +## Rules + +1. **Stale closer** — daily cron. Unmilestoned issues get a warning at 7 days of inactivity, are closed at 14. Exempt: milestoned or `keep-open`-labelled issues. Assignees are not an exemption — if an unmilestoned issue somehow has assignees (event drift), Rule 2 unassigns them and Rule 1 still applies. +2. **Assignment invariant** — event + cron. You cannot assign a person to an unmilestoned issue. Violators are unassigned automatically. +3. **Approval label sync** — event + cron. `not-approved` and `approved` track milestone state (none = `not-approved`, any milestone = `approved`). + +See the parent spec for the full contribution model rationale. + +## Triggers + +- [issue-lifecycle-cron.yml](../../.github/workflows/issue-lifecycle-cron.yml) — daily at 06:00 UTC, plus `workflow_dispatch`. +- [issue-lifecycle-events.yml](../../.github/workflows/issue-lifecycle-events.yml) — `issues: [opened, assigned, milestoned, demilestoned]`. + +## Local testing + +```bash +cd scripts/issue-lifecycle +npm install +export GITHUB_TOKEN=ghp_... # PAT with repo scope + +# Full cron sweep against the real repo, dry-run (default for CLI): +npx tsx index.ts --mode cron + +# Single-issue event-mode dry-run (simulates the assigned event): +EVENT_NAME=assigned npx tsx index.ts --mode event --issue 1234 + +# Force live mode locally (BE CAREFUL — this will mutate the repo): +npx tsx index.ts --mode cron --no-dry-run +``` + +CLI invocations are dry-run by default. Pass `--no-dry-run` to apply. + +## Toggling dry-run in production + +1. Go to repo **Settings → Secrets and variables → Actions → Variables**. +2. Edit `ISSUE_LIFECYCLE_DRY_RUN`. +3. Set to `false` to make the Actions act for real; any other value (or unset) keeps them in dry-run mode. + +The default is `true` — both workflows log decisions but do not act until the maintainer flips the variable. + +## File layout + +- [config.ts](./config.ts) — constants, labels, comment templates +- [github.ts](./github.ts) — Octokit wrapper, action applier, label idempotent-creation +- [rules/approval-label-sync.ts](./rules/approval-label-sync.ts) — Rule 3 (pure function) +- [rules/assignment-invariant.ts](./rules/assignment-invariant.ts) — Rule 2 (pure function) +- [rules/stale-closer.ts](./rules/stale-closer.ts) — Rule 1 (async; reads comment history) +- [cron.ts](./cron.ts) — daily sweep orchestrator +- [events.ts](./events.ts) — event-mode dispatcher +- [index.ts](./index.ts) — entrypoint, arg parser + +## Notes + +- Labels are auto-created on every run (idempotent). The maintainer does not need to manually pre-create any of them. +- Rules act only on issues — PRs are filtered out (`pull_request` field check). +- API rate limit during the cron sweep causes the run to exit non-zero; the next day's cron retries. +- No Claude / LLM calls. Layer B will layer triage on top of this foundation. diff --git a/scripts/issue-lifecycle/config.ts b/scripts/issue-lifecycle/config.ts new file mode 100644 index 000000000..6a0c00684 --- /dev/null +++ b/scripts/issue-lifecycle/config.ts @@ -0,0 +1,62 @@ +export const REPO = { owner: "openfrontio", repo: "OpenFrontIO" } as const; + +export const STALE_WARN_DAYS = 7; +export const STALE_CLOSE_DAYS = 14; + +export const LABELS = { + NOT_APPROVED: "not-approved", + APPROVED: "approved", + STALE: "stale", + KEEP_OPEN: "keep-open", + NEEDS_INFO: "needs-info", + AUTO_CLOSED_STALE: "auto-closed-stale", +} as const; + +export const LABEL_COLORS: Record = { + [LABELS.NOT_APPROVED]: "B60205", + [LABELS.APPROVED]: "0E8A16", + [LABELS.STALE]: "BFD4F2", + [LABELS.KEEP_OPEN]: "FFFFFF", + [LABELS.NEEDS_INFO]: "FBCA04", + [LABELS.AUTO_CLOSED_STALE]: "586069", +}; + +export const LABEL_DESCRIPTIONS: Record = { + [LABELS.NOT_APPROVED]: `Not yet approved by maintainer; will auto-close after ${STALE_CLOSE_DAYS} days if no milestone is set`, + [LABELS.APPROVED]: "Maintainer has assigned a milestone — work is approved", + [LABELS.STALE]: `No activity recently; will auto-close in ${STALE_CLOSE_DAYS - STALE_WARN_DAYS} days unless updated`, + [LABELS.KEEP_OPEN]: "Exempt from auto-close", + [LABELS.NEEDS_INFO]: + "Reporter was asked for more info; no special timer — standard stale-close still applies", + [LABELS.AUTO_CLOSED_STALE]: "Closed automatically due to inactivity", +}; + +export const COMMENTS = { + STALE_WARNING: (author: string): string => + `Hi @${author}, this issue hasn't had activity in ${STALE_WARN_DAYS} days and doesn't yet have a milestone assigned. + +If a maintainer doesn't milestone this issue (or you don't update it) within the next ${STALE_CLOSE_DAYS - STALE_WARN_DAYS} days, it will be **automatically closed**. + +If you believe this issue is important, consider: +- Adding more context, repro steps, or examples +- Discussing in our [Discord](https://discord.gg/K9zernJB5z) +- Requesting the \`${LABELS.KEEP_OPEN}\` label if it should be exempt from auto-close + +— *Automated. See [CONTRIBUTING.md](https://github.com/${REPO.owner}/${REPO.repo}/blob/main/CONTRIBUTING.md).*`, + + AUTO_CLOSED_STALE: (author: string): string => + `Closing this issue as it hasn't been milestoned and has had no recent activity. + +This isn't a judgment of the issue's merit — just routine triage. @${author}, if you believe this should be reconsidered, please reopen with additional context or discuss in [Discord](https://discord.gg/K9zernJB5z). + +— *Automated.*`, + + UNASSIGNED_NO_MILESTONE: (assignees: string[]): string => + `${assignees.map((u) => "@" + u).join(", ")} — you've been unassigned from this issue automatically because it doesn't have a milestone set. + +In OpenFront's workflow, an issue must have a milestone (\`backlog\` or a version like \`v30\`) before anyone can be assigned. This ensures only approved work has people working on it. + +If this is approved work, a maintainer needs to milestone the issue first, then re-assign you. + +— *Automated. See [CONTRIBUTING.md](https://github.com/${REPO.owner}/${REPO.repo}/blob/main/CONTRIBUTING.md).*`, +} as const; diff --git a/scripts/issue-lifecycle/cron.ts b/scripts/issue-lifecycle/cron.ts new file mode 100644 index 000000000..0b2f00719 --- /dev/null +++ b/scripts/issue-lifecycle/cron.ts @@ -0,0 +1,72 @@ +import type { Octokit } from "@octokit/rest"; +import { + type Action, + applyActions, + describeAction, + iterateOpenIssues, +} from "./github"; +import { syncApprovalLabel } from "./rules/approval-label-sync"; +import { enforceAssignmentInvariant } from "./rules/assignment-invariant"; +import { checkStale } from "./rules/stale-closer"; + +export async function runCron( + octokit: Octokit, + dryRun: boolean, +): Promise { + const now = new Date(); + let issueCount = 0; + let actedOnCount = 0; + + for await (const issue of iterateOpenIssues(octokit)) { + issueCount++; + const prefix = `[issue-lifecycle] issue #${issue.number}`; + + try { + const stale = await checkStale(issue, octokit, now); + if (stale.length > 0) { + logActions(`${prefix} — rule: stale-closer`, stale); + await maybeApply(octokit, issue.number, stale, dryRun, prefix); + actedOnCount++; + } + + const assignment = enforceAssignmentInvariant(issue); + if (assignment.length > 0) { + logActions(`${prefix} — rule: assignment-invariant`, assignment); + await maybeApply(octokit, issue.number, assignment, dryRun, prefix); + actedOnCount++; + } + + const labelSync = syncApprovalLabel(issue); + if (labelSync.length > 0) { + logActions(`${prefix} — rule: approval-label-sync`, labelSync); + await maybeApply(octokit, issue.number, labelSync, dryRun, prefix); + actedOnCount++; + } + } catch (err) { + console.error(`${prefix} — error: ${err}`); + } + } + + console.log( + `[issue-lifecycle] cron sweep complete — issues scanned: ${issueCount}, rules triggered: ${actedOnCount}, dry_run: ${dryRun}`, + ); +} + +function logActions(prefix: string, actions: Action[]): void { + const summary = actions.map(describeAction).join(", "); + console.log(`${prefix} — actions: ${summary}`); +} + +async function maybeApply( + octokit: Octokit, + issueNumber: number, + actions: Action[], + dryRun: boolean, + prefix: string, +): Promise { + if (dryRun) { + console.log(`${prefix} — DRY_RUN: not applied`); + return; + } + await applyActions(octokit, issueNumber, actions); +} diff --git a/scripts/issue-lifecycle/events.ts b/scripts/issue-lifecycle/events.ts new file mode 100644 index 000000000..2ec75c5cc --- /dev/null +++ b/scripts/issue-lifecycle/events.ts @@ -0,0 +1,74 @@ +import type { Octokit } from "@octokit/rest"; +import { type Action, applyActions, describeAction, getIssue } from "./github"; +import { syncApprovalLabel } from "./rules/approval-label-sync"; +import { enforceAssignmentInvariant } from "./rules/assignment-invariant"; + +export type EventName = "opened" | "assigned" | "milestoned" | "demilestoned"; + +export function isKnownEvent(name: string): name is EventName { + return ( + name === "opened" || + name === "assigned" || + name === "milestoned" || + name === "demilestoned" + ); +} + +export async function runEvent( + octokit: Octokit, + eventName: string, + issueNumber: number, + dryRun: boolean, +): Promise { + if (!isKnownEvent(eventName)) { + console.log( + `[issue-lifecycle] issue #${issueNumber} — event "${eventName}" not handled by this Action`, + ); + return; + } + + const issue = await getIssue(octokit, issueNumber); + if (!issue) return; + if (issue.is_pull_request) { + console.log(`[issue-lifecycle] issue #${issueNumber} is a PR — skipping`); + return; + } + + const prefix = `[issue-lifecycle] issue #${issueNumber} (event: ${eventName})`; + const buckets: { rule: string; actions: Action[] }[] = []; + + if (eventName === "assigned" || eventName === "demilestoned") { + buckets.push({ + rule: "assignment-invariant", + actions: enforceAssignmentInvariant(issue), + }); + } + + if ( + eventName === "opened" || + eventName === "milestoned" || + eventName === "demilestoned" + ) { + buckets.push({ + rule: "approval-label-sync", + actions: syncApprovalLabel(issue), + }); + } + + let acted = false; + for (const { rule, actions } of buckets) { + if (actions.length === 0) continue; + acted = true; + console.log( + `${prefix} — rule: ${rule} — actions: ${actions.map(describeAction).join(", ")}`, + ); + if (dryRun) { + console.log(`${prefix} — DRY_RUN: not applied`); + continue; + } + await applyActions(octokit, issueNumber, actions); + } + if (!acted) { + console.log(`${prefix} — no actions needed`); + } +} diff --git a/scripts/issue-lifecycle/github.ts b/scripts/issue-lifecycle/github.ts new file mode 100644 index 000000000..30a37da7d --- /dev/null +++ b/scripts/issue-lifecycle/github.ts @@ -0,0 +1,263 @@ +import { Octokit } from "@octokit/rest"; +import { LABEL_COLORS, LABEL_DESCRIPTIONS, LABELS, REPO } from "./config"; + +export type Issue = { + number: number; + state: "open" | "closed"; + milestone: { number: number; title: string } | null; + labels: string[]; + assignees: { login: string }[]; + user: { login: string; type: string } | null; + created_at: string; + is_pull_request: boolean; +}; + +export type IssueComment = { + created_at: string; + user: { login: string; type: string } | null; +}; + +export type Action = + | { type: "add_label"; label: string } + | { type: "remove_label"; label: string } + | { type: "comment"; body: string } + | { type: "unassign"; users: string[] } + | { type: "close"; reason: "not_planned" | "completed" }; + +export function makeOctokit(token: string): Octokit { + return new Octokit({ auth: token }); +} + +export function isBotUser( + user: { login: string; type: string } | null, +): boolean { + if (!user) return false; + if (user.type === "Bot") return true; + if (user.login.endsWith("[bot]")) return true; + return false; +} + +function normalizeIssue(data: { + number: number; + state: string; + milestone: { number: number; title: string } | null; + labels: ({ name?: string } | string)[]; + assignees?: { login: string }[] | null; + user: { login: string; type: string } | null; + created_at: string; + pull_request?: unknown; +}): Issue { + return { + number: data.number, + state: data.state === "closed" ? "closed" : "open", + milestone: data.milestone + ? { number: data.milestone.number, title: data.milestone.title } + : null, + labels: (data.labels ?? []) + .map((l) => (typeof l === "string" ? l : (l.name ?? ""))) + .filter((name) => name.length > 0), + assignees: (data.assignees ?? []).map((a) => ({ login: a.login })), + user: data.user ? { login: data.user.login, type: data.user.type } : null, + created_at: data.created_at, + is_pull_request: + data.pull_request !== undefined && data.pull_request !== null, + }; +} + +export async function getIssue( + octokit: Octokit, + issueNumber: number, +): Promise { + try { + const { data } = await octokit.rest.issues.get({ + ...REPO, + issue_number: issueNumber, + }); + return normalizeIssue(data); + } catch (err) { + if (isStatus(err, 404)) { + console.warn(`[issue-lifecycle] Issue #${issueNumber} not found`); + return null; + } + throw err; + } +} + +export async function* iterateOpenIssues( + octokit: Octokit, +): AsyncGenerator { + const iterator = octokit.paginate.iterator(octokit.rest.issues.listForRepo, { + ...REPO, + state: "open", + per_page: 100, + }); + for await (const { data } of iterator) { + for (const raw of data) { + const issue = normalizeIssue(raw); + if (issue.is_pull_request) continue; + yield issue; + } + } +} + +export async function listIssueComments( + octokit: Octokit, + issueNumber: number, +): Promise { + const comments = await octokit.paginate(octokit.rest.issues.listComments, { + ...REPO, + issue_number: issueNumber, + per_page: 100, + }); + return comments.map((c) => ({ + created_at: c.created_at, + user: c.user ? { login: c.user.login, type: c.user.type } : null, + })); +} + +export async function ensureLabel( + octokit: Octokit, + name: string, +): Promise { + try { + await octokit.rest.issues.getLabel({ ...REPO, name }); + } catch (err) { + if (!isStatus(err, 404)) throw err; + try { + await octokit.rest.issues.createLabel({ + ...REPO, + name, + color: LABEL_COLORS[name] ?? "CCCCCC", + description: LABEL_DESCRIPTIONS[name] ?? "", + }); + } catch (createErr) { + // 422 = concurrent run created the label between our get and create. + if (!isStatus(createErr, 422)) throw createErr; + } + } +} + +export async function ensureAllLabels(octokit: Octokit): Promise { + for (const name of Object.values(LABELS)) { + await ensureLabel(octokit, name); + } +} + +export async function addLabel( + octokit: Octokit, + issueNumber: number, + label: string, +): Promise { + await octokit.rest.issues.addLabels({ + ...REPO, + issue_number: issueNumber, + labels: [label], + }); +} + +export async function removeLabel( + octokit: Octokit, + issueNumber: number, + label: string, +): Promise { + try { + await octokit.rest.issues.removeLabel({ + ...REPO, + issue_number: issueNumber, + name: label, + }); + } catch (err) { + // 404 means label wasn't on the issue — treat as success. + if (!isStatus(err, 404)) throw err; + } +} + +export async function postComment( + octokit: Octokit, + issueNumber: number, + body: string, +): Promise { + await octokit.rest.issues.createComment({ + ...REPO, + issue_number: issueNumber, + body, + }); +} + +export async function unassignUsers( + octokit: Octokit, + issueNumber: number, + users: string[], +): Promise { + await octokit.rest.issues.removeAssignees({ + ...REPO, + issue_number: issueNumber, + assignees: users, + }); +} + +export async function closeIssue( + octokit: Octokit, + issueNumber: number, + reason: "not_planned" | "completed", +): Promise { + await octokit.rest.issues.update({ + ...REPO, + issue_number: issueNumber, + state: "closed", + state_reason: reason, + }); +} + +export async function applyActions( + octokit: Octokit, + issueNumber: number, + actions: Action[], +): Promise { + for (const action of actions) { + switch (action.type) { + case "add_label": + await ensureLabel(octokit, action.label); + await addLabel(octokit, issueNumber, action.label); + break; + case "remove_label": + await removeLabel(octokit, issueNumber, action.label); + break; + case "comment": + await postComment(octokit, issueNumber, action.body); + break; + case "unassign": + if (action.users.length > 0) { + await unassignUsers(octokit, issueNumber, action.users); + } + break; + case "close": + await closeIssue(octokit, issueNumber, action.reason); + break; + } + } +} + +export function describeAction(action: Action): string { + switch (action.type) { + case "add_label": + return `add_label(${action.label})`; + case "remove_label": + return `remove_label(${action.label})`; + case "comment": + return `comment`; + case "unassign": + return `unassign(${action.users.join(",")})`; + case "close": + return `close(${action.reason})`; + } +} + +function isStatus(err: unknown, status: number): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: unknown }).status === status + ); +} diff --git a/scripts/issue-lifecycle/index.ts b/scripts/issue-lifecycle/index.ts new file mode 100644 index 000000000..3b1563179 --- /dev/null +++ b/scripts/issue-lifecycle/index.ts @@ -0,0 +1,85 @@ +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 { + const args = parseArgs(process.argv.slice(2)); + + if (args.mode === null) { + throw new Error("--mode 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 ", + ); + } + 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); +}); diff --git a/scripts/issue-lifecycle/package-lock.json b/scripts/issue-lifecycle/package-lock.json new file mode 100644 index 000000000..09c3aa617 --- /dev/null +++ b/scripts/issue-lifecycle/package-lock.json @@ -0,0 +1,717 @@ +{ + "name": "openfrontio-issue-lifecycle", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openfrontio-issue-lifecycle", + "version": "0.0.0", + "dependencies": { + "@octokit/rest": "^21.0.2", + "tsx": "^4.21.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", + "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", + "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.2.2", + "@octokit/request": "^9.2.3", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", + "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", + "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", + "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^6.1.4", + "@octokit/plugin-paginate-rest": "^11.4.2", + "@octokit/plugin-request-log": "^5.3.1", + "@octokit/plugin-rest-endpoint-methods": "^13.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "license": "Apache-2.0" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + } + } +} diff --git a/scripts/issue-lifecycle/package.json b/scripts/issue-lifecycle/package.json new file mode 100644 index 000000000..095c6ae13 --- /dev/null +++ b/scripts/issue-lifecycle/package.json @@ -0,0 +1,11 @@ +{ + "name": "openfrontio-issue-lifecycle", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Deterministic GitHub Actions for issue lifecycle (stale closer, assignment invariant, approval-label sync). See README.md.", + "dependencies": { + "@octokit/rest": "^21.0.2", + "tsx": "^4.21.0" + } +} diff --git a/scripts/issue-lifecycle/rules/approval-label-sync.ts b/scripts/issue-lifecycle/rules/approval-label-sync.ts new file mode 100644 index 000000000..a7da478c7 --- /dev/null +++ b/scripts/issue-lifecycle/rules/approval-label-sync.ts @@ -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; +} diff --git a/scripts/issue-lifecycle/rules/assignment-invariant.ts b/scripts/issue-lifecycle/rules/assignment-invariant.ts new file mode 100644 index 000000000..bd81d2169 --- /dev/null +++ b/scripts/issue-lifecycle/rules/assignment-invariant.ts @@ -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) }, + ]; +} diff --git a/scripts/issue-lifecycle/rules/stale-closer.ts b/scripts/issue-lifecycle/rules/stale-closer.ts new file mode 100644 index 000000000..fbfb22609 --- /dev/null +++ b/scripts/issue-lifecycle/rules/stale-closer.ts @@ -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 { + 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 []; +} diff --git a/scripts/issue-lifecycle/tsconfig.json b/scripts/issue-lifecycle/tsconfig.json new file mode 100644 index 000000000..9da0f8638 --- /dev/null +++ b/scripts/issue-lifecycle/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": {} + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +}