diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0c4d166..fbf855e73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/claude-cron.yml b/.github/workflows/claude-cron.yml new file mode 100644 index 000000000..9a6a8e565 --- /dev/null +++ b/.github/workflows/claude-cron.yml @@ -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 diff --git a/package.json b/package.json index 161f142c0..c7383c3d5 100644 --- a/package.json +++ b/package.json @@ -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": { "**/*": [ diff --git a/scripts/claude-cron/README.md b/scripts/claude-cron/README.md new file mode 100644 index 000000000..8c8908e7d --- /dev/null +++ b/scripts/claude-cron/README.md @@ -0,0 +1,106 @@ +# Claude Triage & Daily Digest + +Daily cron that uses Claude (Anthropic API) to triage incoming GitHub issues and produce a maintainer digest. Builds on top of [Issue Lifecycle (Layer A)](../issue-lifecycle/README.md), which it never overrides. + +## What it does + +Once a day, [the workflow](../../.github/workflows/claude-cron.yml) runs two passes: + +1. **Pass 1 โ€” Triage.** For each open issue without the `claude-triaged` label (and not authored by an org member), Claude classifies the issue. Bugs and small QoL improvements get labelled; out-of-scope reports (feature requests, support questions, billing, security, translation) get auto-closed with a redirect comment. +2. **Pass 2 โ€” Digest.** Claude reviews every open PR awaiting maintainer review, then composes a markdown digest of what needs your attention. The digest gets posted as a comment on a pinned tracking issue, and optionally to a Discord webhook. + +Both passes default to dry-run. + +## Rules and guarantees + +- **Idempotent.** Once an issue has the `claude-triaged` label, it is never re-processed. +- **Conservative.** Auto-close decisions require `confidence: high` from Claude. Anything else stays open with `claude-uncertain`. +- **Advisor-only on duplicates.** Suspected duplicates get `possible-duplicate` and a comment โ€” never auto-closed. +- **Hands off Layer A.** Adding or removing labels in `FORBIDDEN_LABELS` (`approved`, `not-approved`, `stale`, `keep-open`, `auto-closed-stale`) throws at runtime. +- **No milestones, no assignments, no reopens.** Claude can't take these actions. + +## Triggers + +- [`claude-cron.yml`](../../.github/workflows/claude-cron.yml) โ€” daily at 06:00 UTC, plus `workflow_dispatch`. + +## Local testing + +```bash +cd scripts/claude-cron +npm install --no-audit --no-fund --ignore-scripts + +export GITHUB_TOKEN=ghp_... # PAT with repo scope (issues:write) +export ANTHROPIC_API_KEY=sk-ant-... # from console.anthropic.com + +# Triage a single issue and print the decision (no apply): +npx tsx index.ts --issue 1234 + +# Run the full daily sweep in dry-run (logs everything, applies nothing): +npx tsx index.ts + +# Run the full sweep for real (BE CAREFUL โ€” this mutates the repo and posts a digest): +npx tsx index.ts --no-dry-run +``` + +CLI invocations (anything with `--issue` or `--dry-run` / `--no-dry-run`) default to dry-run unless `--no-dry-run` is given explicitly. Cron invocations (no CLI flags) read `DRY_RUN` from env. + +Run unit tests from the repo root: + +```bash +npx vitest run --dir scripts/claude-cron/tests +``` + +## Toggling dry-run in production + +1. Go to repo **Settings โ†’ Secrets and variables โ†’ Actions โ†’ Variables**. +2. Set `CLAUDE_CRON_DRY_RUN`. +3. Set to `false` to act for real; any other value (or unset) keeps it in dry-run mode. + +The default is `true`. + +## Pre-launch checklist + +Before flipping `CLAUDE_CRON_DRY_RUN` to `false`, the maintainer should: + +- [ ] Set `ANTHROPIC_API_KEY` as a repo secret. +- [ ] Fill in the TODO constants in [`config.ts`](./config.ts): + - `URLS.GAME_DISCORD` + - `URLS.SECURITY_EMAIL` (or publish a `SECURITY.md` and link it) + - Decide on `ENABLE_TRANSLATION_CLOSE` (default `true`) +- [ ] Create a tracking issue for the digest, pin it, and set `DIGEST_ISSUE_NUMBER` in `config.ts`. Until set, the digest is logged to stdout instead of posted. +- [ ] _(Optional)_ Set `DISCORD_WEBHOOK_URL` as a repo secret if Discord posting is desired. +- [ ] Watch dry-run logs in Actions output for at least one week. Verify: + - Classification looks reasonable for the actual issues received. + - Auto-close decisions are ones you'd agree with. + - The digest format is useful. + - Token usage is in the expected range (logged per call). +- [ ] Tune the triage system prompt in [`claude.ts`](./claude.ts) based on observed misclassifications. +- [ ] Flip `CLAUDE_CRON_DRY_RUN` repo variable to `'false'`. + +## File layout + +- [`index.ts`](./index.ts) โ€” entrypoint and CLI arg parsing +- [`config.ts`](./config.ts) โ€” constants, labels, comment templates, forbidden-label list +- [`github.ts`](./github.ts) โ€” Octokit wrapper, label idempotency, forbidden-label guard +- [`claude.ts`](./claude.ts) โ€” Anthropic SDK wrapper, system prompts, tool schemas +- [`pass1-triage.ts`](./pass1-triage.ts) โ€” Pass 1: triage logic, confidence threshold enforcement +- [`pr-reviewer.ts`](./pr-reviewer.ts) โ€” Phase 2a: per-PR Claude review for the digest +- [`pass2-digest.ts`](./pass2-digest.ts) โ€” Pass 2 orchestrator: assembles inputs, calls Claude, posts +- [`digest-formatter.ts`](./digest-formatter.ts) โ€” input shaping + deterministic fallback markdown +- [`discord.ts`](./discord.ts) โ€” optional Discord webhook poster + +## Cost notes + +The system prompts and tool schemas are designed so each call is small (~1โ€“3k input tokens, <500 output). Triage runs once per new outside-author issue (~3โ€“10/day in steady state); PR review runs once per non-draft open PR awaiting review. A daily run typically uses 10โ€“20 Claude calls. Hard cap is `MAX_CALLS_PER_RUN = 100`. + +Token usage is logged per call (`tokens in=X out=Y`); grep for it in workflow output to diagnose cost spikes. + +## Editing the prompts + +The triage system prompt is the highest-leverage knob. It lives in [`claude.ts`](./claude.ts) as `TRIAGE_SYSTEM_PROMPT`. Tune it when: + +- You see Claude consistently miscategorizing a kind of issue. +- A new category of out-of-scope report starts appearing (e.g., bot reports, spam). +- You want to adjust the bar between `qol-improvement` and `feature`. + +Always test prompt changes locally with `--issue ` against a few representative real issues before merging. diff --git a/scripts/claude-cron/claude.ts b/scripts/claude-cron/claude.ts new file mode 100644 index 000000000..91627c878 --- /dev/null +++ b/scripts/claude-cron/claude.ts @@ -0,0 +1,622 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { AreaLabel, Classification } from "./config"; +import { + CLAUDE_MODEL, + DIGEST_LOOKBACK_HOURS, + MAINTAINER_LOGIN, +} from "./config"; +import type { Issue, PullRequest } from "./github"; + +export type Confidence = "low" | "medium" | "high"; + +export type TriageDecision = { + classification: Classification; + confidence: Confidence; + reasoning: string; + suggested_area?: AreaLabel; + duplicate_of?: number; + clarifying_questions?: string[]; +}; + +export type PRReview = { + summary: string; + risk: "low" | "medium" | "high"; + tests_present: "yes" | "no" | "n/a"; + scope_match: "matches" | "expanded" | "no_linked_issue"; + ai_smell?: "low" | "medium" | "high"; + concerns?: string[]; +}; + +export type DigestResult = { + markdown: string; + maintainer_actions?: string[]; +}; + +export type UsageStats = { + input_tokens: number; + output_tokens: number; +}; + +export function makeAnthropic(apiKey: string): Anthropic { + return new Anthropic({ apiKey }); +} + +// --------------------------------------------------------------------------- +// Tool schemas โ€” force structured output via tool_choice +// --------------------------------------------------------------------------- + +export const TRIAGE_TOOL = { + name: "submit_triage_decision", + description: "Submit your triage decision for this GitHub issue.", + input_schema: { + type: "object" as const, + required: ["classification", "confidence", "reasoning"], + properties: { + classification: { + type: "string", + enum: [ + "bug", + "qol-improvement", + "duplicate", + "needs-info", + "feature", + "question", + "support", + "billing", + "security", + "translation", + "uncertain", + ], + description: "The category this issue falls into.", + }, + confidence: { + type: "string", + enum: ["low", "medium", "high"], + description: + "How confident you are. Auto-close categories require 'high' confidence to take action; below that, the system downgrades to 'uncertain'.", + }, + reasoning: { + type: "string", + description: + "One or two sentences explaining the classification. Will be logged for audit.", + }, + suggested_area: { + type: "string", + enum: [ + "area:client", + "area:core", + "area:server", + "area:map-generator", + "area:other", + ], + description: + "For 'bug' or 'qol-improvement' only. Best-guess area of the codebase.", + }, + duplicate_of: { + type: "integer", + description: + "For 'duplicate' classification only. The issue number this duplicates. Must be drawn from the provided recent-issues list and must be currently open.", + }, + clarifying_questions: { + type: "array", + items: { type: "string" }, + description: + "For 'needs-info' classification only. Specific questions to ask the reporter โ€” not generic 'please provide more info'. Each item is one question.", + }, + }, + }, +} as const; + +export const PR_REVIEW_TOOL = { + name: "submit_pr_review", + description: "Submit a quick-review summary of a GitHub pull request.", + input_schema: { + type: "object" as const, + required: ["summary", "risk", "tests_present", "scope_match"], + properties: { + summary: { + type: "string", + description: + "One sentence (max ~80 chars) describing what this PR does.", + }, + risk: { + type: "string", + enum: ["low", "medium", "high"], + description: + "Risk assessment based on what code is touched and how. src/core changes are higher risk; UI-only changes are lower.", + }, + tests_present: { + type: "string", + enum: ["yes", "no", "n/a"], + description: + "For PRs touching src/core, tests are required. 'n/a' if no src/core changes.", + }, + scope_match: { + type: "string", + enum: ["matches", "expanded", "no_linked_issue"], + description: + "Does the PR match the scope of its linked issue? 'expanded' if it does extra things beyond what the issue asked for.", + }, + ai_smell: { + type: "string", + enum: ["low", "medium", "high"], + description: + "Likelihood this is unsupervised LLM output. Look for: invented APIs, inconsistent style, over-commented obvious lines, defensive code around impossible cases.", + }, + concerns: { + type: "array", + items: { type: "string" }, + description: + "Specific things the maintainer should look at first. Empty if none.", + }, + }, + }, +} as const; + +export const DIGEST_TOOL = { + name: "submit_digest", + description: "Submit the daily triage digest for the maintainer.", + input_schema: { + type: "object" as const, + required: ["markdown"], + properties: { + markdown: { + type: "string", + description: + "The full digest as GitHub-flavored markdown. Follow the format specified in the system prompt. Omit empty sections.", + }, + maintainer_actions: { + type: "array", + items: { type: "string" }, + description: + "Concrete suggested actions for the maintainer this morning (e.g., 'Milestone #123 to backlog').", + }, + }, + }, +} as const; + +// --------------------------------------------------------------------------- +// System prompts +// --------------------------------------------------------------------------- + +export const TRIAGE_SYSTEM_PROMPT = `You are the triage assistant for OpenFront.io, a browser-based real-time strategy game written in TypeScript. The codebase has four main components: + +- \`src/client/\` โ€” rendering (WebGL), UI (Lit web components + Tailwind), WebSocket comms. +- \`src/core/\` โ€” deterministic game simulation (no external deps, runs in a Web Worker). +- \`src/server/\` โ€” Node.js/Express/ws coordinator. +- \`map-generator/\` โ€” Go program that generates map binaries. + +Your job is to classify an incoming issue into exactly one category and report your confidence. The system applies actions automatically based on your decision. Be conservative โ€” when uncertain, return \`uncertain\`. False-closing a real bug is worse than leaving an uncertain issue open for the maintainer to look at. + +## Classification taxonomy + +**bug** โ€” Reproducible defect in existing functionality. Crashes, wrong rendering, server errors, broken game logic. The game does X but should do Y. + +**qol-improvement** โ€” Small-scope work reasonably completable by one contributor in under a week. Includes hotkey additions, confirmation dialogs, UI tweaks, minor behavior changes, **small new features that don't require new game systems**, sound/visual polish, and performance improvements to existing code paths. The line is *scope/effort*, not "is it new functionality." + +**duplicate** โ€” Same issue (semantically, not lexically) exists in the recent open-issues context. You MUST cite the duplicate issue number. Do not suggest duplicates of closed issues โ€” they're parked or rejected. + +**needs-info** โ€” Plausibly a bug or QoL, but lacks specifics needed to act: no repro steps, no version (\`gitCommit\`), no expected-vs-actual. Provide *specific* clarifying questions, not generic "please provide more info." + +**feature** โ€” Substantial new system. Multi-week scope. Touches multiple subsystems. Examples: new unit types, new game modes, achievement systems, alternate currencies, cosmetic systems, new map *types* (not "add one more map"), new alliance mechanics, anything needing new art assets or new networked state. + +**question** โ€” Asking about the codebase, contributing, building from source, architecture. + +**support** โ€” Player asking for help playing: can't connect, account issues, reporting players, troubleshooting their local environment. + +**billing** โ€” Any mention of money: purchases, subscriptions, refunds, premium, payment failures. Even if there's also a technical defect, classify as billing โ€” support has billing access. + +**security** โ€” Vulnerability reports, exploit disclosures, anything that shouldn't be public. The system will NOT auto-close these or post a comment โ€” it just routes to \`claude-uncertain\` so the maintainer can handle them discreetly (closing wouldn't make the issue private anyway). + +**translation** โ€” Localization issues, language errors, new-language requests. + +**uncertain** โ€” You can't confidently place this in any other category. Default to this. + +## Tie-breaking + +- Bug + billing โ†’ **billing.** +- Bug + support: specific reproducible defect โ†’ **bug**, vague "doesn't work for me" โ†’ **support**, doubt โ†’ **bug**. +- Feature vs. QoL: new assets/systems โ†’ **feature**, tweak to existing flow โ†’ **qol-improvement**, doubt โ†’ **uncertain** (do NOT default to feature; that auto-closes). + +## Examples + +- "Game crashes when I right-click in spectator mode after 5 minutes" โ†’ **bug** (reproducible defect, specific trigger), area:client, confidence: high. +- "Could you add a hotkey to toggle the chat?" โ†’ **qol-improvement** (small UI tweak), area:client, confidence: high. +- "Game is buggy" with no body โ†’ **needs-info**, clarifying_questions: ["Which specific bug?", "Steps to reproduce?", "Which browser and OS?"], confidence: high. +- "Add an espionage mechanic with hidden units" โ†’ **feature** (new game system), confidence: high. +- "Refund my purchase, it doesn't work" โ†’ **billing**, confidence: high. +- "How do I run the dev server?" โ†’ **question**, confidence: high. +- "Can't connect to any game" โ†’ **support** unless concrete repro steps are given. +- "I think there's an XSS in the chat" โ†’ **security**, confidence: high. +- Issue text identical in shape to recent open #1234 โ†’ **duplicate**, duplicate_of: 1234. +- Something ambiguous, e.g. an issue with rich but unclear repro that could be a bug or could be PEBKAC โ†’ **uncertain** (keep it open for the maintainer). + +## Confidence + +Auto-close categories (feature, question, support, billing, translation) require \`confidence: high\` for the system to act on them. Anything lower will be downgraded to \`uncertain\` and stay open. So: + +- For an auto-close category, use \`high\` only if you'd defend the decision to the maintainer. +- For label-only categories (bug, qol-improvement, duplicate, needs-info, security), any confidence is fine; the consequences are small and reversible. + +## Hard rules + +- NEVER suggest setting a milestone. Milestones are maintainer-only. +- NEVER suggest \`approved\` or \`not-approved\` labels. Layer A manages those automatically based on milestone state. +- NEVER suggest assigning anyone. +- NEVER reopen closed issues; never reference them as duplicates. +- Being skeptical and returning \`uncertain\` is better than miscategorizing. + +Call the \`submit_triage_decision\` tool exactly once.`; + +export const PR_REVIEW_SYSTEM_PROMPT = `You are a code-review assistant doing a *fast triage pass* on a pull request for OpenFront.io, a browser-based RTS written in TypeScript. + +Your output is one row in the maintainer's daily digest. It should: + +- Summarize the PR in one sentence (max ~80 chars). +- Assess risk based on what code is touched: \`src/core\` (deterministic simulation) is highest risk, \`src/server\` medium, \`src/client\` lower, docs/tests/config typically low. +- Report whether tests are present. **Tests are required for any change in \`src/core\`.** Use 'n/a' only when no \`src/core\` files are touched. +- Compare against the linked issue's scope. 'expanded' means the PR does noticeably more than the issue asked for (often a red flag). 'no_linked_issue' if the PR doesn't reference an issue. +- Optionally flag AI smells: invented APIs, inconsistent style, overly defensive code for impossible cases, dense comments on obvious lines. +- Note specific concerns the maintainer should look at first, if any. + +Be terse. The output goes into a maintainer digest scanned at a glance. Call the \`submit_pr_review\` tool exactly once.`; + +export const DIGEST_SYSTEM_PROMPT = `You are writing a daily triage digest for ${MAINTAINER_LOGIN} (Evan), the maintainer of OpenFront.io. + +The digest is a brief morning briefing. It tells Evan what needs his attention today. + +## Required structure (markdown) + +\`\`\`markdown +# Daily Triage โ€” YYYY-MM-DD + +## ๐ŸŸข Newly classified by Claude (N) +- #123 โ€” short summary ยท suggested action +- ... + +## ๐ŸŸก Needs your judgment (M) +- #125 โ€” Claude flagged 'claude-uncertain' because [reason] + +## ๐Ÿ”ด Auto-closing in <24h (K) +- #126 โ€” \`stale\`, milestone or let close + +## ๐ŸŸ  Awaiting your milestone (Q) +- #45 [bug, area:client] โ€” 21d โ€” Crash on right-click +- #76 [qol-improvement] โ€” 12d โ€” Add chat hotkey +- ... + +## ๐Ÿ”ต PRs needing review (P) +- #220 by @alice โ€” fixes alliance UI bug ยท src/client only ยท tests โœ“ +- ... + +## Patterns +[Only if you noticed something specific. Otherwise omit this section entirely.] + +## Recommended actions +- Milestone #123 as backlog +- ... +\`\`\` + +## Rules + +- Target length: 200โ€“600 words. Should fit in one or two screenfuls. +- Omit any section that has no entries. Do not write "No items" rows. +- Direct address: "Evan, today you have..." is fine if it's natural; don't force it. +- For each PR, surface the pre-computed quick review: risk, tests, scope match. Don't speculate beyond what's given. +- **Awaiting your milestone**: list ALL items from the input verbatim. This is the full backlog of un-milestoned issues. Don't truncate or sample โ€” Evan wants to see every one. The bracketed tags (e.g. \`[bug, area:client]\`) and age (e.g. \`21d\`) come straight from the input; pass them through unchanged. +- **Patterns**: only mention if the input genuinely shows a cluster (e.g., 4+ new issues mentioning the same map or feature). Do NOT invent patterns. Omit the section if nothing notable. +- **Recommended actions**: 2โ€“5 concrete items maximum. Each is something Evan can do today in <5 minutes. +- Use the issue/PR data exactly as provided. Don't hallucinate numbers, authors, or labels. + +Call the \`submit_digest\` tool exactly once with the full markdown.`; + +// --------------------------------------------------------------------------- +// Input formatters +// --------------------------------------------------------------------------- + +const MAX_BODY_CHARS = 4000; +const MAX_DIFF_CHARS = 20000; +const RECENT_ISSUE_SUMMARY_CHARS = 100; + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max) + `\n\nโ€ฆ[truncated; ${s.length - max} chars omitted]`; +} + +export function formatIssueForTriage( + issue: Issue, + recentOpenIssues: Issue[], +): string { + const body = + issue.body.trim() === "" + ? "_(empty body)_" + : truncate(issue.body, MAX_BODY_CHARS); + const labels = issue.labels.length > 0 ? issue.labels.join(", ") : "(none)"; + const recent = recentOpenIssues + .filter((r) => r.number !== issue.number) + .map((r) => { + const snippet = r.body + .replace(/\s+/g, " ") + .slice(0, RECENT_ISSUE_SUMMARY_CHARS) + .trim(); + return `- #${r.number}: ${r.title} โ€” ${snippet}`; + }) + .join("\n"); + + return `## Issue to classify + +- Number: #${issue.number} +- Author: @${issue.user?.login ?? "unknown"} +- Author association: ${issue.author_association} +- Created: ${issue.created_at} +- Existing labels: ${labels} + +### Title +${issue.title} + +### Body +${body} + +--- + +## Recent open issues (for duplicate detection only, NOT for classifying this issue) + +${recent || "_(no other open issues)_"} + +--- + +Classify the issue above. Call \`submit_triage_decision\` exactly once.`; +} + +export function formatPRForReview( + pr: PullRequest, + diff: string, + linkedIssue: Issue | null, +): string { + const description = + pr.body.trim() === "" + ? "_(empty description)_" + : truncate(pr.body, MAX_BODY_CHARS); + const linkedIssueBlock = linkedIssue + ? `## Linked issue #${linkedIssue.number}: ${linkedIssue.title} + +${truncate(linkedIssue.body || "_(empty body)_", 1500)}` + : "_(no linked issue detected)_"; + + return `## Pull request to review + +- Number: #${pr.number} +- Author: @${pr.user?.login ?? "unknown"} (association: ${pr.author_association}) +- Title: ${pr.title} +- Created: ${pr.created_at} + +### Description +${description} + +${linkedIssueBlock} + +## Diff + +\`\`\`diff +${truncate(diff, MAX_DIFF_CHARS)} +\`\`\` + +Review the PR above. Call \`submit_pr_review\` exactly once.`; +} + +export type DigestInputs = { + today: string; + triageReadyInWindow: { + number: number; + title: string; + author: string; + area: string | null; + }[]; + needsMaintainerTriage: { + number: number; + title: string; + reasoning?: string; + }[]; + needsInfoCount: number; + staleClosingSoon: { number: number; title: string }[]; + awaitingMilestone: { + number: number; + title: string; + ageDays: number; + primaryLabel: string | null; + area: string | null; + }[]; + prReviews: { + pr: { number: number; title: string; author: string }; + review: PRReview; + }[]; + triageSummary: { processed: number; closed: number; kept: number }; +}; + +export function formatDigestInputs(inputs: DigestInputs): string { + const triageReady = + inputs.triageReadyInWindow.length === 0 + ? "_(none)_" + : inputs.triageReadyInWindow + .map( + (i) => + `- #${i.number} by @${i.author}${i.area ? ` [${i.area}]` : ""}: ${i.title}`, + ) + .join("\n"); + + const needsTriage = + inputs.needsMaintainerTriage.length === 0 + ? "_(none)_" + : inputs.needsMaintainerTriage + .map( + (i) => + `- #${i.number}: ${i.title}${i.reasoning ? ` โ€” Claude's reasoning: ${i.reasoning}` : ""}`, + ) + .join("\n"); + + const stale = + inputs.staleClosingSoon.length === 0 + ? "_(none)_" + : inputs.staleClosingSoon + .map((i) => `- #${i.number}: ${i.title}`) + .join("\n"); + + const awaitingMilestone = + inputs.awaitingMilestone.length === 0 + ? "_(none)_" + : inputs.awaitingMilestone + .map((i) => { + const tags = [i.primaryLabel, i.area].filter(Boolean).join(", "); + const tagStr = tags ? ` [${tags}]` : ""; + return `- #${i.number}${tagStr} โ€” ${i.ageDays}d โ€” ${i.title}`; + }) + .join("\n"); + + const prs = + inputs.prReviews.length === 0 + ? "_(none)_" + : inputs.prReviews + .map(({ pr, review }) => { + const concerns = + review.concerns && review.concerns.length > 0 + ? ` ยท concerns: ${review.concerns.join("; ")}` + : ""; + const ai = + review.ai_smell && review.ai_smell !== "low" + ? ` ยท ai-smell: ${review.ai_smell}` + : ""; + return `- #${pr.number} by @${pr.author}: ${pr.title} + summary: ${review.summary} + risk: ${review.risk} ยท tests: ${review.tests_present} ยท scope: ${review.scope_match}${ai}${concerns}`; + }) + .join("\n"); + + return `Today: ${inputs.today} + +## Triage pass summary (last 24h) +- Processed: ${inputs.triageSummary.processed} +- Auto-closed: ${inputs.triageSummary.closed} +- Kept open: ${inputs.triageSummary.kept} + +## Newly classified by Claude (last ${DIGEST_LOOKBACK_HOURS}h, label: claude-approved) +${triageReady} + +## Needs maintainer triage (label: claude-uncertain) +${needsTriage} + +## Needs info (label: needs-info) +Count: ${inputs.needsInfoCount} + +## Stale, closing soon (label: stale; Layer A will close in <24h unless milestoned) +${stale} + +## Awaiting your milestone (label: not-approved; full open backlog, sorted oldest first) +${awaitingMilestone} + +## Open PRs awaiting maintainer review (any author) +${prs} + +Compose the digest now. Follow the format in the system prompt. Omit empty sections.`; +} + +// --------------------------------------------------------------------------- +// API call helpers +// --------------------------------------------------------------------------- + +type ToolUseBlock = { + type: "tool_use"; + name: string; + input: unknown; +}; + +function findToolUse( + content: Anthropic.Messages.ContentBlock[], + expectedName: string, +): ToolUseBlock { + for (const block of content) { + if (block.type === "tool_use") { + const tu = block as unknown as ToolUseBlock; + if (tu.name !== expectedName) { + throw new Error( + `Claude returned tool_use for unexpected tool "${tu.name}" (expected "${expectedName}")`, + ); + } + return tu; + } + } + throw new Error( + `Claude response had no tool_use block (expected "${expectedName}")`, + ); +} + +export type CallResult = { value: T; usage: UsageStats }; + +async function callClaudeWithTool( + anthropic: Anthropic, + args: { + system: string; + userContent: string; + tool: { + name: string; + description: string; + input_schema: { type: "object" } & Record; + }; + maxTokens: number; + }, +): Promise> { + 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> { + return callClaudeWithTool(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> { + return callClaudeWithTool(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> { + return callClaudeWithTool(anthropic, { + system: DIGEST_SYSTEM_PROMPT, + userContent: formatDigestInputs(inputs), + tool: DIGEST_TOOL, + maxTokens: 2000, + }); +} diff --git a/scripts/claude-cron/config.ts b/scripts/claude-cron/config.ts new file mode 100644 index 000000000..ef09b518d --- /dev/null +++ b/scripts/claude-cron/config.ts @@ -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 = 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 = { + [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 = { + [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, +}; diff --git a/scripts/claude-cron/digest-formatter.ts b/scripts/claude-cron/digest-formatter.ts new file mode 100644 index 000000000..216422d24 --- /dev/null +++ b/scripts/claude-cron/digest-formatter.ts @@ -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 { + 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)"; +} diff --git a/scripts/claude-cron/discord.ts b/scripts/claude-cron/discord.ts new file mode 100644 index 000000000..b134ed5d3 --- /dev/null +++ b/scripts/claude-cron/discord.ts @@ -0,0 +1,20 @@ +import { truncateForDiscord } from "./digest-formatter"; + +export async function postToDiscord( + webhookUrl: string, + markdown: string, +): Promise { + 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(() => ""); + throw new Error( + `Discord webhook failed: ${response.status} ${response.statusText} โ€” ${text}`, + ); + } +} diff --git a/scripts/claude-cron/github.ts b/scripts/claude-cron/github.ts new file mode 100644 index 000000000..fd382d00c --- /dev/null +++ b/scripts/claude-cron/github.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await octokit.rest.issues.addLabels({ + ...REPO, + issue_number: issueNumber, + labels: [label], + }); +} + +export async function removeLabel( + octokit: Octokit, + issueNumber: number, + label: string, +): Promise { + try { + await octokit.rest.issues.removeLabel({ + ...REPO, + issue_number: issueNumber, + name: label, + }); + } catch (err) { + if (!isStatus(err, 404)) throw err; + } +} + +export async function postComment( + octokit: Octokit, + issueNumber: number, + body: string, +): Promise { + await octokit.rest.issues.createComment({ + ...REPO, + issue_number: issueNumber, + body, + }); +} + +export async function closeIssue( + octokit: Octokit, + issueNumber: number, + reason: "not_planned" | "completed", +): Promise { + await octokit.rest.issues.update({ + ...REPO, + issue_number: issueNumber, + state: "closed", + state_reason: reason, + }); +} + +export async function applyActions( + octokit: Octokit, + issueNumber: number, + actions: Action[], +): Promise { + for (const action of actions) { + 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 }; diff --git a/scripts/claude-cron/index.ts b/scripts/claude-cron/index.ts new file mode 100644 index 000000000..3ea8288a6 --- /dev/null +++ b/scripts/claude-cron/index.ts @@ -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 { + 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 { + 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 { + 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); +}); diff --git a/scripts/claude-cron/package-lock.json b/scripts/claude-cron/package-lock.json new file mode 100644 index 000000000..8cff9fbec --- /dev/null +++ b/scripts/claude-cron/package-lock.json @@ -0,0 +1,1138 @@ +{ + "name": "openfrontio-claude-cron", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openfrontio-claude-cron", + "version": "0.0.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.40.0", + "@octokit/rest": "^21.0.2", + "tsx": "^4.21.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.40.1.tgz", + "integrity": "sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", + "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", + "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.2.2", + "@octokit/request": "^9.2.3", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", + "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", + "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", + "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^6.1.4", + "@octokit/plugin-paginate-rest": "^11.4.2", + "@octokit/plugin-request-log": "^5.3.1", + "@octokit/plugin-rest-endpoint-methods": "^13.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "license": "Apache-2.0" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/scripts/claude-cron/package.json b/scripts/claude-cron/package.json new file mode 100644 index 000000000..aeb286fb0 --- /dev/null +++ b/scripts/claude-cron/package.json @@ -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" + } +} diff --git a/scripts/claude-cron/pass1-triage.ts b/scripts/claude-cron/pass1-triage.ts new file mode 100644 index 000000000..609fcf4ea --- /dev/null +++ b/scripts/claude-cron/pass1-triage.ts @@ -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(AUTO_CLOSE_CLASSIFICATIONS); + +export type TriagePassResult = { + processed: number; + closed: number; + kept: number; + callCount: number; + recordsByNumber: Map; +}; + +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 { + 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(); + 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}`, + ); +} diff --git a/scripts/claude-cron/pass2-digest.ts b/scripts/claude-cron/pass2-digest.ts new file mode 100644 index 000000000..5b07a6ffa --- /dev/null +++ b/scripts/claude-cron/pass2-digest.ts @@ -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 { + 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 { + 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 }; diff --git a/scripts/claude-cron/pr-reviewer.ts b/scripts/claude-cron/pr-reviewer.ts new file mode 100644 index 000000000..54bb8800f --- /dev/null +++ b/scripts/claude-cron/pr-reviewer.ts @@ -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 { + const reviews = await listPullReviews(octokit, pr.number); + return isReviewPendingFromReviews(reviews, MAINTAINER_LOGIN, pr.head_sha); +} + +export async function listPRsAwaitingMaintainerReview( + octokit: Octokit, +): Promise { + 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 { + 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 }; +} diff --git a/scripts/claude-cron/tests/digest-formatter.test.ts b/scripts/claude-cron/tests/digest-formatter.test.ts new file mode 100644 index 000000000..0d952a8ef --- /dev/null +++ b/scripts/claude-cron/tests/digest-formatter.test.ts @@ -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); + }); +}); diff --git a/scripts/claude-cron/tests/github-guards.test.ts b/scripts/claude-cron/tests/github-guards.test.ts new file mode 100644 index 000000000..0b401d0e3 --- /dev/null +++ b/scripts/claude-cron/tests/github-guards.test.ts @@ -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); + }); +}); diff --git a/scripts/claude-cron/tests/pass1-triage.test.ts b/scripts/claude-cron/tests/pass1-triage.test.ts new file mode 100644 index 000000000..16721cfb3 --- /dev/null +++ b/scripts/claude-cron/tests/pass1-triage.test.ts @@ -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 { + 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); + }); +}); diff --git a/scripts/claude-cron/tests/pr-reviewer.test.ts b/scripts/claude-cron/tests/pr-reviewer.test.ts new file mode 100644 index 000000000..5864784b8 --- /dev/null +++ b/scripts/claude-cron/tests/pr-reviewer.test.ts @@ -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); + }); +}); diff --git a/scripts/claude-cron/tsconfig.json b/scripts/claude-cron/tsconfig.json new file mode 100644 index 000000000..9da0f8638 --- /dev/null +++ b/scripts/claude-cron/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": {} + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +}