mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +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,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.
|
||||
@@ -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<string, string> = {
|
||||
[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<string, string> = {
|
||||
[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;
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
if (dryRun) {
|
||||
console.log(`${prefix} — DRY_RUN: not applied`);
|
||||
return;
|
||||
}
|
||||
await applyActions(octokit, issueNumber, actions);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -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<Issue | null> {
|
||||
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<Issue> {
|
||||
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<IssueComment[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (const name of Object.values(LABELS)) {
|
||||
await ensureLabel(octokit, name);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addLabel(
|
||||
octokit: Octokit,
|
||||
issueNumber: number,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
await octokit.rest.issues.addLabels({
|
||||
...REPO,
|
||||
issue_number: issueNumber,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeLabel(
|
||||
octokit: Octokit,
|
||||
issueNumber: number,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await octokit.rest.issues.createComment({
|
||||
...REPO,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unassignUsers(
|
||||
octokit: Octokit,
|
||||
issueNumber: number,
|
||||
users: string[],
|
||||
): Promise<void> {
|
||||
await octokit.rest.issues.removeAssignees({
|
||||
...REPO,
|
||||
issue_number: issueNumber,
|
||||
assignees: users,
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeIssue(
|
||||
octokit: Octokit,
|
||||
issueNumber: number,
|
||||
reason: "not_planned" | "completed",
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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<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);
|
||||
});
|
||||
+717
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user