mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:00:44 +00:00
claude
This commit is contained in:
@@ -44,6 +44,10 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm ci
|
||||
# The claude-cron package has its own node_modules with the SDKs that
|
||||
# its tests transitively need. Root vitest discovers those tests but
|
||||
# can't resolve their imports unless this is installed.
|
||||
- run: cd scripts/claude-cron && npm ci --no-audit --no-fund --ignore-scripts
|
||||
- run: npm run test:coverage
|
||||
|
||||
eslint:
|
||||
@@ -57,6 +61,7 @@ jobs:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: cd scripts/claude-cron && npm ci --no-audit --no-fund --ignore-scripts
|
||||
- run: npx eslint --format gha
|
||||
|
||||
prettier:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: 🤖 Claude Triage & Digest (cron)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
run:
|
||||
name: Daily triage and digest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
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/claude-cron
|
||||
run: npm ci --no-audit --no-fund --ignore-scripts
|
||||
- name: Run Claude triage and digest
|
||||
working-directory: scripts/claude-cron
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DRY_RUN: ${{ vars.CLAUDE_CRON_DRY_RUN || 'true' }}
|
||||
run: npx tsx index.ts
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
"lint:fix": "eslint --fix",
|
||||
"prepare": "husky",
|
||||
"gen-maps": "cd map-generator && go run . && npm run format",
|
||||
"inst": "npm ci --ignore-scripts"
|
||||
"inst": "npm ci --ignore-scripts && (cd scripts/claude-cron && npm ci --no-audit --no-fund --ignore-scripts)"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": [
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Claude Triage & Daily Digest
|
||||
|
||||
Daily cron that uses Claude (Anthropic API) to triage incoming GitHub issues and produce a maintainer digest. Builds on top of [Issue Lifecycle (Layer A)](../issue-lifecycle/README.md), which it never overrides.
|
||||
|
||||
## What it does
|
||||
|
||||
Once a day, [the workflow](../../.github/workflows/claude-cron.yml) runs two passes:
|
||||
|
||||
1. **Pass 1 — Triage.** For each open issue without the `claude-triaged` label (and not authored by an org member), Claude classifies the issue. Bugs and small QoL improvements get labelled; out-of-scope reports (feature requests, support questions, billing, security, translation) get auto-closed with a redirect comment.
|
||||
2. **Pass 2 — Digest.** Claude reviews every open PR awaiting maintainer review, then composes a markdown digest of what needs your attention. The digest gets posted as a comment on a pinned tracking issue, and optionally to a Discord webhook.
|
||||
|
||||
Both passes default to dry-run.
|
||||
|
||||
## Rules and guarantees
|
||||
|
||||
- **Idempotent.** Once an issue has the `claude-triaged` label, it is never re-processed.
|
||||
- **Conservative.** Auto-close decisions require `confidence: high` from Claude. Anything else stays open with `claude-uncertain`.
|
||||
- **Advisor-only on duplicates.** Suspected duplicates get `possible-duplicate` and a comment — never auto-closed.
|
||||
- **Hands off Layer A.** Adding or removing labels in `FORBIDDEN_LABELS` (`approved`, `not-approved`, `stale`, `keep-open`, `auto-closed-stale`) throws at runtime.
|
||||
- **No milestones, no assignments, no reopens.** Claude can't take these actions.
|
||||
|
||||
## Triggers
|
||||
|
||||
- [`claude-cron.yml`](../../.github/workflows/claude-cron.yml) — daily at 06:00 UTC, plus `workflow_dispatch`.
|
||||
|
||||
## Local testing
|
||||
|
||||
```bash
|
||||
cd scripts/claude-cron
|
||||
npm install --no-audit --no-fund --ignore-scripts
|
||||
|
||||
export GITHUB_TOKEN=ghp_... # PAT with repo scope (issues:write)
|
||||
export ANTHROPIC_API_KEY=sk-ant-... # from console.anthropic.com
|
||||
|
||||
# Triage a single issue and print the decision (no apply):
|
||||
npx tsx index.ts --issue 1234
|
||||
|
||||
# Run the full daily sweep in dry-run (logs everything, applies nothing):
|
||||
npx tsx index.ts
|
||||
|
||||
# Run the full sweep for real (BE CAREFUL — this mutates the repo and posts a digest):
|
||||
npx tsx index.ts --no-dry-run
|
||||
```
|
||||
|
||||
CLI invocations (anything with `--issue` or `--dry-run` / `--no-dry-run`) default to dry-run unless `--no-dry-run` is given explicitly. Cron invocations (no CLI flags) read `DRY_RUN` from env.
|
||||
|
||||
Run unit tests from the repo root:
|
||||
|
||||
```bash
|
||||
npx vitest run --dir scripts/claude-cron/tests
|
||||
```
|
||||
|
||||
## Toggling dry-run in production
|
||||
|
||||
1. Go to repo **Settings → Secrets and variables → Actions → Variables**.
|
||||
2. Set `CLAUDE_CRON_DRY_RUN`.
|
||||
3. Set to `false` to act for real; any other value (or unset) keeps it in dry-run mode.
|
||||
|
||||
The default is `true`.
|
||||
|
||||
## Pre-launch checklist
|
||||
|
||||
Before flipping `CLAUDE_CRON_DRY_RUN` to `false`, the maintainer should:
|
||||
|
||||
- [ ] Set `ANTHROPIC_API_KEY` as a repo secret.
|
||||
- [ ] Fill in the TODO constants in [`config.ts`](./config.ts):
|
||||
- `URLS.GAME_DISCORD`
|
||||
- `URLS.SECURITY_EMAIL` (or publish a `SECURITY.md` and link it)
|
||||
- Decide on `ENABLE_TRANSLATION_CLOSE` (default `true`)
|
||||
- [ ] Create a tracking issue for the digest, pin it, and set `DIGEST_ISSUE_NUMBER` in `config.ts`. Until set, the digest is logged to stdout instead of posted.
|
||||
- [ ] _(Optional)_ Set `DISCORD_WEBHOOK_URL` as a repo secret if Discord posting is desired.
|
||||
- [ ] Watch dry-run logs in Actions output for at least one week. Verify:
|
||||
- Classification looks reasonable for the actual issues received.
|
||||
- Auto-close decisions are ones you'd agree with.
|
||||
- The digest format is useful.
|
||||
- Token usage is in the expected range (logged per call).
|
||||
- [ ] Tune the triage system prompt in [`claude.ts`](./claude.ts) based on observed misclassifications.
|
||||
- [ ] Flip `CLAUDE_CRON_DRY_RUN` repo variable to `'false'`.
|
||||
|
||||
## File layout
|
||||
|
||||
- [`index.ts`](./index.ts) — entrypoint and CLI arg parsing
|
||||
- [`config.ts`](./config.ts) — constants, labels, comment templates, forbidden-label list
|
||||
- [`github.ts`](./github.ts) — Octokit wrapper, label idempotency, forbidden-label guard
|
||||
- [`claude.ts`](./claude.ts) — Anthropic SDK wrapper, system prompts, tool schemas
|
||||
- [`pass1-triage.ts`](./pass1-triage.ts) — Pass 1: triage logic, confidence threshold enforcement
|
||||
- [`pr-reviewer.ts`](./pr-reviewer.ts) — Phase 2a: per-PR Claude review for the digest
|
||||
- [`pass2-digest.ts`](./pass2-digest.ts) — Pass 2 orchestrator: assembles inputs, calls Claude, posts
|
||||
- [`digest-formatter.ts`](./digest-formatter.ts) — input shaping + deterministic fallback markdown
|
||||
- [`discord.ts`](./discord.ts) — optional Discord webhook poster
|
||||
|
||||
## Cost notes
|
||||
|
||||
The system prompts and tool schemas are designed so each call is small (~1–3k input tokens, <500 output). Triage runs once per new outside-author issue (~3–10/day in steady state); PR review runs once per non-draft open PR awaiting review. A daily run typically uses 10–20 Claude calls. Hard cap is `MAX_CALLS_PER_RUN = 100`.
|
||||
|
||||
Token usage is logged per call (`tokens in=X out=Y`); grep for it in workflow output to diagnose cost spikes.
|
||||
|
||||
## Editing the prompts
|
||||
|
||||
The triage system prompt is the highest-leverage knob. It lives in [`claude.ts`](./claude.ts) as `TRIAGE_SYSTEM_PROMPT`. Tune it when:
|
||||
|
||||
- You see Claude consistently miscategorizing a kind of issue.
|
||||
- A new category of out-of-scope report starts appearing (e.g., bot reports, spam).
|
||||
- You want to adjust the bar between `qol-improvement` and `feature`.
|
||||
|
||||
Always test prompt changes locally with `--issue <num>` against a few representative real issues before merging.
|
||||
@@ -0,0 +1,622 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type { AreaLabel, Classification } from "./config";
|
||||
import {
|
||||
CLAUDE_MODEL,
|
||||
DIGEST_LOOKBACK_HOURS,
|
||||
MAINTAINER_LOGIN,
|
||||
} from "./config";
|
||||
import type { Issue, PullRequest } from "./github";
|
||||
|
||||
export type Confidence = "low" | "medium" | "high";
|
||||
|
||||
export type TriageDecision = {
|
||||
classification: Classification;
|
||||
confidence: Confidence;
|
||||
reasoning: string;
|
||||
suggested_area?: AreaLabel;
|
||||
duplicate_of?: number;
|
||||
clarifying_questions?: string[];
|
||||
};
|
||||
|
||||
export type PRReview = {
|
||||
summary: string;
|
||||
risk: "low" | "medium" | "high";
|
||||
tests_present: "yes" | "no" | "n/a";
|
||||
scope_match: "matches" | "expanded" | "no_linked_issue";
|
||||
ai_smell?: "low" | "medium" | "high";
|
||||
concerns?: string[];
|
||||
};
|
||||
|
||||
export type DigestResult = {
|
||||
markdown: string;
|
||||
maintainer_actions?: string[];
|
||||
};
|
||||
|
||||
export type UsageStats = {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
|
||||
export function makeAnthropic(apiKey: string): Anthropic {
|
||||
return new Anthropic({ apiKey });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool schemas — force structured output via tool_choice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TRIAGE_TOOL = {
|
||||
name: "submit_triage_decision",
|
||||
description: "Submit your triage decision for this GitHub issue.",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
required: ["classification", "confidence", "reasoning"],
|
||||
properties: {
|
||||
classification: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"bug",
|
||||
"qol-improvement",
|
||||
"duplicate",
|
||||
"needs-info",
|
||||
"feature",
|
||||
"question",
|
||||
"support",
|
||||
"billing",
|
||||
"security",
|
||||
"translation",
|
||||
"uncertain",
|
||||
],
|
||||
description: "The category this issue falls into.",
|
||||
},
|
||||
confidence: {
|
||||
type: "string",
|
||||
enum: ["low", "medium", "high"],
|
||||
description:
|
||||
"How confident you are. Auto-close categories require 'high' confidence to take action; below that, the system downgrades to 'uncertain'.",
|
||||
},
|
||||
reasoning: {
|
||||
type: "string",
|
||||
description:
|
||||
"One or two sentences explaining the classification. Will be logged for audit.",
|
||||
},
|
||||
suggested_area: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"area:client",
|
||||
"area:core",
|
||||
"area:server",
|
||||
"area:map-generator",
|
||||
"area:other",
|
||||
],
|
||||
description:
|
||||
"For 'bug' or 'qol-improvement' only. Best-guess area of the codebase.",
|
||||
},
|
||||
duplicate_of: {
|
||||
type: "integer",
|
||||
description:
|
||||
"For 'duplicate' classification only. The issue number this duplicates. Must be drawn from the provided recent-issues list and must be currently open.",
|
||||
},
|
||||
clarifying_questions: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"For 'needs-info' classification only. Specific questions to ask the reporter — not generic 'please provide more info'. Each item is one question.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const PR_REVIEW_TOOL = {
|
||||
name: "submit_pr_review",
|
||||
description: "Submit a quick-review summary of a GitHub pull request.",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
required: ["summary", "risk", "tests_present", "scope_match"],
|
||||
properties: {
|
||||
summary: {
|
||||
type: "string",
|
||||
description:
|
||||
"One sentence (max ~80 chars) describing what this PR does.",
|
||||
},
|
||||
risk: {
|
||||
type: "string",
|
||||
enum: ["low", "medium", "high"],
|
||||
description:
|
||||
"Risk assessment based on what code is touched and how. src/core changes are higher risk; UI-only changes are lower.",
|
||||
},
|
||||
tests_present: {
|
||||
type: "string",
|
||||
enum: ["yes", "no", "n/a"],
|
||||
description:
|
||||
"For PRs touching src/core, tests are required. 'n/a' if no src/core changes.",
|
||||
},
|
||||
scope_match: {
|
||||
type: "string",
|
||||
enum: ["matches", "expanded", "no_linked_issue"],
|
||||
description:
|
||||
"Does the PR match the scope of its linked issue? 'expanded' if it does extra things beyond what the issue asked for.",
|
||||
},
|
||||
ai_smell: {
|
||||
type: "string",
|
||||
enum: ["low", "medium", "high"],
|
||||
description:
|
||||
"Likelihood this is unsupervised LLM output. Look for: invented APIs, inconsistent style, over-commented obvious lines, defensive code around impossible cases.",
|
||||
},
|
||||
concerns: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Specific things the maintainer should look at first. Empty if none.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const DIGEST_TOOL = {
|
||||
name: "submit_digest",
|
||||
description: "Submit the daily triage digest for the maintainer.",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
required: ["markdown"],
|
||||
properties: {
|
||||
markdown: {
|
||||
type: "string",
|
||||
description:
|
||||
"The full digest as GitHub-flavored markdown. Follow the format specified in the system prompt. Omit empty sections.",
|
||||
},
|
||||
maintainer_actions: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Concrete suggested actions for the maintainer this morning (e.g., 'Milestone #123 to backlog').",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TRIAGE_SYSTEM_PROMPT = `You are the triage assistant for OpenFront.io, a browser-based real-time strategy game written in TypeScript. The codebase has four main components:
|
||||
|
||||
- \`src/client/\` — rendering (WebGL), UI (Lit web components + Tailwind), WebSocket comms.
|
||||
- \`src/core/\` — deterministic game simulation (no external deps, runs in a Web Worker).
|
||||
- \`src/server/\` — Node.js/Express/ws coordinator.
|
||||
- \`map-generator/\` — Go program that generates map binaries.
|
||||
|
||||
Your job is to classify an incoming issue into exactly one category and report your confidence. The system applies actions automatically based on your decision. Be conservative — when uncertain, return \`uncertain\`. False-closing a real bug is worse than leaving an uncertain issue open for the maintainer to look at.
|
||||
|
||||
## Classification taxonomy
|
||||
|
||||
**bug** — Reproducible defect in existing functionality. Crashes, wrong rendering, server errors, broken game logic. The game does X but should do Y.
|
||||
|
||||
**qol-improvement** — Small-scope work reasonably completable by one contributor in under a week. Includes hotkey additions, confirmation dialogs, UI tweaks, minor behavior changes, **small new features that don't require new game systems**, sound/visual polish, and performance improvements to existing code paths. The line is *scope/effort*, not "is it new functionality."
|
||||
|
||||
**duplicate** — Same issue (semantically, not lexically) exists in the recent open-issues context. You MUST cite the duplicate issue number. Do not suggest duplicates of closed issues — they're parked or rejected.
|
||||
|
||||
**needs-info** — Plausibly a bug or QoL, but lacks specifics needed to act: no repro steps, no version (\`gitCommit\`), no expected-vs-actual. Provide *specific* clarifying questions, not generic "please provide more info."
|
||||
|
||||
**feature** — Substantial new system. Multi-week scope. Touches multiple subsystems. Examples: new unit types, new game modes, achievement systems, alternate currencies, cosmetic systems, new map *types* (not "add one more map"), new alliance mechanics, anything needing new art assets or new networked state.
|
||||
|
||||
**question** — Asking about the codebase, contributing, building from source, architecture.
|
||||
|
||||
**support** — Player asking for help playing: can't connect, account issues, reporting players, troubleshooting their local environment.
|
||||
|
||||
**billing** — Any mention of money: purchases, subscriptions, refunds, premium, payment failures. Even if there's also a technical defect, classify as billing — support has billing access.
|
||||
|
||||
**security** — Vulnerability reports, exploit disclosures, anything that shouldn't be public. The system will NOT auto-close these or post a comment — it just routes to \`claude-uncertain\` so the maintainer can handle them discreetly (closing wouldn't make the issue private anyway).
|
||||
|
||||
**translation** — Localization issues, language errors, new-language requests.
|
||||
|
||||
**uncertain** — You can't confidently place this in any other category. Default to this.
|
||||
|
||||
## Tie-breaking
|
||||
|
||||
- Bug + billing → **billing.**
|
||||
- Bug + support: specific reproducible defect → **bug**, vague "doesn't work for me" → **support**, doubt → **bug**.
|
||||
- Feature vs. QoL: new assets/systems → **feature**, tweak to existing flow → **qol-improvement**, doubt → **uncertain** (do NOT default to feature; that auto-closes).
|
||||
|
||||
## Examples
|
||||
|
||||
- "Game crashes when I right-click in spectator mode after 5 minutes" → **bug** (reproducible defect, specific trigger), area:client, confidence: high.
|
||||
- "Could you add a hotkey to toggle the chat?" → **qol-improvement** (small UI tweak), area:client, confidence: high.
|
||||
- "Game is buggy" with no body → **needs-info**, clarifying_questions: ["Which specific bug?", "Steps to reproduce?", "Which browser and OS?"], confidence: high.
|
||||
- "Add an espionage mechanic with hidden units" → **feature** (new game system), confidence: high.
|
||||
- "Refund my purchase, it doesn't work" → **billing**, confidence: high.
|
||||
- "How do I run the dev server?" → **question**, confidence: high.
|
||||
- "Can't connect to any game" → **support** unless concrete repro steps are given.
|
||||
- "I think there's an XSS in the chat" → **security**, confidence: high.
|
||||
- Issue text identical in shape to recent open #1234 → **duplicate**, duplicate_of: 1234.
|
||||
- Something ambiguous, e.g. an issue with rich but unclear repro that could be a bug or could be PEBKAC → **uncertain** (keep it open for the maintainer).
|
||||
|
||||
## Confidence
|
||||
|
||||
Auto-close categories (feature, question, support, billing, translation) require \`confidence: high\` for the system to act on them. Anything lower will be downgraded to \`uncertain\` and stay open. So:
|
||||
|
||||
- For an auto-close category, use \`high\` only if you'd defend the decision to the maintainer.
|
||||
- For label-only categories (bug, qol-improvement, duplicate, needs-info, security), any confidence is fine; the consequences are small and reversible.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- NEVER suggest setting a milestone. Milestones are maintainer-only.
|
||||
- NEVER suggest \`approved\` or \`not-approved\` labels. Layer A manages those automatically based on milestone state.
|
||||
- NEVER suggest assigning anyone.
|
||||
- NEVER reopen closed issues; never reference them as duplicates.
|
||||
- Being skeptical and returning \`uncertain\` is better than miscategorizing.
|
||||
|
||||
Call the \`submit_triage_decision\` tool exactly once.`;
|
||||
|
||||
export const PR_REVIEW_SYSTEM_PROMPT = `You are a code-review assistant doing a *fast triage pass* on a pull request for OpenFront.io, a browser-based RTS written in TypeScript.
|
||||
|
||||
Your output is one row in the maintainer's daily digest. It should:
|
||||
|
||||
- Summarize the PR in one sentence (max ~80 chars).
|
||||
- Assess risk based on what code is touched: \`src/core\` (deterministic simulation) is highest risk, \`src/server\` medium, \`src/client\` lower, docs/tests/config typically low.
|
||||
- Report whether tests are present. **Tests are required for any change in \`src/core\`.** Use 'n/a' only when no \`src/core\` files are touched.
|
||||
- Compare against the linked issue's scope. 'expanded' means the PR does noticeably more than the issue asked for (often a red flag). 'no_linked_issue' if the PR doesn't reference an issue.
|
||||
- Optionally flag AI smells: invented APIs, inconsistent style, overly defensive code for impossible cases, dense comments on obvious lines.
|
||||
- Note specific concerns the maintainer should look at first, if any.
|
||||
|
||||
Be terse. The output goes into a maintainer digest scanned at a glance. Call the \`submit_pr_review\` tool exactly once.`;
|
||||
|
||||
export const DIGEST_SYSTEM_PROMPT = `You are writing a daily triage digest for ${MAINTAINER_LOGIN} (Evan), the maintainer of OpenFront.io.
|
||||
|
||||
The digest is a brief morning briefing. It tells Evan what needs his attention today.
|
||||
|
||||
## Required structure (markdown)
|
||||
|
||||
\`\`\`markdown
|
||||
# Daily Triage — YYYY-MM-DD
|
||||
|
||||
## 🟢 Newly classified by Claude (N)
|
||||
- #123 — short summary · suggested action
|
||||
- ...
|
||||
|
||||
## 🟡 Needs your judgment (M)
|
||||
- #125 — Claude flagged 'claude-uncertain' because [reason]
|
||||
|
||||
## 🔴 Auto-closing in <24h (K)
|
||||
- #126 — \`stale\`, milestone or let close
|
||||
|
||||
## 🟠 Awaiting your milestone (Q)
|
||||
- #45 [bug, area:client] — 21d — Crash on right-click
|
||||
- #76 [qol-improvement] — 12d — Add chat hotkey
|
||||
- ...
|
||||
|
||||
## 🔵 PRs needing review (P)
|
||||
- #220 by @alice — fixes alliance UI bug · src/client only · tests ✓
|
||||
- ...
|
||||
|
||||
## Patterns
|
||||
[Only if you noticed something specific. Otherwise omit this section entirely.]
|
||||
|
||||
## Recommended actions
|
||||
- Milestone #123 as backlog
|
||||
- ...
|
||||
\`\`\`
|
||||
|
||||
## Rules
|
||||
|
||||
- Target length: 200–600 words. Should fit in one or two screenfuls.
|
||||
- Omit any section that has no entries. Do not write "No items" rows.
|
||||
- Direct address: "Evan, today you have..." is fine if it's natural; don't force it.
|
||||
- For each PR, surface the pre-computed quick review: risk, tests, scope match. Don't speculate beyond what's given.
|
||||
- **Awaiting your milestone**: list ALL items from the input verbatim. This is the full backlog of un-milestoned issues. Don't truncate or sample — Evan wants to see every one. The bracketed tags (e.g. \`[bug, area:client]\`) and age (e.g. \`21d\`) come straight from the input; pass them through unchanged.
|
||||
- **Patterns**: only mention if the input genuinely shows a cluster (e.g., 4+ new issues mentioning the same map or feature). Do NOT invent patterns. Omit the section if nothing notable.
|
||||
- **Recommended actions**: 2–5 concrete items maximum. Each is something Evan can do today in <5 minutes.
|
||||
- Use the issue/PR data exactly as provided. Don't hallucinate numbers, authors, or labels.
|
||||
|
||||
Call the \`submit_digest\` tool exactly once with the full markdown.`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_BODY_CHARS = 4000;
|
||||
const MAX_DIFF_CHARS = 20000;
|
||||
const RECENT_ISSUE_SUMMARY_CHARS = 100;
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max) + `\n\n…[truncated; ${s.length - max} chars omitted]`;
|
||||
}
|
||||
|
||||
export function formatIssueForTriage(
|
||||
issue: Issue,
|
||||
recentOpenIssues: Issue[],
|
||||
): string {
|
||||
const body =
|
||||
issue.body.trim() === ""
|
||||
? "_(empty body)_"
|
||||
: truncate(issue.body, MAX_BODY_CHARS);
|
||||
const labels = issue.labels.length > 0 ? issue.labels.join(", ") : "(none)";
|
||||
const recent = recentOpenIssues
|
||||
.filter((r) => r.number !== issue.number)
|
||||
.map((r) => {
|
||||
const snippet = r.body
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, RECENT_ISSUE_SUMMARY_CHARS)
|
||||
.trim();
|
||||
return `- #${r.number}: ${r.title} — ${snippet}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `## Issue to classify
|
||||
|
||||
- Number: #${issue.number}
|
||||
- Author: @${issue.user?.login ?? "unknown"}
|
||||
- Author association: ${issue.author_association}
|
||||
- Created: ${issue.created_at}
|
||||
- Existing labels: ${labels}
|
||||
|
||||
### Title
|
||||
${issue.title}
|
||||
|
||||
### Body
|
||||
${body}
|
||||
|
||||
---
|
||||
|
||||
## Recent open issues (for duplicate detection only, NOT for classifying this issue)
|
||||
|
||||
${recent || "_(no other open issues)_"}
|
||||
|
||||
---
|
||||
|
||||
Classify the issue above. Call \`submit_triage_decision\` exactly once.`;
|
||||
}
|
||||
|
||||
export function formatPRForReview(
|
||||
pr: PullRequest,
|
||||
diff: string,
|
||||
linkedIssue: Issue | null,
|
||||
): string {
|
||||
const description =
|
||||
pr.body.trim() === ""
|
||||
? "_(empty description)_"
|
||||
: truncate(pr.body, MAX_BODY_CHARS);
|
||||
const linkedIssueBlock = linkedIssue
|
||||
? `## Linked issue #${linkedIssue.number}: ${linkedIssue.title}
|
||||
|
||||
${truncate(linkedIssue.body || "_(empty body)_", 1500)}`
|
||||
: "_(no linked issue detected)_";
|
||||
|
||||
return `## Pull request to review
|
||||
|
||||
- Number: #${pr.number}
|
||||
- Author: @${pr.user?.login ?? "unknown"} (association: ${pr.author_association})
|
||||
- Title: ${pr.title}
|
||||
- Created: ${pr.created_at}
|
||||
|
||||
### Description
|
||||
${description}
|
||||
|
||||
${linkedIssueBlock}
|
||||
|
||||
## Diff
|
||||
|
||||
\`\`\`diff
|
||||
${truncate(diff, MAX_DIFF_CHARS)}
|
||||
\`\`\`
|
||||
|
||||
Review the PR above. Call \`submit_pr_review\` exactly once.`;
|
||||
}
|
||||
|
||||
export type DigestInputs = {
|
||||
today: string;
|
||||
triageReadyInWindow: {
|
||||
number: number;
|
||||
title: string;
|
||||
author: string;
|
||||
area: string | null;
|
||||
}[];
|
||||
needsMaintainerTriage: {
|
||||
number: number;
|
||||
title: string;
|
||||
reasoning?: string;
|
||||
}[];
|
||||
needsInfoCount: number;
|
||||
staleClosingSoon: { number: number; title: string }[];
|
||||
awaitingMilestone: {
|
||||
number: number;
|
||||
title: string;
|
||||
ageDays: number;
|
||||
primaryLabel: string | null;
|
||||
area: string | null;
|
||||
}[];
|
||||
prReviews: {
|
||||
pr: { number: number; title: string; author: string };
|
||||
review: PRReview;
|
||||
}[];
|
||||
triageSummary: { processed: number; closed: number; kept: number };
|
||||
};
|
||||
|
||||
export function formatDigestInputs(inputs: DigestInputs): string {
|
||||
const triageReady =
|
||||
inputs.triageReadyInWindow.length === 0
|
||||
? "_(none)_"
|
||||
: inputs.triageReadyInWindow
|
||||
.map(
|
||||
(i) =>
|
||||
`- #${i.number} by @${i.author}${i.area ? ` [${i.area}]` : ""}: ${i.title}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const needsTriage =
|
||||
inputs.needsMaintainerTriage.length === 0
|
||||
? "_(none)_"
|
||||
: inputs.needsMaintainerTriage
|
||||
.map(
|
||||
(i) =>
|
||||
`- #${i.number}: ${i.title}${i.reasoning ? ` — Claude's reasoning: ${i.reasoning}` : ""}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const stale =
|
||||
inputs.staleClosingSoon.length === 0
|
||||
? "_(none)_"
|
||||
: inputs.staleClosingSoon
|
||||
.map((i) => `- #${i.number}: ${i.title}`)
|
||||
.join("\n");
|
||||
|
||||
const awaitingMilestone =
|
||||
inputs.awaitingMilestone.length === 0
|
||||
? "_(none)_"
|
||||
: inputs.awaitingMilestone
|
||||
.map((i) => {
|
||||
const tags = [i.primaryLabel, i.area].filter(Boolean).join(", ");
|
||||
const tagStr = tags ? ` [${tags}]` : "";
|
||||
return `- #${i.number}${tagStr} — ${i.ageDays}d — ${i.title}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const prs =
|
||||
inputs.prReviews.length === 0
|
||||
? "_(none)_"
|
||||
: inputs.prReviews
|
||||
.map(({ pr, review }) => {
|
||||
const concerns =
|
||||
review.concerns && review.concerns.length > 0
|
||||
? ` · concerns: ${review.concerns.join("; ")}`
|
||||
: "";
|
||||
const ai =
|
||||
review.ai_smell && review.ai_smell !== "low"
|
||||
? ` · ai-smell: ${review.ai_smell}`
|
||||
: "";
|
||||
return `- #${pr.number} by @${pr.author}: ${pr.title}
|
||||
summary: ${review.summary}
|
||||
risk: ${review.risk} · tests: ${review.tests_present} · scope: ${review.scope_match}${ai}${concerns}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `Today: ${inputs.today}
|
||||
|
||||
## Triage pass summary (last 24h)
|
||||
- Processed: ${inputs.triageSummary.processed}
|
||||
- Auto-closed: ${inputs.triageSummary.closed}
|
||||
- Kept open: ${inputs.triageSummary.kept}
|
||||
|
||||
## Newly classified by Claude (last ${DIGEST_LOOKBACK_HOURS}h, label: claude-approved)
|
||||
${triageReady}
|
||||
|
||||
## Needs maintainer triage (label: claude-uncertain)
|
||||
${needsTriage}
|
||||
|
||||
## Needs info (label: needs-info)
|
||||
Count: ${inputs.needsInfoCount}
|
||||
|
||||
## Stale, closing soon (label: stale; Layer A will close in <24h unless milestoned)
|
||||
${stale}
|
||||
|
||||
## Awaiting your milestone (label: not-approved; full open backlog, sorted oldest first)
|
||||
${awaitingMilestone}
|
||||
|
||||
## Open PRs awaiting maintainer review (any author)
|
||||
${prs}
|
||||
|
||||
Compose the digest now. Follow the format in the system prompt. Omit empty sections.`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API call helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ToolUseBlock = {
|
||||
type: "tool_use";
|
||||
name: string;
|
||||
input: unknown;
|
||||
};
|
||||
|
||||
function findToolUse(
|
||||
content: Anthropic.Messages.ContentBlock[],
|
||||
expectedName: string,
|
||||
): ToolUseBlock {
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use") {
|
||||
const tu = block as unknown as ToolUseBlock;
|
||||
if (tu.name !== expectedName) {
|
||||
throw new Error(
|
||||
`Claude returned tool_use for unexpected tool "${tu.name}" (expected "${expectedName}")`,
|
||||
);
|
||||
}
|
||||
return tu;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Claude response had no tool_use block (expected "${expectedName}")`,
|
||||
);
|
||||
}
|
||||
|
||||
export type CallResult<T> = { value: T; usage: UsageStats };
|
||||
|
||||
async function callClaudeWithTool<T>(
|
||||
anthropic: Anthropic,
|
||||
args: {
|
||||
system: string;
|
||||
userContent: string;
|
||||
tool: {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: { type: "object" } & Record<string, unknown>;
|
||||
};
|
||||
maxTokens: number;
|
||||
},
|
||||
): Promise<CallResult<T>> {
|
||||
const response = await anthropic.messages.create({
|
||||
model: CLAUDE_MODEL,
|
||||
max_tokens: args.maxTokens,
|
||||
system: args.system,
|
||||
messages: [{ role: "user", content: args.userContent }],
|
||||
tools: [args.tool],
|
||||
tool_choice: { type: "tool", name: args.tool.name },
|
||||
});
|
||||
const tu = findToolUse(response.content, args.tool.name);
|
||||
return {
|
||||
value: tu.input as T,
|
||||
usage: {
|
||||
input_tokens: response.usage.input_tokens,
|
||||
output_tokens: response.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function classifyIssue(
|
||||
anthropic: Anthropic,
|
||||
issue: Issue,
|
||||
recentOpenIssues: Issue[],
|
||||
): Promise<CallResult<TriageDecision>> {
|
||||
return callClaudeWithTool<TriageDecision>(anthropic, {
|
||||
system: TRIAGE_SYSTEM_PROMPT,
|
||||
userContent: formatIssueForTriage(issue, recentOpenIssues),
|
||||
tool: TRIAGE_TOOL,
|
||||
maxTokens: 1024,
|
||||
});
|
||||
}
|
||||
|
||||
export async function reviewPR(
|
||||
anthropic: Anthropic,
|
||||
pr: PullRequest,
|
||||
diff: string,
|
||||
linkedIssue: Issue | null,
|
||||
): Promise<CallResult<PRReview>> {
|
||||
return callClaudeWithTool<PRReview>(anthropic, {
|
||||
system: PR_REVIEW_SYSTEM_PROMPT,
|
||||
userContent: formatPRForReview(pr, diff, linkedIssue),
|
||||
tool: PR_REVIEW_TOOL,
|
||||
maxTokens: 800,
|
||||
});
|
||||
}
|
||||
|
||||
export async function composeDigest(
|
||||
anthropic: Anthropic,
|
||||
inputs: DigestInputs,
|
||||
): Promise<CallResult<DigestResult>> {
|
||||
return callClaudeWithTool<DigestResult>(anthropic, {
|
||||
system: DIGEST_SYSTEM_PROMPT,
|
||||
userContent: formatDigestInputs(inputs),
|
||||
tool: DIGEST_TOOL,
|
||||
maxTokens: 2000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
export const REPO = { owner: "openfrontio", repo: "OpenFrontIO" } as const;
|
||||
|
||||
export const MAINTAINER_LOGIN = "evanpelle";
|
||||
|
||||
export const CLAUDE_MODEL = "claude-sonnet-4-6";
|
||||
export const MAX_CALLS_PER_RUN = 100;
|
||||
export const RECENT_ISSUES_CONTEXT_SIZE = 50;
|
||||
export const DIGEST_LOOKBACK_HOURS = 24;
|
||||
|
||||
// TODO(maintainer): create a tracking issue, pin it, and set its number here.
|
||||
// Until set, the digest is logged to stdout instead of posted.
|
||||
export const DIGEST_ISSUE_NUMBER = 0;
|
||||
|
||||
// Toggle to false to let translation issues stay open with `claude-uncertain`
|
||||
// instead of being auto-closed and redirected to Crowdin.
|
||||
export const ENABLE_TRANSLATION_CLOSE = true;
|
||||
|
||||
export const URLS = {
|
||||
DEV_DISCORD: "https://discord.gg/K9zernJB5z",
|
||||
GAME_DISCORD: "https://discord.gg/openfront",
|
||||
CROWDIN: "https://crowdin.com/project/openfront-mls",
|
||||
SECURITY_EMAIL: "security@openfront.io",
|
||||
BILLING_EMAIL: "support@openfront.io",
|
||||
} as const;
|
||||
|
||||
// Labels managed by THIS Action — Claude only applies these.
|
||||
export const LABELS = {
|
||||
CLAUDE_TRIAGED: "claude-triaged",
|
||||
CLAUDE_APPROVED: "claude-approved",
|
||||
CLAUDE_UNCERTAIN: "claude-uncertain",
|
||||
BUG: "bug",
|
||||
QOL_IMPROVEMENT: "qol-improvement",
|
||||
POSSIBLE_DUPLICATE: "possible-duplicate",
|
||||
NEEDS_INFO: "needs-info", // shared with Layer A
|
||||
AUTO_CLOSED_FEATURE: "auto-closed-feature",
|
||||
AUTO_CLOSED_QUESTION: "auto-closed-question",
|
||||
AUTO_CLOSED_SUPPORT: "auto-closed-support",
|
||||
AUTO_CLOSED_BILLING: "auto-closed-billing",
|
||||
AUTO_CLOSED_TRANSLATION: "auto-closed-translation",
|
||||
AREA_CLIENT: "area:client",
|
||||
AREA_CORE: "area:core",
|
||||
AREA_SERVER: "area:server",
|
||||
AREA_MAP_GENERATOR: "area:map-generator",
|
||||
AREA_OTHER: "area:other",
|
||||
} as const;
|
||||
|
||||
// Labels Layer A owns. This Action MUST NOT add or remove these.
|
||||
// Enforced as a runtime assertion in applyDecision.
|
||||
export const FORBIDDEN_LABELS: ReadonlySet<string> = new Set([
|
||||
"approved",
|
||||
"not-approved",
|
||||
"stale",
|
||||
"keep-open",
|
||||
"auto-closed-stale",
|
||||
]);
|
||||
|
||||
// Layer A label names referenced for digest fetches (read-only).
|
||||
export const LAYER_A_LABELS = {
|
||||
STALE: "stale",
|
||||
NOT_APPROVED: "not-approved",
|
||||
} as const;
|
||||
|
||||
// Note: `security` is intentionally NOT in this list. Closing a GitHub issue
|
||||
// doesn't make its content private — it stays publicly indexed. Auto-closing
|
||||
// a security report with a "thanks, please file privately" comment would
|
||||
// only draw more attention to a still-public exploit description. Instead,
|
||||
// `security` is routed to `claude-uncertain` so the maintainer can manually
|
||||
// delete, contact the reporter privately, or process it discreetly.
|
||||
export const AUTO_CLOSE_CLASSIFICATIONS = [
|
||||
"feature",
|
||||
"question",
|
||||
"support",
|
||||
"billing",
|
||||
"translation",
|
||||
] as const;
|
||||
export type AutoCloseClassification =
|
||||
(typeof AUTO_CLOSE_CLASSIFICATIONS)[number];
|
||||
|
||||
export const ALL_CLASSIFICATIONS = [
|
||||
"bug",
|
||||
"qol-improvement",
|
||||
"duplicate",
|
||||
"needs-info",
|
||||
"feature",
|
||||
"question",
|
||||
"support",
|
||||
"billing",
|
||||
"security",
|
||||
"translation",
|
||||
"uncertain",
|
||||
] as const;
|
||||
export type Classification = (typeof ALL_CLASSIFICATIONS)[number];
|
||||
|
||||
export const AREA_LABELS = [
|
||||
LABELS.AREA_CLIENT,
|
||||
LABELS.AREA_CORE,
|
||||
LABELS.AREA_SERVER,
|
||||
LABELS.AREA_MAP_GENERATOR,
|
||||
LABELS.AREA_OTHER,
|
||||
] as const;
|
||||
export type AreaLabel = (typeof AREA_LABELS)[number];
|
||||
|
||||
export const LABEL_COLORS: Record<string, string> = {
|
||||
[LABELS.CLAUDE_TRIAGED]: "EEEEEE",
|
||||
[LABELS.CLAUDE_APPROVED]: "0E70B0",
|
||||
[LABELS.CLAUDE_UNCERTAIN]: "5319E7",
|
||||
[LABELS.BUG]: "D93F0B",
|
||||
[LABELS.QOL_IMPROVEMENT]: "1D76DB",
|
||||
[LABELS.POSSIBLE_DUPLICATE]: "FEF2C0",
|
||||
[LABELS.NEEDS_INFO]: "FBCA04",
|
||||
[LABELS.AUTO_CLOSED_FEATURE]: "586069",
|
||||
[LABELS.AUTO_CLOSED_QUESTION]: "586069",
|
||||
[LABELS.AUTO_CLOSED_SUPPORT]: "586069",
|
||||
[LABELS.AUTO_CLOSED_BILLING]: "586069",
|
||||
[LABELS.AUTO_CLOSED_TRANSLATION]: "586069",
|
||||
[LABELS.AREA_CLIENT]: "C5DEF5",
|
||||
[LABELS.AREA_CORE]: "C5DEF5",
|
||||
[LABELS.AREA_SERVER]: "C5DEF5",
|
||||
[LABELS.AREA_MAP_GENERATOR]: "C5DEF5",
|
||||
[LABELS.AREA_OTHER]: "C5DEF5",
|
||||
};
|
||||
|
||||
export const LABEL_DESCRIPTIONS: Record<string, string> = {
|
||||
[LABELS.CLAUDE_TRIAGED]:
|
||||
"Claude has run triage on this issue (idempotency marker)",
|
||||
[LABELS.CLAUDE_APPROVED]:
|
||||
"Claude confidently classified this as actionable bug or QoL — ready for maintainer milestone decision",
|
||||
[LABELS.CLAUDE_UNCERTAIN]:
|
||||
"Claude was not confident enough to classify — maintainer needs to triage manually",
|
||||
[LABELS.BUG]: "Reproducible defect in existing functionality",
|
||||
[LABELS.QOL_IMPROVEMENT]:
|
||||
"Small-scope improvement; completable by one contributor in under a week",
|
||||
[LABELS.POSSIBLE_DUPLICATE]:
|
||||
"Claude suggested this may duplicate another issue — maintainer confirms",
|
||||
[LABELS.NEEDS_INFO]:
|
||||
"Reporter was asked for more info; no special timer — standard stale-close still applies",
|
||||
[LABELS.AUTO_CLOSED_FEATURE]:
|
||||
"Closed automatically as a feature request from external contributor",
|
||||
[LABELS.AUTO_CLOSED_QUESTION]:
|
||||
"Closed automatically as a developer question — redirected to Dev Discord",
|
||||
[LABELS.AUTO_CLOSED_SUPPORT]:
|
||||
"Closed automatically as a player support request — redirected to Game Discord",
|
||||
[LABELS.AUTO_CLOSED_BILLING]:
|
||||
"Closed automatically as a billing matter — redirected to support email",
|
||||
[LABELS.AUTO_CLOSED_TRANSLATION]:
|
||||
"Closed automatically as a translation matter — redirected to Crowdin",
|
||||
[LABELS.AREA_CLIENT]:
|
||||
"Best-guess area: client (rendering, UI, web components)",
|
||||
[LABELS.AREA_CORE]: "Best-guess area: core simulation",
|
||||
[LABELS.AREA_SERVER]: "Best-guess area: game server",
|
||||
[LABELS.AREA_MAP_GENERATOR]: "Best-guess area: map generator",
|
||||
[LABELS.AREA_OTHER]: "Best-guess area: unclear or outside main components",
|
||||
};
|
||||
|
||||
const AUTOMATION_FOOTER = "— *Automated by Claude triage.*";
|
||||
|
||||
export const COMMENTS = {
|
||||
AUTO_CLOSED_FEATURE: (
|
||||
author: string,
|
||||
): string => `Hi @${author}, thanks for the suggestion.
|
||||
|
||||
OpenFront doesn't accept feature suggestions as GitHub issues from external contributors. Feature ideas are discussed in our [Dev Discord](${URLS.DEV_DISCORD}) first — if a maintainer thinks the idea is worth pursuing, they'll create the tracking issue.
|
||||
|
||||
Please join the Discord and post your idea there.
|
||||
|
||||
If you believe this was misclassified — for example, it's actually a bug or a small quality-of-life improvement to existing functionality — please reopen with more detail.
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/${REPO.owner}/${REPO.repo}/blob/main/CONTRIBUTING.md) for the full process.
|
||||
|
||||
${AUTOMATION_FOOTER}`,
|
||||
|
||||
AUTO_CLOSED_QUESTION: (author: string): string => `Hi @${author},
|
||||
|
||||
Development and contributor questions are handled in our [Dev Discord](${URLS.DEV_DISCORD}), not on the issue tracker. You'll get a faster response there.
|
||||
|
||||
If you intended to report a bug rather than ask a question, please reopen with repro steps.
|
||||
|
||||
${AUTOMATION_FOOTER}`,
|
||||
|
||||
AUTO_CLOSED_SUPPORT: (author: string): string => `Hi @${author},
|
||||
|
||||
For player support — bugs you're experiencing in-game, connectivity issues, reporting players, account problems — please open a ticket in our [Game Discord](${URLS.GAME_DISCORD}). The community and moderators can help much faster than the issue tracker.
|
||||
|
||||
If you've identified a specific reproducible defect (specific steps that always trigger the same broken behavior), please reopen with those details.
|
||||
|
||||
${AUTOMATION_FOOTER}`,
|
||||
|
||||
AUTO_CLOSED_BILLING: (author: string): string => `Hi @${author},
|
||||
|
||||
For anything related to purchases, subscriptions, refunds, premium accounts, or billing — please email ${URLS.BILLING_EMAIL}. We can't process billing issues through GitHub.
|
||||
|
||||
${AUTOMATION_FOOTER}`,
|
||||
|
||||
AUTO_CLOSED_TRANSLATION: (author: string): string => `Hi @${author},
|
||||
|
||||
Translation issues — text errors, requests for new languages, localization corrections — are handled through Crowdin, not the GitHub issue tracker.
|
||||
|
||||
Please visit [our Crowdin project](${URLS.CROWDIN}) to suggest fixes or join a translation team.
|
||||
|
||||
${AUTOMATION_FOOTER}`,
|
||||
|
||||
POSSIBLE_DUPLICATE: (
|
||||
author: string,
|
||||
duplicateOf: number,
|
||||
): string => `Hi @${author},
|
||||
|
||||
This issue looks like it might be a duplicate of #${duplicateOf}. A maintainer will confirm.
|
||||
|
||||
If you believe it's distinct from #${duplicateOf}, please leave a comment explaining the difference.
|
||||
|
||||
— *Automated by Claude triage. This is a suggestion, not a closure.*`,
|
||||
|
||||
NEEDS_INFO: (author: string, questions: string[]): string => {
|
||||
const qList = questions.map((q) => `- ${q}`).join("\n");
|
||||
return `Hi @${author}, thanks for the report. To help us triage this, could you provide a bit more detail?
|
||||
|
||||
${qList}
|
||||
|
||||
Without this information, we may not be able to act on this issue.
|
||||
|
||||
${AUTOMATION_FOOTER}`;
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const CLOSE_COMMENT_BY_CLASSIFICATION: Record<
|
||||
AutoCloseClassification,
|
||||
(author: string) => string
|
||||
> = {
|
||||
feature: COMMENTS.AUTO_CLOSED_FEATURE,
|
||||
question: COMMENTS.AUTO_CLOSED_QUESTION,
|
||||
support: COMMENTS.AUTO_CLOSED_SUPPORT,
|
||||
billing: COMMENTS.AUTO_CLOSED_BILLING,
|
||||
translation: COMMENTS.AUTO_CLOSED_TRANSLATION,
|
||||
};
|
||||
|
||||
export const CLOSE_LABEL_BY_CLASSIFICATION: Record<
|
||||
AutoCloseClassification,
|
||||
string
|
||||
> = {
|
||||
feature: LABELS.AUTO_CLOSED_FEATURE,
|
||||
question: LABELS.AUTO_CLOSED_QUESTION,
|
||||
support: LABELS.AUTO_CLOSED_SUPPORT,
|
||||
billing: LABELS.AUTO_CLOSED_BILLING,
|
||||
translation: LABELS.AUTO_CLOSED_TRANSLATION,
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { DigestInputs } from "./claude";
|
||||
import { DIGEST_LOOKBACK_HOURS, LABELS, LAYER_A_LABELS } from "./config";
|
||||
import { type Issue, listIssuesLabeled } from "./github";
|
||||
import type { TriagePassResult } from "./pass1-triage";
|
||||
import type { PRReviewRecord } from "./pr-reviewer";
|
||||
|
||||
const MS_PER_HOUR = 1000 * 60 * 60;
|
||||
const MS_PER_DAY = MS_PER_HOUR * 24;
|
||||
|
||||
const AREA_LABEL_PREFIX = "area:";
|
||||
|
||||
// Order matters: the first match in this list is reported as the issue's
|
||||
// primary Claude label in the digest.
|
||||
const PRIMARY_CLAUDE_LABELS = [
|
||||
LABELS.BUG,
|
||||
LABELS.QOL_IMPROVEMENT,
|
||||
LABELS.CLAUDE_UNCERTAIN,
|
||||
LABELS.NEEDS_INFO,
|
||||
LABELS.POSSIBLE_DUPLICATE,
|
||||
] as const;
|
||||
|
||||
function findAreaLabel(issue: Issue): string | null {
|
||||
const area = issue.labels.find((l) => l.startsWith(AREA_LABEL_PREFIX));
|
||||
return area ?? null;
|
||||
}
|
||||
|
||||
function findPrimaryClaudeLabel(issue: Issue): string | null {
|
||||
for (const candidate of PRIMARY_CLAUDE_LABELS) {
|
||||
if (issue.labels.includes(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ageDays(createdAt: string, now: Date): number {
|
||||
const createdMs = Date.parse(createdAt);
|
||||
if (Number.isNaN(createdMs)) return 0;
|
||||
return Math.max(0, Math.floor((now.getTime() - createdMs) / MS_PER_DAY));
|
||||
}
|
||||
|
||||
function isWithinLookback(
|
||||
updatedAt: string,
|
||||
now: Date,
|
||||
hours: number,
|
||||
): boolean {
|
||||
const updatedMs = Date.parse(updatedAt);
|
||||
if (Number.isNaN(updatedMs)) return false;
|
||||
return now.getTime() - updatedMs <= hours * MS_PER_HOUR;
|
||||
}
|
||||
|
||||
export type GatherDigestInputsArgs = {
|
||||
octokit: Octokit;
|
||||
now: Date;
|
||||
triagePass: TriagePassResult;
|
||||
prReviews: PRReviewRecord[];
|
||||
};
|
||||
|
||||
export async function gatherDigestInputs(
|
||||
args: GatherDigestInputsArgs,
|
||||
): Promise<DigestInputs> {
|
||||
const { octokit, now, triagePass, prReviews } = args;
|
||||
|
||||
const [
|
||||
triageReadyAll,
|
||||
needsTriageAll,
|
||||
needsInfoAll,
|
||||
staleAll,
|
||||
notApprovedAll,
|
||||
] = await Promise.all([
|
||||
listIssuesLabeled(octokit, LABELS.CLAUDE_APPROVED),
|
||||
listIssuesLabeled(octokit, LABELS.CLAUDE_UNCERTAIN),
|
||||
listIssuesLabeled(octokit, LABELS.NEEDS_INFO),
|
||||
listIssuesLabeled(octokit, LAYER_A_LABELS.STALE),
|
||||
listIssuesLabeled(octokit, LAYER_A_LABELS.NOT_APPROVED),
|
||||
]);
|
||||
|
||||
const triageReadyInWindow = triageReadyAll
|
||||
.filter((i) => isWithinLookback(i.updated_at, now, DIGEST_LOOKBACK_HOURS))
|
||||
.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
author: i.user?.login ?? "unknown",
|
||||
area: findAreaLabel(i),
|
||||
}));
|
||||
|
||||
const needsMaintainerTriage = needsTriageAll.map((i) => {
|
||||
const record = triagePass.recordsByNumber.get(i.number);
|
||||
return {
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
reasoning: record?.decision.reasoning,
|
||||
};
|
||||
});
|
||||
|
||||
const staleClosingSoon = staleAll.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
}));
|
||||
|
||||
const awaitingMilestone = notApprovedAll
|
||||
.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
ageDays: ageDays(i.created_at, now),
|
||||
primaryLabel: findPrimaryClaudeLabel(i),
|
||||
area: findAreaLabel(i),
|
||||
}))
|
||||
.sort((a, b) => b.ageDays - a.ageDays);
|
||||
|
||||
return {
|
||||
today: formatIsoDate(now),
|
||||
triageReadyInWindow,
|
||||
needsMaintainerTriage,
|
||||
needsInfoCount: needsInfoAll.length,
|
||||
staleClosingSoon,
|
||||
awaitingMilestone,
|
||||
prReviews: prReviews.map(({ pr, review }) => ({
|
||||
pr: {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
author: pr.user?.login ?? "unknown",
|
||||
},
|
||||
review,
|
||||
})),
|
||||
triageSummary: {
|
||||
processed: triagePass.processed,
|
||||
closed: triagePass.closed,
|
||||
kept: triagePass.kept,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatIsoDate(d: Date): string {
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback digest used when Claude composition fails. Deterministic markdown
|
||||
* rendered directly from the inputs. Never the preferred path — Claude's
|
||||
* version is more readable — but useful as a safety net.
|
||||
*/
|
||||
export function renderFallbackDigest(inputs: DigestInputs): string {
|
||||
const sections: string[] = [`# Daily Triage — ${inputs.today}`];
|
||||
|
||||
if (inputs.triageReadyInWindow.length > 0) {
|
||||
sections.push(
|
||||
`## 🟢 Newly classified by Claude (${inputs.triageReadyInWindow.length})`,
|
||||
...inputs.triageReadyInWindow.map(
|
||||
(i) =>
|
||||
`- #${i.number}${i.area ? ` [${i.area}]` : ""} — ${i.title} (by @${i.author})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (inputs.needsMaintainerTriage.length > 0) {
|
||||
sections.push(
|
||||
`## 🟡 Needs your judgment (${inputs.needsMaintainerTriage.length})`,
|
||||
...inputs.needsMaintainerTriage.map(
|
||||
(i) =>
|
||||
`- #${i.number} — ${i.title}${i.reasoning ? ` _(${i.reasoning})_` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (inputs.staleClosingSoon.length > 0) {
|
||||
sections.push(
|
||||
`## 🔴 Auto-closing in <24h (${inputs.staleClosingSoon.length})`,
|
||||
...inputs.staleClosingSoon.map((i) => `- #${i.number} — ${i.title}`),
|
||||
);
|
||||
}
|
||||
|
||||
if (inputs.awaitingMilestone.length > 0) {
|
||||
sections.push(
|
||||
`## 🟠 Awaiting your milestone (${inputs.awaitingMilestone.length})`,
|
||||
...inputs.awaitingMilestone.map((i) => {
|
||||
const tags = [i.primaryLabel, i.area].filter(Boolean).join(", ");
|
||||
const tagStr = tags ? ` [${tags}]` : "";
|
||||
return `- #${i.number}${tagStr} — ${i.ageDays}d — ${i.title}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (inputs.prReviews.length > 0) {
|
||||
sections.push(
|
||||
`## 🔵 PRs needing review (${inputs.prReviews.length})`,
|
||||
...inputs.prReviews.map(({ pr, review }) => {
|
||||
const flags = [
|
||||
`risk: ${review.risk}`,
|
||||
`tests: ${review.tests_present}`,
|
||||
`scope: ${review.scope_match}`,
|
||||
].join(" · ");
|
||||
return `- #${pr.number} by @${pr.author} — ${review.summary} · ${flags}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (inputs.needsInfoCount > 0) {
|
||||
sections.push(
|
||||
`_${inputs.needsInfoCount} issues waiting on reporter (\`needs-info\`)._`,
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
`— *Fallback digest (Claude composition failed). Generated deterministically.*`,
|
||||
);
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a digest down to the Discord 2000-char limit. Adds a truncation
|
||||
* marker if anything had to be dropped.
|
||||
*/
|
||||
export function truncateForDiscord(markdown: string, limit = 1900): string {
|
||||
if (markdown.length <= limit) return markdown;
|
||||
return markdown.slice(0, limit) + "\n…(truncated)";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { truncateForDiscord } from "./digest-formatter";
|
||||
|
||||
export async function postToDiscord(
|
||||
webhookUrl: string,
|
||||
markdown: string,
|
||||
): Promise<void> {
|
||||
const body = JSON.stringify({ content: truncateForDiscord(markdown) });
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "<no body>");
|
||||
throw new Error(
|
||||
`Discord webhook failed: ${response.status} ${response.statusText} — ${text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import {
|
||||
FORBIDDEN_LABELS,
|
||||
LABELS,
|
||||
LABEL_COLORS,
|
||||
LABEL_DESCRIPTIONS,
|
||||
REPO,
|
||||
} from "./config";
|
||||
|
||||
export type Issue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
state: "open" | "closed";
|
||||
labels: string[];
|
||||
user: { login: string } | null;
|
||||
author_association: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_pull_request: boolean;
|
||||
};
|
||||
|
||||
export type PullRequest = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
user: { login: string } | null;
|
||||
author_association: string;
|
||||
draft: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
head_sha: string;
|
||||
};
|
||||
|
||||
export type PullRequestReview = {
|
||||
user_login: string | null;
|
||||
submitted_at: string | null;
|
||||
commit_id: string | null;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| { type: "add_label"; label: string }
|
||||
| { type: "remove_label"; label: string }
|
||||
| { type: "comment"; body: string }
|
||||
| { type: "close"; reason: "not_planned" | "completed" };
|
||||
|
||||
export function makeOctokit(token: string): Octokit {
|
||||
return new Octokit({ auth: token });
|
||||
}
|
||||
|
||||
type RawIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
labels: ({ name?: string } | string)[];
|
||||
user: { login: string } | null;
|
||||
author_association?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pull_request?: unknown;
|
||||
};
|
||||
|
||||
function normalizeIssue(data: RawIssue): Issue {
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title,
|
||||
body: data.body ?? "",
|
||||
state: data.state === "closed" ? "closed" : "open",
|
||||
labels: (data.labels ?? [])
|
||||
.map((l) => (typeof l === "string" ? l : (l.name ?? "")))
|
||||
.filter((name) => name.length > 0),
|
||||
user: data.user ? { login: data.user.login } : null,
|
||||
author_association: data.author_association ?? "NONE",
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
is_pull_request:
|
||||
data.pull_request !== undefined && data.pull_request !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasLabel(issue: Issue, label: string): boolean {
|
||||
return issue.labels.includes(label);
|
||||
}
|
||||
|
||||
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 as RawIssue);
|
||||
} catch (err) {
|
||||
if (isStatus(err, 404)) {
|
||||
console.warn(`[claude-cron] issue #${issueNumber} not found`);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOpenIssues(octokit: Octokit): Promise<Issue[]> {
|
||||
const out: 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 as RawIssue);
|
||||
if (issue.is_pull_request) continue;
|
||||
out.push(issue);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listIssuesLabeled(
|
||||
octokit: Octokit,
|
||||
label: string,
|
||||
opts: { state?: "open" | "closed" | "all" } = {},
|
||||
): Promise<Issue[]> {
|
||||
const out: Issue[] = [];
|
||||
const iterator = octokit.paginate.iterator(octokit.rest.issues.listForRepo, {
|
||||
...REPO,
|
||||
state: opts.state ?? "open",
|
||||
labels: label,
|
||||
per_page: 100,
|
||||
});
|
||||
for await (const { data } of iterator) {
|
||||
for (const raw of data) {
|
||||
const issue = normalizeIssue(raw as RawIssue);
|
||||
if (issue.is_pull_request) continue;
|
||||
out.push(issue);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listOpenPRs(octokit: Octokit): Promise<PullRequest[]> {
|
||||
const out: PullRequest[] = [];
|
||||
const iterator = octokit.paginate.iterator(octokit.rest.pulls.list, {
|
||||
...REPO,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
});
|
||||
for await (const { data } of iterator) {
|
||||
for (const pr of data) {
|
||||
out.push({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? "",
|
||||
user: pr.user ? { login: pr.user.login } : null,
|
||||
author_association: pr.author_association ?? "NONE",
|
||||
draft: pr.draft ?? false,
|
||||
created_at: pr.created_at,
|
||||
updated_at: pr.updated_at,
|
||||
head_sha: pr.head.sha,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listPullReviews(
|
||||
octokit: Octokit,
|
||||
prNumber: number,
|
||||
): Promise<PullRequestReview[]> {
|
||||
const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, {
|
||||
...REPO,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return reviews.map((r) => ({
|
||||
user_login: r.user?.login ?? null,
|
||||
submitted_at: r.submitted_at ?? null,
|
||||
commit_id: r.commit_id ?? null,
|
||||
state: r.state,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchPRDiff(
|
||||
octokit: Octokit,
|
||||
prNumber: number,
|
||||
): Promise<string> {
|
||||
const response = await octokit.rest.pulls.get({
|
||||
...REPO,
|
||||
pull_number: prNumber,
|
||||
mediaType: { format: "diff" },
|
||||
});
|
||||
// When mediaType.format is "diff", Octokit returns the raw diff string as data.
|
||||
return response.data as unknown as string;
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotForbidden(action: Action): void {
|
||||
if (action.type === "add_label" || action.type === "remove_label") {
|
||||
if (FORBIDDEN_LABELS.has(action.label)) {
|
||||
throw new Error(
|
||||
`[claude-cron] refusing to ${action.type} forbidden label "${action.label}" — managed by Layer A`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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 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) {
|
||||
assertNotForbidden(action);
|
||||
}
|
||||
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 "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 "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
|
||||
);
|
||||
}
|
||||
|
||||
export { assertNotForbidden };
|
||||
@@ -0,0 +1,207 @@
|
||||
import { classifyIssue, makeAnthropic } from "./claude";
|
||||
import { MAX_CALLS_PER_RUN, RECENT_ISSUES_CONTEXT_SIZE, URLS } from "./config";
|
||||
import { postToDiscord } from "./discord";
|
||||
import {
|
||||
ensureAllLabels,
|
||||
getIssue,
|
||||
listOpenIssues,
|
||||
makeOctokit,
|
||||
} from "./github";
|
||||
import {
|
||||
decisionToActions,
|
||||
downgradeIfLowConfidence,
|
||||
isTriageCandidate,
|
||||
runTriagePass,
|
||||
} from "./pass1-triage";
|
||||
import { runDigestPass } from "./pass2-digest";
|
||||
|
||||
type ParsedArgs = {
|
||||
issueNumber: number | null;
|
||||
dryRunOverride: boolean | null;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
let issueNumber: number | null = null;
|
||||
let dryRunOverride: boolean | null = null;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--issue") {
|
||||
const next = argv[i + 1];
|
||||
if (next) {
|
||||
const parsed = parseInt(next, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`--issue requires a numeric value, got: ${next}`);
|
||||
}
|
||||
issueNumber = parsed;
|
||||
}
|
||||
i++;
|
||||
} else if (arg === "--dry-run") {
|
||||
dryRunOverride = true;
|
||||
} else if (arg === "--no-dry-run") {
|
||||
dryRunOverride = false;
|
||||
}
|
||||
}
|
||||
return { issueNumber, dryRunOverride };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to run live if any URL in config.ts is still a TODO_ placeholder.
|
||||
* Catches the failure mode where a redirect comment renders the literal
|
||||
* placeholder string as a link in the public issue.
|
||||
*/
|
||||
function assertConfigReadyForLiveRun(): void {
|
||||
const placeholders: string[] = [];
|
||||
for (const [key, value] of Object.entries(URLS)) {
|
||||
if (typeof value === "string" && value.startsWith("TODO_")) {
|
||||
placeholders.push(key);
|
||||
}
|
||||
}
|
||||
if (placeholders.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to run live: unfilled URL placeholders in config.ts: ${placeholders.join(", ")}. Set the real values before running with --no-dry-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 singleIssueMode(args: {
|
||||
issueNumber: number;
|
||||
dryRun: boolean;
|
||||
githubToken: string;
|
||||
anthropicKey: string;
|
||||
}): Promise<void> {
|
||||
const octokit = makeOctokit(args.githubToken);
|
||||
const anthropic = makeAnthropic(args.anthropicKey);
|
||||
|
||||
console.log(
|
||||
`[claude-cron] single-issue mode — issue #${args.issueNumber} (dry_run=${args.dryRun})`,
|
||||
);
|
||||
|
||||
const issue = await getIssue(octokit, args.issueNumber);
|
||||
if (!issue) {
|
||||
console.error(`[claude-cron] issue #${args.issueNumber} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!isTriageCandidate(issue)) {
|
||||
console.log(
|
||||
`[claude-cron] issue #${args.issueNumber} is not a triage candidate — state=${issue.state}, labels=${issue.labels.join(",")}, assoc=${issue.author_association}, is_pr=${issue.is_pull_request}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allOpen = await listOpenIssues(octokit);
|
||||
const contextIssues = allOpen.slice(0, RECENT_ISSUES_CONTEXT_SIZE);
|
||||
|
||||
const result = await classifyIssue(anthropic, issue, contextIssues);
|
||||
console.log(
|
||||
`[claude-cron] tokens in=${result.usage.input_tokens} out=${result.usage.output_tokens}`,
|
||||
);
|
||||
const decision = downgradeIfLowConfidence(result.value);
|
||||
console.log(`[claude-cron] decision:`, JSON.stringify(decision, null, 2));
|
||||
|
||||
const actions = decisionToActions(decision, issue);
|
||||
console.log(
|
||||
`[claude-cron] would apply: ${actions.map((a) => a.type + (a.type === "add_label" || a.type === "remove_label" ? `(${a.label})` : "")).join(", ")}`,
|
||||
);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(`[claude-cron] DRY_RUN: no actions applied`);
|
||||
return;
|
||||
}
|
||||
await ensureAllLabels(octokit);
|
||||
// Import here to avoid pulling the apply path into the dry-run hot path.
|
||||
const { applyActions } = await import("./github");
|
||||
await applyActions(octokit, args.issueNumber, actions);
|
||||
console.log(`[claude-cron] applied ${actions.length} actions`);
|
||||
}
|
||||
|
||||
async function fullCronMode(args: {
|
||||
dryRun: boolean;
|
||||
githubToken: string;
|
||||
anthropicKey: string;
|
||||
discordWebhookUrl: string | null;
|
||||
}): Promise<void> {
|
||||
const octokit = makeOctokit(args.githubToken);
|
||||
const anthropic = makeAnthropic(args.anthropicKey);
|
||||
|
||||
console.log(`[claude-cron] starting cron (dry_run=${args.dryRun})`);
|
||||
|
||||
if (!args.dryRun) await ensureAllLabels(octokit);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const triagePass = await runTriagePass(octokit, anthropic, {
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
console.log(
|
||||
`[claude-cron] triage pass — processed=${triagePass.processed} closed=${triagePass.closed} kept=${triagePass.kept} calls=${triagePass.callCount}`,
|
||||
);
|
||||
|
||||
const remainingCalls = Math.max(0, MAX_CALLS_PER_RUN - triagePass.callCount);
|
||||
const digestResult = await runDigestPass({
|
||||
octokit,
|
||||
anthropic,
|
||||
triagePass,
|
||||
now,
|
||||
dryRun: args.dryRun,
|
||||
remainingClaudeCalls: remainingCalls,
|
||||
});
|
||||
console.log(
|
||||
`[claude-cron] digest pass — pr_review_calls=${digestResult.prReviewCallCount} composed_by_claude=${digestResult.composedByClaude} posted=${digestResult.posted}`,
|
||||
);
|
||||
|
||||
if (!args.dryRun && args.discordWebhookUrl) {
|
||||
try {
|
||||
await postToDiscord(args.discordWebhookUrl, digestResult.markdown);
|
||||
console.log(`[claude-cron] digest posted to Discord webhook`);
|
||||
} catch (err) {
|
||||
console.error(`[claude-cron] Discord post failed: ${err}`);
|
||||
}
|
||||
} else if (args.dryRun && args.discordWebhookUrl) {
|
||||
console.log(`[claude-cron] DRY_RUN: would post digest to Discord webhook`);
|
||||
}
|
||||
|
||||
console.log(`[claude-cron] done`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cliInvocation =
|
||||
args.dryRunOverride !== null || args.issueNumber !== null;
|
||||
const dryRun = resolveDryRun(args.dryRunOverride, cliInvocation);
|
||||
|
||||
if (!dryRun) assertConfigReadyForLiveRun();
|
||||
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
if (!githubToken) throw new Error("GITHUB_TOKEN env var is required");
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!anthropicKey) throw new Error("ANTHROPIC_API_KEY env var is required");
|
||||
const discordWebhookUrl = process.env.DISCORD_WEBHOOK_URL ?? null;
|
||||
|
||||
if (args.issueNumber !== null) {
|
||||
await singleIssueMode({
|
||||
issueNumber: args.issueNumber,
|
||||
dryRun,
|
||||
githubToken,
|
||||
anthropicKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fullCronMode({
|
||||
dryRun,
|
||||
githubToken,
|
||||
anthropicKey,
|
||||
discordWebhookUrl,
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[claude-cron] Unexpected error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Generated
+1138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "openfrontio-claude-cron",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Daily cron that uses Claude to triage GitHub issues and produce a maintainer digest. See README.md.",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.40.0",
|
||||
"@octokit/rest": "^21.0.2",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { classifyIssue, type TriageDecision } from "./claude";
|
||||
import {
|
||||
AUTO_CLOSE_CLASSIFICATIONS,
|
||||
type AutoCloseClassification,
|
||||
type Classification,
|
||||
CLOSE_COMMENT_BY_CLASSIFICATION,
|
||||
CLOSE_LABEL_BY_CLASSIFICATION,
|
||||
COMMENTS,
|
||||
ENABLE_TRANSLATION_CLOSE,
|
||||
LABELS,
|
||||
MAX_CALLS_PER_RUN,
|
||||
RECENT_ISSUES_CONTEXT_SIZE,
|
||||
} from "./config";
|
||||
import {
|
||||
type Action,
|
||||
applyActions,
|
||||
describeAction,
|
||||
type Issue,
|
||||
listOpenIssues,
|
||||
} from "./github";
|
||||
|
||||
const TRUSTED_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
|
||||
const AUTO_CLOSE_SET = new Set<string>(AUTO_CLOSE_CLASSIFICATIONS);
|
||||
|
||||
export type TriagePassResult = {
|
||||
processed: number;
|
||||
closed: number;
|
||||
kept: number;
|
||||
callCount: number;
|
||||
recordsByNumber: Map<number, TriageRecord>;
|
||||
};
|
||||
|
||||
export type TriageRecord = {
|
||||
issue: Issue;
|
||||
decision: TriageDecision;
|
||||
appliedActions: Action[];
|
||||
};
|
||||
|
||||
export function isTriageCandidate(issue: Issue): boolean {
|
||||
if (issue.is_pull_request) return false;
|
||||
if (issue.state !== "open") return false;
|
||||
if (issue.labels.includes(LABELS.CLAUDE_TRIAGED)) return false;
|
||||
if (TRUSTED_ASSOCIATIONS.has(issue.author_association)) return false;
|
||||
if (issue.labels.some((l) => l.startsWith("auto-closed-"))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isOrgMemberIssueNeedingTag(issue: Issue): boolean {
|
||||
if (issue.is_pull_request) return false;
|
||||
if (issue.state !== "open") return false;
|
||||
if (issue.labels.includes(LABELS.CLAUDE_TRIAGED)) return false;
|
||||
if (!TRUSTED_ASSOCIATIONS.has(issue.author_association)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-close categories require `confidence: high` to act. Anything below is
|
||||
* downgraded to `uncertain` so the maintainer can decide. This is the
|
||||
* asymmetric-cost rule: closing in error is worse than leaving open in error.
|
||||
*/
|
||||
export function downgradeIfLowConfidence(
|
||||
decision: TriageDecision,
|
||||
): TriageDecision {
|
||||
if (
|
||||
AUTO_CLOSE_SET.has(decision.classification) &&
|
||||
decision.confidence !== "high"
|
||||
) {
|
||||
return {
|
||||
classification: "uncertain",
|
||||
confidence: decision.confidence,
|
||||
reasoning: `${decision.reasoning} (downgraded from ${decision.classification} due to confidence=${decision.confidence})`,
|
||||
};
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a triage decision into a list of GitHub actions. Pure function;
|
||||
* does no I/O. The `claude-triaged` label is always appended last so
|
||||
* re-running the cron doesn't re-process this issue.
|
||||
*/
|
||||
export function decisionToActions(
|
||||
decision: TriageDecision,
|
||||
issue: Issue,
|
||||
): Action[] {
|
||||
const author = issue.user?.login ?? "there";
|
||||
const actions: Action[] = [];
|
||||
|
||||
switch (decision.classification) {
|
||||
case "bug":
|
||||
actions.push({ type: "add_label", label: LABELS.BUG });
|
||||
if (decision.suggested_area) {
|
||||
actions.push({ type: "add_label", label: decision.suggested_area });
|
||||
}
|
||||
actions.push({ type: "add_label", label: LABELS.CLAUDE_APPROVED });
|
||||
break;
|
||||
|
||||
case "qol-improvement":
|
||||
actions.push({ type: "add_label", label: LABELS.QOL_IMPROVEMENT });
|
||||
if (decision.suggested_area) {
|
||||
actions.push({ type: "add_label", label: decision.suggested_area });
|
||||
}
|
||||
actions.push({ type: "add_label", label: LABELS.CLAUDE_APPROVED });
|
||||
break;
|
||||
|
||||
case "duplicate":
|
||||
actions.push({ type: "add_label", label: LABELS.POSSIBLE_DUPLICATE });
|
||||
if (decision.duplicate_of) {
|
||||
actions.push({
|
||||
type: "comment",
|
||||
body: COMMENTS.POSSIBLE_DUPLICATE(author, decision.duplicate_of),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "needs-info":
|
||||
actions.push({ type: "add_label", label: LABELS.NEEDS_INFO });
|
||||
if (
|
||||
decision.clarifying_questions &&
|
||||
decision.clarifying_questions.length > 0
|
||||
) {
|
||||
actions.push({
|
||||
type: "comment",
|
||||
body: COMMENTS.NEEDS_INFO(author, decision.clarifying_questions),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "uncertain":
|
||||
case "security":
|
||||
// Security is intentionally not auto-closed: closing doesn't make a
|
||||
// GitHub issue private. We route to claude-uncertain (no comment, no
|
||||
// close) so the maintainer can manually delete, contact the reporter
|
||||
// privately, or otherwise handle it discreetly.
|
||||
actions.push({
|
||||
type: "add_label",
|
||||
label: LABELS.CLAUDE_UNCERTAIN,
|
||||
});
|
||||
break;
|
||||
|
||||
case "feature":
|
||||
case "question":
|
||||
case "support":
|
||||
case "billing":
|
||||
case "translation": {
|
||||
const cls = decision.classification as AutoCloseClassification;
|
||||
if (cls === "translation" && !ENABLE_TRANSLATION_CLOSE) {
|
||||
actions.push({
|
||||
type: "add_label",
|
||||
label: LABELS.CLAUDE_UNCERTAIN,
|
||||
});
|
||||
break;
|
||||
}
|
||||
actions.push({
|
||||
type: "add_label",
|
||||
label: CLOSE_LABEL_BY_CLASSIFICATION[cls],
|
||||
});
|
||||
actions.push({
|
||||
type: "comment",
|
||||
body: CLOSE_COMMENT_BY_CLASSIFICATION[cls](author),
|
||||
});
|
||||
actions.push({ type: "close", reason: "not_planned" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency marker: always last, so we don't re-process this issue tomorrow.
|
||||
actions.push({ type: "add_label", label: LABELS.CLAUDE_TRIAGED });
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function classificationClosesIssue(
|
||||
classification: Classification,
|
||||
): boolean {
|
||||
if (!AUTO_CLOSE_SET.has(classification)) return false;
|
||||
if (classification === "translation") return ENABLE_TRANSLATION_CLOSE;
|
||||
return true;
|
||||
}
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
|
||||
export async function runTriagePass(
|
||||
octokit: Octokit,
|
||||
anthropic: Anthropic,
|
||||
opts: { dryRun: boolean },
|
||||
): Promise<TriagePassResult> {
|
||||
const allOpenIssues = await listOpenIssues(octokit);
|
||||
const candidates = allOpenIssues.filter(isTriageCandidate);
|
||||
const orgMemberIssues = allOpenIssues.filter(isOrgMemberIssueNeedingTag);
|
||||
const contextIssues = allOpenIssues.slice(0, RECENT_ISSUES_CONTEXT_SIZE);
|
||||
|
||||
console.log(
|
||||
`[claude-cron] triage pass — total_open=${allOpenIssues.length} candidates=${candidates.length} org_member=${orgMemberIssues.length}`,
|
||||
);
|
||||
|
||||
const records = new Map<number, TriageRecord>();
|
||||
let processed = 0;
|
||||
let closed = 0;
|
||||
let kept = 0;
|
||||
let callCount = 0;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
// First, handle org-member issues — no Claude call. Mark them claude-approved + claude-triaged.
|
||||
for (const issue of orgMemberIssues) {
|
||||
const actions: Action[] = [
|
||||
{ type: "add_label", label: LABELS.CLAUDE_APPROVED },
|
||||
{ type: "add_label", label: LABELS.CLAUDE_TRIAGED },
|
||||
];
|
||||
const synthDecision: TriageDecision = {
|
||||
classification: "qol-improvement",
|
||||
confidence: "high",
|
||||
reasoning:
|
||||
"Org member — assumed approved by default; skipped Claude call.",
|
||||
};
|
||||
logActions(issue.number, "org-member-skip", actions);
|
||||
if (!opts.dryRun) {
|
||||
try {
|
||||
await applyActions(octokit, issue.number, actions);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[claude-cron] issue #${issue.number} — apply error: ${err}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[claude-cron] issue #${issue.number} — DRY_RUN: not applied`,
|
||||
);
|
||||
}
|
||||
records.set(issue.number, {
|
||||
issue,
|
||||
decision: synthDecision,
|
||||
appliedActions: actions,
|
||||
});
|
||||
processed += 1;
|
||||
kept += 1;
|
||||
}
|
||||
|
||||
// Then, candidates needing Claude classification.
|
||||
for (const issue of candidates) {
|
||||
if (callCount >= MAX_CALLS_PER_RUN) {
|
||||
console.warn(
|
||||
`[claude-cron] hit MAX_CALLS_PER_RUN cap (${MAX_CALLS_PER_RUN}); stopping triage pass`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error(
|
||||
`[claude-cron] ${MAX_CONSECUTIVE_FAILURES} consecutive Claude failures; aborting triage pass`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let rawDecision: TriageDecision;
|
||||
try {
|
||||
const result = await classifyIssue(anthropic, issue, contextIssues);
|
||||
callCount += 1;
|
||||
rawDecision = result.value;
|
||||
consecutiveFailures = 0;
|
||||
console.log(
|
||||
`[claude-cron] issue #${issue.number} — tokens in=${result.usage.input_tokens} out=${result.usage.output_tokens}`,
|
||||
);
|
||||
} catch (err) {
|
||||
consecutiveFailures += 1;
|
||||
console.error(
|
||||
`[claude-cron] issue #${issue.number} — Claude error (consecutive=${consecutiveFailures}): ${err}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const decision = downgradeIfLowConfidence(rawDecision);
|
||||
if (decision !== rawDecision) {
|
||||
console.log(
|
||||
`[claude-cron] issue #${issue.number} — downgraded ${rawDecision.classification}/${rawDecision.confidence} -> uncertain`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[claude-cron] issue #${issue.number} — classification=${decision.classification} confidence=${decision.confidence} — ${decision.reasoning}`,
|
||||
);
|
||||
|
||||
const actions = decisionToActions(decision, issue);
|
||||
logActions(issue.number, decision.classification, actions);
|
||||
|
||||
if (!opts.dryRun) {
|
||||
try {
|
||||
await applyActions(octokit, issue.number, actions);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[claude-cron] issue #${issue.number} — apply error: ${err}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[claude-cron] issue #${issue.number} — DRY_RUN: not applied`,
|
||||
);
|
||||
}
|
||||
|
||||
records.set(issue.number, { issue, decision, appliedActions: actions });
|
||||
processed += 1;
|
||||
if (classificationClosesIssue(decision.classification)) closed += 1;
|
||||
else kept += 1;
|
||||
}
|
||||
|
||||
return { processed, closed, kept, callCount, recordsByNumber: records };
|
||||
}
|
||||
|
||||
function logActions(
|
||||
issueNumber: number,
|
||||
label: string,
|
||||
actions: Action[],
|
||||
): void {
|
||||
const summary = actions.map(describeAction).join(", ");
|
||||
console.log(
|
||||
`[claude-cron] issue #${issueNumber} — ${label} — actions: ${summary}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { composeDigest, type DigestInputs } from "./claude";
|
||||
import { DIGEST_ISSUE_NUMBER, MAX_CALLS_PER_RUN, REPO } from "./config";
|
||||
import { gatherDigestInputs, renderFallbackDigest } from "./digest-formatter";
|
||||
import type { TriagePassResult } from "./pass1-triage";
|
||||
import { reviewAllPRsAwaitingReview } from "./pr-reviewer";
|
||||
|
||||
export type DigestPassResult = {
|
||||
markdown: string;
|
||||
prReviewCallCount: number;
|
||||
composedByClaude: boolean;
|
||||
posted: boolean;
|
||||
};
|
||||
|
||||
export type RunDigestArgs = {
|
||||
octokit: Octokit;
|
||||
anthropic: Anthropic;
|
||||
triagePass: TriagePassResult;
|
||||
now: Date;
|
||||
dryRun: boolean;
|
||||
remainingClaudeCalls: number;
|
||||
};
|
||||
|
||||
export async function runDigestPass(
|
||||
args: RunDigestArgs,
|
||||
): Promise<DigestPassResult> {
|
||||
const { octokit, anthropic, triagePass, now, dryRun, remainingClaudeCalls } =
|
||||
args;
|
||||
|
||||
// Phase 2a — review every open PR awaiting maintainer review.
|
||||
const { reviews, callCount: prReviewCallCount } =
|
||||
await reviewAllPRsAwaitingReview(octokit, anthropic, {
|
||||
maxCalls: Math.max(0, remainingClaudeCalls - 1),
|
||||
});
|
||||
|
||||
// Gather all inputs for the digest composer.
|
||||
const inputs = await gatherDigestInputs({
|
||||
octokit,
|
||||
now,
|
||||
triagePass,
|
||||
prReviews: reviews,
|
||||
});
|
||||
|
||||
// Phase 2b — compose the digest markdown via Claude (with fallback).
|
||||
let markdown: string;
|
||||
let composedByClaude = false;
|
||||
try {
|
||||
const result = await composeDigest(anthropic, inputs);
|
||||
markdown = result.value.markdown;
|
||||
composedByClaude = true;
|
||||
console.log(
|
||||
`[claude-cron] digest composed — tokens in=${result.usage.input_tokens} out=${result.usage.output_tokens}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[claude-cron] digest composition failed; using deterministic fallback: ${err}`,
|
||||
);
|
||||
markdown = renderFallbackDigest(inputs);
|
||||
}
|
||||
|
||||
let posted = false;
|
||||
if (dryRun) {
|
||||
console.log(`[claude-cron] DRY_RUN: digest (not posted):\n\n${markdown}\n`);
|
||||
} else {
|
||||
posted = await postDigestToTrackingIssue(octokit, markdown);
|
||||
}
|
||||
|
||||
return { markdown, prReviewCallCount, composedByClaude, posted };
|
||||
}
|
||||
|
||||
async function postDigestToTrackingIssue(
|
||||
octokit: Octokit,
|
||||
markdown: string,
|
||||
): Promise<boolean> {
|
||||
if (DIGEST_ISSUE_NUMBER === 0) {
|
||||
console.warn(
|
||||
`[claude-cron] DIGEST_ISSUE_NUMBER is unset in config.ts; logging digest instead of posting`,
|
||||
);
|
||||
console.log(markdown);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { data: tracking } = await octokit.rest.issues.get({
|
||||
...REPO,
|
||||
issue_number: DIGEST_ISSUE_NUMBER,
|
||||
});
|
||||
if (tracking.state === "closed") {
|
||||
console.error(
|
||||
`[claude-cron] tracking issue #${DIGEST_ISSUE_NUMBER} is closed; not posting. Reopen or update DIGEST_ISSUE_NUMBER.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[claude-cron] could not fetch tracking issue #${DIGEST_ISSUE_NUMBER}: ${err}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await octokit.rest.issues.createComment({
|
||||
...REPO,
|
||||
issue_number: DIGEST_ISSUE_NUMBER,
|
||||
body: markdown,
|
||||
});
|
||||
console.log(
|
||||
`[claude-cron] digest posted to tracking issue #${DIGEST_ISSUE_NUMBER}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export { DIGEST_ISSUE_NUMBER, MAX_CALLS_PER_RUN };
|
||||
export type { DigestInputs };
|
||||
@@ -0,0 +1,132 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { reviewPR, type PRReview } from "./claude";
|
||||
import { MAINTAINER_LOGIN } from "./config";
|
||||
import {
|
||||
fetchPRDiff,
|
||||
getIssue,
|
||||
listOpenPRs,
|
||||
listPullReviews,
|
||||
type Issue,
|
||||
type PullRequest,
|
||||
} from "./github";
|
||||
|
||||
export type PRReviewRecord = {
|
||||
pr: PullRequest;
|
||||
review: PRReview;
|
||||
};
|
||||
|
||||
export function extractLinkedIssueNumber(prBody: string): number | null {
|
||||
// Match common close-keyword references: "Fixes #123", "Closes #45", etc.
|
||||
// Per GitHub linking syntax; we ignore cross-repo references for simplicity.
|
||||
const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*[:#]?\s*#(\d+)\b/gi;
|
||||
const matches = [...prBody.matchAll(re)];
|
||||
if (matches.length === 0) return null;
|
||||
// If multiple, prefer the first — usually the primary linked issue.
|
||||
return parseInt(matches[0][1], 10);
|
||||
}
|
||||
|
||||
// Pure predicate so the SHA-comparison logic is unit-testable.
|
||||
// Reviewed = maintainer has at least one review on the current head commit.
|
||||
// Comparing review.commit_id to pr.head_sha (instead of timestamps vs
|
||||
// pr.updated_at) avoids re-flagging PRs whose only change since the review
|
||||
// is a label, comment, or title edit.
|
||||
export function isReviewPendingFromReviews(
|
||||
reviews: { user_login: string | null; commit_id: string | null }[],
|
||||
maintainerLogin: string,
|
||||
headSha: string,
|
||||
): boolean {
|
||||
const byMaintainer = reviews.filter((r) => r.user_login === maintainerLogin);
|
||||
if (byMaintainer.length === 0) return true;
|
||||
return !byMaintainer.some((r) => r.commit_id === headSha);
|
||||
}
|
||||
|
||||
export async function isMaintainerReviewPending(
|
||||
octokit: Octokit,
|
||||
pr: PullRequest,
|
||||
): Promise<boolean> {
|
||||
const reviews = await listPullReviews(octokit, pr.number);
|
||||
return isReviewPendingFromReviews(reviews, MAINTAINER_LOGIN, pr.head_sha);
|
||||
}
|
||||
|
||||
export async function listPRsAwaitingMaintainerReview(
|
||||
octokit: Octokit,
|
||||
): Promise<PullRequest[]> {
|
||||
const allOpen = await listOpenPRs(octokit);
|
||||
const result: PullRequest[] = [];
|
||||
for (const pr of allOpen) {
|
||||
if (pr.draft) continue;
|
||||
try {
|
||||
if (await isMaintainerReviewPending(octokit, pr)) {
|
||||
result.push(pr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[claude-cron] PR #${pr.number} — error checking reviews; including in digest just in case: ${err}`,
|
||||
);
|
||||
result.push(pr);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchLinkedIssue(
|
||||
octokit: Octokit,
|
||||
pr: PullRequest,
|
||||
): Promise<Issue | null> {
|
||||
const issueNumber = extractLinkedIssueNumber(pr.body);
|
||||
if (issueNumber === null) return null;
|
||||
const issue = await getIssue(octokit, issueNumber);
|
||||
if (!issue) return null;
|
||||
if (issue.is_pull_request) return null;
|
||||
return issue;
|
||||
}
|
||||
|
||||
export async function reviewAllPRsAwaitingReview(
|
||||
octokit: Octokit,
|
||||
anthropic: Anthropic,
|
||||
opts: { maxCalls: number },
|
||||
): Promise<{ reviews: PRReviewRecord[]; callCount: number }> {
|
||||
const prs = await listPRsAwaitingMaintainerReview(octokit);
|
||||
console.log(
|
||||
`[claude-cron] PR review phase — open PRs awaiting maintainer review: ${prs.length}`,
|
||||
);
|
||||
|
||||
const reviews: PRReviewRecord[] = [];
|
||||
let callCount = 0;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
for (const pr of prs) {
|
||||
if (callCount >= opts.maxCalls) {
|
||||
console.warn(
|
||||
`[claude-cron] PR review phase — hit maxCalls cap (${opts.maxCalls}); stopping`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (consecutiveFailures >= 3) {
|
||||
console.error(
|
||||
`[claude-cron] PR review phase — 3 consecutive failures; stopping`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const diff = await fetchPRDiff(octokit, pr.number);
|
||||
const linkedIssue = await fetchLinkedIssue(octokit, pr);
|
||||
const result = await reviewPR(anthropic, pr, diff, linkedIssue);
|
||||
callCount += 1;
|
||||
consecutiveFailures = 0;
|
||||
console.log(
|
||||
`[claude-cron] PR #${pr.number} — review: risk=${result.value.risk} tests=${result.value.tests_present} scope=${result.value.scope_match} tokens in=${result.usage.input_tokens} out=${result.usage.output_tokens}`,
|
||||
);
|
||||
reviews.push({ pr, review: result.value });
|
||||
} catch (err) {
|
||||
consecutiveFailures += 1;
|
||||
console.error(
|
||||
`[claude-cron] PR #${pr.number} — review error (consecutive=${consecutiveFailures}): ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { reviews, callCount };
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DigestInputs } from "../claude";
|
||||
import {
|
||||
ageDays,
|
||||
formatIsoDate,
|
||||
renderFallbackDigest,
|
||||
truncateForDiscord,
|
||||
} from "../digest-formatter";
|
||||
|
||||
function emptyInputs(): DigestInputs {
|
||||
return {
|
||||
today: "2026-05-29",
|
||||
triageReadyInWindow: [],
|
||||
needsMaintainerTriage: [],
|
||||
needsInfoCount: 0,
|
||||
staleClosingSoon: [],
|
||||
awaitingMilestone: [],
|
||||
prReviews: [],
|
||||
triageSummary: { processed: 0, closed: 0, kept: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatIsoDate", () => {
|
||||
it("renders YYYY-MM-DD", () => {
|
||||
expect(formatIsoDate(new Date("2026-05-29T12:34:56Z"))).toBe("2026-05-29");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFallbackDigest", () => {
|
||||
it("includes only sections with content", () => {
|
||||
const out = renderFallbackDigest(emptyInputs());
|
||||
expect(out).toContain("# Daily Triage — 2026-05-29");
|
||||
expect(out).not.toContain("🟢 Newly classified");
|
||||
expect(out).not.toContain("🟡 Needs your judgment");
|
||||
expect(out).not.toContain("🔴 Auto-closing");
|
||||
expect(out).not.toContain("🟠 Awaiting your milestone");
|
||||
expect(out).not.toContain("🔵 PRs needing review");
|
||||
});
|
||||
|
||||
it("emits the newly-classified section with each issue", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
triageReadyInWindow: [
|
||||
{
|
||||
number: 100,
|
||||
title: "Crash on right-click",
|
||||
author: "alice",
|
||||
area: "area:client",
|
||||
},
|
||||
{ number: 101, title: "Hotkey for chat", author: "bob", area: null },
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🟢 Newly classified by Claude (2)");
|
||||
expect(out).toContain(
|
||||
"#100 [area:client] — Crash on right-click (by @alice)",
|
||||
);
|
||||
expect(out).toContain("#101 — Hotkey for chat (by @bob)");
|
||||
});
|
||||
|
||||
it("emits the needs-judgment section with reasoning when present", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
needsMaintainerTriage: [
|
||||
{ number: 5, title: "ambiguous report", reasoning: "could be PEBKAC" },
|
||||
{ number: 6, title: "no reasoning case" },
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🟡 Needs your judgment (2)");
|
||||
expect(out).toContain("#5 — ambiguous report _(could be PEBKAC)_");
|
||||
expect(out).toContain("#6 — no reasoning case");
|
||||
});
|
||||
|
||||
it("emits the awaiting-milestone section with all entries, tags, and age", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
awaitingMilestone: [
|
||||
{
|
||||
number: 45,
|
||||
title: "Crash on right-click",
|
||||
ageDays: 21,
|
||||
primaryLabel: "bug",
|
||||
area: "area:client",
|
||||
},
|
||||
{
|
||||
number: 76,
|
||||
title: "Add chat hotkey",
|
||||
ageDays: 12,
|
||||
primaryLabel: "qol-improvement",
|
||||
area: null,
|
||||
},
|
||||
{
|
||||
number: 99,
|
||||
title: "Just opened",
|
||||
ageDays: 0,
|
||||
primaryLabel: null,
|
||||
area: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🟠 Awaiting your milestone (3)");
|
||||
expect(out).toContain(
|
||||
"#45 [bug, area:client] — 21d — Crash on right-click",
|
||||
);
|
||||
expect(out).toContain("#76 [qol-improvement] — 12d — Add chat hotkey");
|
||||
expect(out).toContain("#99 — 0d — Just opened");
|
||||
});
|
||||
|
||||
it("emits the PR review section with risk/tests/scope summary", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
prReviews: [
|
||||
{
|
||||
pr: { number: 220, title: "tick refactor", author: "carol" },
|
||||
review: {
|
||||
summary: "refactors tick loop",
|
||||
risk: "high",
|
||||
tests_present: "yes",
|
||||
scope_match: "matches",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🔵 PRs needing review (1)");
|
||||
expect(out).toContain(
|
||||
"#220 by @carol — refactors tick loop · risk: high · tests: yes · scope: matches",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends a needs-info count when nonzero", () => {
|
||||
const out = renderFallbackDigest({ ...emptyInputs(), needsInfoCount: 4 });
|
||||
expect(out).toContain("4 issues waiting on reporter");
|
||||
});
|
||||
|
||||
it("omits needs-info line when count is zero", () => {
|
||||
expect(renderFallbackDigest(emptyInputs())).not.toContain(
|
||||
"waiting on reporter",
|
||||
);
|
||||
});
|
||||
|
||||
it("always tags itself as the fallback", () => {
|
||||
expect(renderFallbackDigest(emptyInputs())).toContain("Fallback digest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ageDays", () => {
|
||||
it("rounds down to whole days", () => {
|
||||
const now = new Date("2026-05-30T12:00:00Z");
|
||||
expect(ageDays("2026-05-30T11:00:00Z", now)).toBe(0);
|
||||
expect(ageDays("2026-05-29T11:00:00Z", now)).toBe(1);
|
||||
expect(ageDays("2026-05-09T12:00:00Z", now)).toBe(21);
|
||||
});
|
||||
|
||||
it("returns 0 for future-dated input (clock skew)", () => {
|
||||
const now = new Date("2026-05-30T12:00:00Z");
|
||||
expect(ageDays("2026-06-01T00:00:00Z", now)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for unparseable input", () => {
|
||||
expect(ageDays("not-a-date", new Date())).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateForDiscord", () => {
|
||||
it("returns the input unchanged when under the limit", () => {
|
||||
expect(truncateForDiscord("short")).toBe("short");
|
||||
});
|
||||
|
||||
it("truncates and appends a marker when over the limit", () => {
|
||||
const long = "a".repeat(2000);
|
||||
const out = truncateForDiscord(long, 100);
|
||||
expect(out.endsWith("…(truncated)")).toBe(true);
|
||||
expect(out.length).toBeLessThan(long.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FORBIDDEN_LABELS } from "../config";
|
||||
import { assertNotForbidden, type Action } from "../github";
|
||||
|
||||
describe("assertNotForbidden", () => {
|
||||
it("allows non-forbidden labels", () => {
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "add_label", label: "bug" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "remove_label", label: "needs-info" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when trying to add a Layer A label", () => {
|
||||
for (const forbidden of FORBIDDEN_LABELS) {
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "add_label", label: forbidden }),
|
||||
).toThrow(/forbidden label/);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when trying to remove a Layer A label", () => {
|
||||
for (const forbidden of FORBIDDEN_LABELS) {
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "remove_label", label: forbidden }),
|
||||
).toThrow(/forbidden label/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw for comment / close actions", () => {
|
||||
const actions: Action[] = [
|
||||
{ type: "comment", body: "hello" },
|
||||
{ type: "close", reason: "not_planned" },
|
||||
];
|
||||
for (const a of actions) {
|
||||
expect(() => assertNotForbidden(a)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("FORBIDDEN_LABELS contains exactly the Layer A labels", () => {
|
||||
expect(FORBIDDEN_LABELS.has("approved")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("not-approved")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("stale")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("keep-open")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("auto-closed-stale")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("bug")).toBe(false);
|
||||
expect(FORBIDDEN_LABELS.has("needs-info")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TriageDecision } from "../claude";
|
||||
import { ENABLE_TRANSLATION_CLOSE, LABELS } from "../config";
|
||||
import type { Issue } from "../github";
|
||||
import {
|
||||
classificationClosesIssue,
|
||||
decisionToActions,
|
||||
downgradeIfLowConfidence,
|
||||
isOrgMemberIssueNeedingTag,
|
||||
isTriageCandidate,
|
||||
} from "../pass1-triage";
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
number: 1,
|
||||
title: "Test issue",
|
||||
body: "Test body",
|
||||
state: "open",
|
||||
labels: [],
|
||||
user: { login: "alice" },
|
||||
author_association: "NONE",
|
||||
created_at: "2026-05-29T00:00:00Z",
|
||||
updated_at: "2026-05-29T00:00:00Z",
|
||||
is_pull_request: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isTriageCandidate", () => {
|
||||
it("accepts a clean open issue from an outside contributor", () => {
|
||||
expect(isTriageCandidate(makeIssue())).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects pull requests", () => {
|
||||
expect(isTriageCandidate(makeIssue({ is_pull_request: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects closed issues", () => {
|
||||
expect(isTriageCandidate(makeIssue({ state: "closed" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects already-triaged issues", () => {
|
||||
expect(
|
||||
isTriageCandidate(makeIssue({ labels: [LABELS.CLAUDE_TRIAGED] })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects issues from org members", () => {
|
||||
for (const assoc of ["OWNER", "MEMBER", "COLLABORATOR"]) {
|
||||
expect(isTriageCandidate(makeIssue({ author_association: assoc }))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects issues that have any auto-closed-* label", () => {
|
||||
expect(
|
||||
isTriageCandidate(makeIssue({ labels: ["auto-closed-feature"] })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTriageCandidate(makeIssue({ labels: ["auto-closed-support"] })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOrgMemberIssueNeedingTag", () => {
|
||||
it("flags org-member issues without claude-triaged", () => {
|
||||
expect(
|
||||
isOrgMemberIssueNeedingTag(makeIssue({ author_association: "MEMBER" })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag org-member issues already tagged", () => {
|
||||
expect(
|
||||
isOrgMemberIssueNeedingTag(
|
||||
makeIssue({
|
||||
author_association: "MEMBER",
|
||||
labels: [LABELS.CLAUDE_TRIAGED],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag outside-contributor issues", () => {
|
||||
expect(
|
||||
isOrgMemberIssueNeedingTag(makeIssue({ author_association: "NONE" })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downgradeIfLowConfidence", () => {
|
||||
const cases: Array<{
|
||||
classification: TriageDecision["classification"];
|
||||
confidence: TriageDecision["confidence"];
|
||||
expectDowngrade: boolean;
|
||||
}> = [
|
||||
{ classification: "feature", confidence: "low", expectDowngrade: true },
|
||||
{ classification: "feature", confidence: "medium", expectDowngrade: true },
|
||||
{ classification: "feature", confidence: "high", expectDowngrade: false },
|
||||
{ classification: "question", confidence: "medium", expectDowngrade: true },
|
||||
{ classification: "support", confidence: "low", expectDowngrade: true },
|
||||
{ classification: "billing", confidence: "high", expectDowngrade: false },
|
||||
{ classification: "security", confidence: "low", expectDowngrade: false },
|
||||
{ classification: "security", confidence: "high", expectDowngrade: false },
|
||||
{
|
||||
classification: "translation",
|
||||
confidence: "medium",
|
||||
expectDowngrade: true,
|
||||
},
|
||||
{ classification: "bug", confidence: "low", expectDowngrade: false },
|
||||
{
|
||||
classification: "qol-improvement",
|
||||
confidence: "low",
|
||||
expectDowngrade: false,
|
||||
},
|
||||
{
|
||||
classification: "needs-info",
|
||||
confidence: "medium",
|
||||
expectDowngrade: false,
|
||||
},
|
||||
{ classification: "duplicate", confidence: "low", expectDowngrade: false },
|
||||
{ classification: "uncertain", confidence: "low", expectDowngrade: false },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(`${c.classification} @ ${c.confidence} → ${c.expectDowngrade ? "downgrade" : "keep"}`, () => {
|
||||
const input: TriageDecision = {
|
||||
classification: c.classification,
|
||||
confidence: c.confidence,
|
||||
reasoning: "test",
|
||||
};
|
||||
const out = downgradeIfLowConfidence(input);
|
||||
if (c.expectDowngrade) {
|
||||
expect(out.classification).toBe("uncertain");
|
||||
expect(out.reasoning).toContain("downgraded");
|
||||
} else {
|
||||
expect(out).toBe(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("decisionToActions", () => {
|
||||
it("bug → bug + area + claude-approved + claude-triaged", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "bug",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
suggested_area: "area:client",
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const labelsAdded = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labelsAdded).toEqual([
|
||||
LABELS.BUG,
|
||||
"area:client",
|
||||
LABELS.CLAUDE_APPROVED,
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
]);
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bug without area still claude-approved and triaged", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "bug", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labelsAdded = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labelsAdded).toEqual([
|
||||
LABELS.BUG,
|
||||
LABELS.CLAUDE_APPROVED,
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("qol-improvement mirrors bug structure with qol label", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "qol-improvement",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
suggested_area: "area:core",
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const labelsAdded = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labelsAdded).toEqual([
|
||||
LABELS.QOL_IMPROVEMENT,
|
||||
"area:core",
|
||||
LABELS.CLAUDE_APPROVED,
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplicate adds possible-duplicate, posts dup comment, does NOT close", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "duplicate",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
duplicate_of: 42,
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toContain(LABELS.POSSIBLE_DUPLICATE);
|
||||
expect(labels).toContain(LABELS.CLAUDE_TRIAGED);
|
||||
const comment = actions.find((a) => a.type === "comment");
|
||||
expect(comment).toBeDefined();
|
||||
expect((comment as { body: string }).body).toContain("#42");
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("needs-info includes only the questions Claude provided", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "needs-info",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
clarifying_questions: ["Which browser?", "What did you click?"],
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const comment = actions.find((a) => a.type === "comment");
|
||||
expect(comment).toBeDefined();
|
||||
expect((comment as { body: string }).body).toContain("Which browser?");
|
||||
expect((comment as { body: string }).body).toContain("What did you click?");
|
||||
});
|
||||
|
||||
it("needs-info with no questions adds label but skips comment", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "needs-info", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("each auto-close classification adds correct label, comment, close", () => {
|
||||
const expectations: Array<{
|
||||
cls: TriageDecision["classification"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ cls: "feature", label: LABELS.AUTO_CLOSED_FEATURE },
|
||||
{ cls: "question", label: LABELS.AUTO_CLOSED_QUESTION },
|
||||
{ cls: "support", label: LABELS.AUTO_CLOSED_SUPPORT },
|
||||
{ cls: "billing", label: LABELS.AUTO_CLOSED_BILLING },
|
||||
];
|
||||
for (const { cls, label } of expectations) {
|
||||
const actions = decisionToActions(
|
||||
{ classification: cls, confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toContain(label);
|
||||
expect(labels).toContain(LABELS.CLAUDE_TRIAGED);
|
||||
expect(actions.find((a) => a.type === "comment")).toBeDefined();
|
||||
expect(actions.find((a) => a.type === "close")).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("security is never auto-closed; routes to claude-uncertain (no comment, no close)", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "security", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toEqual([LABELS.CLAUDE_UNCERTAIN, LABELS.CLAUDE_TRIAGED]);
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("translation auto-closes only when ENABLE_TRANSLATION_CLOSE is true", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "translation", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
if (ENABLE_TRANSLATION_CLOSE) {
|
||||
expect(actions.find((a) => a.type === "close")).toBeDefined();
|
||||
} else {
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toContain(LABELS.CLAUDE_UNCERTAIN);
|
||||
}
|
||||
});
|
||||
|
||||
it("uncertain adds claude-uncertain and claude-triaged only", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "uncertain", confidence: "low", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toEqual([LABELS.CLAUDE_UNCERTAIN, LABELS.CLAUDE_TRIAGED]);
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("always ends with the claude-triaged label", () => {
|
||||
const all: TriageDecision["classification"][] = [
|
||||
"bug",
|
||||
"qol-improvement",
|
||||
"duplicate",
|
||||
"needs-info",
|
||||
"feature",
|
||||
"question",
|
||||
"support",
|
||||
"billing",
|
||||
"security",
|
||||
"translation",
|
||||
"uncertain",
|
||||
];
|
||||
for (const cls of all) {
|
||||
const actions = decisionToActions(
|
||||
{ classification: cls, confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const lastAddLabel = [...actions]
|
||||
.reverse()
|
||||
.find((a) => a.type === "add_label");
|
||||
expect((lastAddLabel as { label: string }).label).toBe(
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("classificationClosesIssue", () => {
|
||||
it("auto-close categories return true (translation depends on flag)", () => {
|
||||
expect(classificationClosesIssue("feature")).toBe(true);
|
||||
expect(classificationClosesIssue("question")).toBe(true);
|
||||
expect(classificationClosesIssue("support")).toBe(true);
|
||||
expect(classificationClosesIssue("billing")).toBe(true);
|
||||
expect(classificationClosesIssue("translation")).toBe(
|
||||
ENABLE_TRANSLATION_CLOSE,
|
||||
);
|
||||
});
|
||||
|
||||
it("non-close categories return false (including security)", () => {
|
||||
expect(classificationClosesIssue("bug")).toBe(false);
|
||||
expect(classificationClosesIssue("qol-improvement")).toBe(false);
|
||||
expect(classificationClosesIssue("duplicate")).toBe(false);
|
||||
expect(classificationClosesIssue("needs-info")).toBe(false);
|
||||
expect(classificationClosesIssue("uncertain")).toBe(false);
|
||||
expect(classificationClosesIssue("security")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractLinkedIssueNumber,
|
||||
isReviewPendingFromReviews,
|
||||
} from "../pr-reviewer";
|
||||
|
||||
describe("extractLinkedIssueNumber", () => {
|
||||
it("matches 'Fixes #123'", () => {
|
||||
expect(extractLinkedIssueNumber("Fixes #123")).toBe(123);
|
||||
});
|
||||
|
||||
it("matches 'closes #45'", () => {
|
||||
expect(extractLinkedIssueNumber("This PR closes #45 by adding...")).toBe(
|
||||
45,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches 'Resolves #99'", () => {
|
||||
expect(extractLinkedIssueNumber("Summary\n\nResolves #99")).toBe(99);
|
||||
});
|
||||
|
||||
it("matches 'fixed #7'", () => {
|
||||
expect(extractLinkedIssueNumber("fixed #7")).toBe(7);
|
||||
});
|
||||
|
||||
it("prefers the first match when multiple keywords are present", () => {
|
||||
expect(extractLinkedIssueNumber("Fixes #10\n\nAlso closes #20")).toBe(10);
|
||||
});
|
||||
|
||||
it("returns null when no linking keyword is found", () => {
|
||||
expect(extractLinkedIssueNumber("Just a refactor")).toBeNull();
|
||||
expect(
|
||||
extractLinkedIssueNumber("Reference to #50 but no keyword"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty body", () => {
|
||||
expect(extractLinkedIssueNumber("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReviewPendingFromReviews", () => {
|
||||
const HEAD = "abc123";
|
||||
const OLD = "def456";
|
||||
|
||||
it("pending when maintainer has no reviews at all", () => {
|
||||
expect(isReviewPendingFromReviews([], "evan", HEAD)).toBe(true);
|
||||
});
|
||||
|
||||
it("pending when only OTHER reviewers have reviewed the head", () => {
|
||||
const reviews = [{ user_login: "alice", commit_id: HEAD }];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(true);
|
||||
});
|
||||
|
||||
it("pending when maintainer reviewed an OLD commit (new commits pushed)", () => {
|
||||
const reviews = [{ user_login: "evan", commit_id: OLD }];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(true);
|
||||
});
|
||||
|
||||
it("not pending when maintainer has reviewed the current head", () => {
|
||||
const reviews = [
|
||||
{ user_login: "evan", commit_id: OLD },
|
||||
{ user_login: "evan", commit_id: HEAD },
|
||||
];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(false);
|
||||
});
|
||||
|
||||
it("not pending if maintainer has at least one review on head (even if also reviewed older commits)", () => {
|
||||
const reviews = [
|
||||
{ user_login: "alice", commit_id: HEAD },
|
||||
{ user_login: "evan", commit_id: HEAD },
|
||||
];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user