This commit is contained in:
evanpelle
2026-05-29 21:11:50 -07:00
parent 885e31460c
commit 168db85be5
20 changed files with 4181 additions and 1 deletions
+5
View File
@@ -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:
+38
View File
@@ -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
View File
@@ -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": {
"**/*": [
+106
View File
@@ -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 (~13k input tokens, <500 output). Triage runs once per new outside-author issue (~310/day in steady state); PR review runs once per non-draft open PR awaiting review. A daily run typically uses 1020 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.
+622
View File
@@ -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: 200600 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**: 25 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,
});
}
+245
View File
@@ -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,
};
+216
View File
@@ -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)";
}
+20
View File
@@ -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}`,
);
}
}
+339
View File
@@ -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 };
+207
View File
@@ -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);
});
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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"
}
}
+318
View File
@@ -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}`,
);
}
+112
View File
@@ -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 };
+132
View File
@@ -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);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {}
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}