diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index b2655cabae6..791769bf9b1 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -2,6 +2,26 @@ name: Bug report description: Report an issue that should be fixed labels: ["bug"] body: + # kilocode_change start + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Kilo is this about? It helps us route your report. + options: + - VS Code extension + - CLI / TUI + - Agent Manager + - JetBrains plugin + - Desktop or web app + - Documentation + - Kilo Gateway or account + - SDK + - Other / not sure + validations: + required: true + # kilocode_change end + - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 92e6c47570a..0e679c00ae7 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -18,3 +18,23 @@ body: description: What do you want to change or add? What are the benefits of implementing this? Try to be detailed so we can understand your request better :) validations: required: true + + # kilocode_change start + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Kilo is this about? It helps us route your request. + options: + - VS Code extension + - CLI / TUI + - Agent Manager + - JetBrains plugin + - Desktop or web app + - Documentation + - Kilo Gateway or account + - SDK + - Other / not sure + validations: + required: true + # kilocode_change end diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a8415564209..5c752da4e4d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,5 @@ + + ## Issue diff --git a/.github/workflows/community-label.yml b/.github/workflows/community-label.yml new file mode 100644 index 00000000000..9a0ebd32383 --- /dev/null +++ b/.github/workflows/community-label.yml @@ -0,0 +1,47 @@ +# kilocode_change - new file +name: Community label + +# Labels new PRs and issues so maintainers can tell community work from team +# work and see which part of the repo it touches. See docs/community-triage.md. +# +# pull_request_target runs with write access, even for fork PRs. That is safe +# here because the job only checks out the base branch and never runs code from +# the PR. It reads PR metadata (title, author, file names) as data. + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, edited] + issues: + types: [opened, edited] + +concurrency: + group: community-label-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + # Optional. Lets the job see private org members. See the docs. + MEMBER_TOKEN: ${{ secrets.COMMUNITY_TRIAGE_MEMBER_TOKEN }} + MAINTAINERS: ${{ vars.COMMUNITY_MAINTAINERS }} + run: bun run script/kilocode/community-triage.ts label diff --git a/.github/workflows/community-sweep.yml b/.github/workflows/community-sweep.yml new file mode 100644 index 00000000000..e0cad458f64 --- /dev/null +++ b/.github/workflows/community-sweep.yml @@ -0,0 +1,61 @@ +# kilocode_change - new file +name: Community sweep + +# Twice a week, the evening before the maintainer triage slots (Friday and +# Tuesday). Works out whose turn it is on each community PR, keeps the status +# labels in sync, closes PRs that got no reply for 14 days and posts the queue. +# See docs/community-triage.md. + +on: + schedule: + - cron: "0 17 * * 1,4" # Monday and Thursday, 17:00 UTC + workflow_dispatch: + inputs: + close: + description: "Close PRs with no reply for 14 days. Off means log only." + type: boolean + default: false + +concurrency: + group: community-sweep + cancel-in-progress: false + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + sweep: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + SLACK_WEBHOOK: ${{ secrets.COMMUNITY_TRIAGE_SLACK_WEBHOOK }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Sweep + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + MEMBER_TOKEN: ${{ secrets.COMMUNITY_TRIAGE_MEMBER_TOKEN }} + MAINTAINERS: ${{ vars.COMMUNITY_MAINTAINERS }} + # Closing stays off until the repo variable is set to "true". + CLOSE_ENABLED: ${{ github.event_name == 'workflow_dispatch' && format('{0}', inputs.close) || vars.COMMUNITY_AUTO_CLOSE_ENABLED }} + SLACK_PAYLOAD: ${{ runner.temp }}/slack-payload.json + run: bun run script/kilocode/community-triage.ts sweep + + - name: Notify Slack + if: env.SLACK_WEBHOOK != '' + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ env.SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload-file-path: ${{ runner.temp }}/slack-payload.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8400e2ee3b..b473c33a092 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,8 @@ There are lots of ways to contribute to the project: - **Feature Requests:** Suggest new features or improvements - **Community Support:** Help other users in the community +Community pull requests are triaged twice a week. See [Community triage](docs/community-triage.md) for what to expect and how long we wait for replies. + The Kilo Community is [on Discord](https://kilo.ai/discord). ## Prerequisites diff --git a/docs/community-triage.md b/docs/community-triage.md new file mode 100644 index 00000000000..e0b1aaed3dc --- /dev/null +++ b/docs/community-triage.md @@ -0,0 +1,153 @@ +# Community triage + +How we handle pull requests and issues from people outside the Kilo team. + +The goal is simple. Maintainers see the community work that matters, and contributors know what happens next. + +Background: [Slack discussion](https://anaconda.slack.com/archives/C0BJ2GQ40HM/p1791377022461359). + +## Overview + +```mermaid +flowchart LR + A[PR or issue opened] --> B[community-label.yml] + B --> C[Labels: community, needs-triage, area:*] + C --> D[community-sweep.yml
Mon + Thu 17:00 UTC] + D --> E[Status labels synced] + D --> F[Queue posted to job summary and Slack] + F --> G[Tuesday: Bruno] + F --> H[Friday: Andrea] + G --> I[Review, label, comment] + H --> I + I --> D +``` + +Two workflows do the work. Both use `script/kilocode/community-triage.ts`. + +| Workflow | Runs | Does | +|---|---|---| +| `community-label.yml` | When a PR or issue opens or is edited | Adds `community`, `needs-triage` and `area:*` labels | +| `community-sweep.yml` | Monday and Thursday, 17:00 UTC | Syncs status labels, closes stale PRs, posts the queue | + +The sweep runs the evening before each maintainer slot. Andrea starts Friday and Bruno starts Tuesday with a fresh queue. + +## Who is a maintainer + +An author or commenter is a maintainer when one of these is true: + +- GitHub shows them as `OWNER`, `MEMBER` or `COLLABORATOR`. +- Their login is in the repo variable `COMMUNITY_MAINTAINERS` (comma separated). +- The optional secret `COMMUNITY_TRIAGE_MEMBER_TOKEN` (`read:org`) shows they are in the org. + +The default token cannot see private org members. They look like normal contributors. Set the variable or the secret so their PRs are not labeled `community`. + +Bots are ignored. + +## Labels + +Set by the workflows: + +| Label | Meaning | +|---|---| +| `community` | Author is not a maintainer | +| `area:vscode`, `area:cli`, `area:agent-manager`, `area:jetbrains`, `area:desktop`, `area:docs`, `area:gateway`, `area:sdk`, `area:ui`, `area:i18n` | Part of the repo. Comes from the title scope, the changed paths, or the issue "Component" dropdown | +| `needs-triage` | No maintainer has reacted yet | +| `needs-review` | The contributor replied or pushed. A maintainer should look again | +| `awaiting-contributor` | A maintainer reacted last. The 14 day clock runs | +| `ci-failing` | Checks on the last commit failed | +| `needs-reimplementation` | `high-value` PR with no reply for 14 days. The team takes it over | + +Set by hand: + +| Label | Meaning | +|---|---| +| `high-value` | We want this change. It is never auto-closed | +| `low-value` | Triaged. Not a priority | +| `keep-open` | Never auto-close this PR | + +The sweep owns the status labels. It adds and removes them on its own. Do not manage them by hand. + +`area:*` labels are only added, never removed. Fix a wrong one by hand. + +## Whose turn is it + +The sweep reads the comments, reviews, review replies and commits of each open community PR. Drafts are skipped. + +```mermaid +stateDiagram-v2 + [*] --> triage: opened, no maintainer reaction + triage --> awaiting: maintainer comments or requests changes + triage --> approved: maintainer approves + awaiting --> review: contributor comments or pushes + review --> awaiting: maintainer comments or requests changes + review --> approved: maintainer approves + approved --> review: contributor pushes again + awaiting --> closed: 14 days, not high-value + awaiting --> adopted: 14 days, high-value + adopted --> [*]: team re-implements or fixes it + closed --> [*] +``` + +| State | Label | Rule | +|---|---|---| +| triage | `needs-triage` | No maintainer reaction yet, and no `high-value` or `low-value` label | +| review | `needs-review` | The contributor acted after the last maintainer reaction, or a `high-value` or `low-value` label exists | +| approved | none | The last maintainer reaction is an approval | +| awaiting | `awaiting-contributor` | The last maintainer reaction asked for something and the contributor has not answered | + +The 14 day clock starts at the time of the last maintainer reaction. + +## The 14 day rule + +When a PR has been `awaiting-contributor` for 14 days: + +| PR | Result | +|---|---| +| Has `high-value` | Gets `needs-reimplementation` and shows in the digest under "Adopt". Add it to the standup doc and pick an owner | +| Has `keep-open` | Nothing happens | +| Anything else | Closed with a comment | + +Closing is **off by default**. Until it is on, the sweep only logs what it would close. To turn it on, set the repo variable `COMMUNITY_AUTO_CLOSE_ENABLED` to `true`. To try it by hand, run the sweep from the Actions tab and tick "close". + +When a PR first gets `awaiting-contributor`, the sweep also posts one comment that gives the due date. That comment is also off until closing is on. The sweep posts at most 20 comments per run (`MAX_COMMENTS`). + +`kilo-auto-close.yml` is separate. It still closes any PR with no activity for 30 days. + +## The maintainer routine + +Open the latest "Community sweep" run. The job summary has the queue. A copy goes to Slack when `COMMUNITY_TRIAGE_SLACK_WEBHOOK` is set. + +1. **Adopt**: pick an owner and add the PR to the standup doc. +2. **Needs triage**: decide. Add `high-value` or `low-value`, or comment on it. Not sure? Put it in the standup doc. +3. **Needs review**: the contributor replied. Review again. +4. **Approved**: merge. +5. **Waiting for contributor**: nothing to do. + +Friday and Tuesday are three to four days apart. This gives contributors time to reply between reviews. + +Useful searches: + +- All community PRs: `is:pr is:open label:community` +- Needs a maintainer: `is:pr is:open label:needs-triage,needs-review` +- One area: `is:pr is:open label:community label:area:vscode` + +## Issues + +New issues get the `community` label, `needs-triage` and an `area:*` label. The area comes from the required "Component" dropdown in the bug and feature templates. The sweep only handles PRs. + +## Setup + +| Name | Kind | Needed | Purpose | +|---|---|---|---| +| `COMMUNITY_AUTO_CLOSE_ENABLED` | Variable | No | `true` turns on closing and comments | +| `COMMUNITY_MAINTAINERS` | Variable | No | Logins treated as maintainers | +| `COMMUNITY_TRIAGE_MEMBER_TOKEN` | Secret | No | Token with `read:org`. Finds private org members | +| `COMMUNITY_TRIAGE_SLACK_WEBHOOK` | Secret | No | Posts the queue to Slack | + +Labels are created by the workflows. Run "Community sweep" once by hand after merge to create them and label the open PRs. + +## Safety + +- `community-label.yml` uses `pull_request_target`. It checks out the base branch only. It never runs PR code. +- The sweep never closes drafts, `high-value` PRs or `keep-open` PRs. +- Closed PRs can be reopened by the contributor. diff --git a/script/check-workflows.ts b/script/check-workflows.ts index 9487c25ece0..f4435939cf5 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -36,6 +36,8 @@ const active = new Set([ "check-org-member.yml", "codeql-kotlin.yml", "codeql.yml", + "community-label.yml", + "community-sweep.yml", "containers.yml", "dependabot-auto-merge.yml", "docs-build.yml", diff --git a/script/kilocode/community-triage.test.ts b/script/kilocode/community-triage.test.ts new file mode 100644 index 00000000000..077dec854d5 --- /dev/null +++ b/script/kilocode/community-triage.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import { areas, classify, component, decide, digest, DAY, label, type Event, type Row } from "./community-triage" + +const now = Date.UTC(2026, 9, 8) +const ago = (days: number) => now - days * DAY + +describe("areas", () => { + test("reads the title scope", () => { + expect(areas("fix(vscode): crash on start", [])).toEqual(["area:vscode"]) + expect(areas("feat(tui)!: new thing", [])).toEqual(["area:cli"]) + }) + + test("reads changed paths and prefers agent-manager over vscode", () => { + const files = ["packages/kilo-vscode/src/agent-manager/a.ts", "packages/kilo-vscode/src/x.ts", "README.md"] + expect(areas("fix: thing", files)).toEqual(["area:agent-manager", "area:vscode"]) + }) + + test("returns nothing for unknown scope and paths", () => { + expect(areas("chore: misc", ["script/x.ts"])).toEqual([]) + }) +}) + +describe("component", () => { + test("maps the issue form answer", () => { + expect(component("### Description\n\nhi\n\n### Component\n\nJetBrains plugin\n\n### Other\n\nx")).toBe( + "area:jetbrains", + ) + }) + + test("ignores other answers", () => { + expect(component("### Component\n\nOther / not sure")).toBeUndefined() + expect(component("no form")).toBeUndefined() + }) +}) + +describe("classify", () => { + const maintainer = (days: number, approve = false): Event => ({ who: "maintainer", at: ago(days), approve }) + const author = (days: number): Event => ({ who: "author", at: ago(days) }) + + test("needs triage without maintainer reaction", () => { + expect(classify([author(3)], [], now)).toEqual({ state: "triage" }) + }) + + test("a triage label counts as triaged", () => { + expect(classify([author(3)], [label.high], now)).toEqual({ state: "review" }) + }) + + test("waits for the contributor after a maintainer reaction", () => { + expect(classify([author(5), maintainer(3)], [], now)).toEqual({ state: "awaiting", since: ago(3), days: 3 }) + }) + + test("contributor reply moves the turn back", () => { + expect(classify([maintainer(5), author(2)], [], now)).toEqual({ state: "review" }) + }) + + test("approval is ready to merge until the author pushes again", () => { + expect(classify([author(5), maintainer(3, true)], [], now)).toEqual({ state: "approved" }) + expect(classify([maintainer(3, true), author(1)], [], now)).toEqual({ state: "review" }) + }) +}) + +describe("decide", () => { + test("swaps stale status labels", () => { + const plan = decide([label.community, label.triage, label.ci], { state: "review" }, false) + expect(plan.add).toEqual([label.review]) + expect(plan.remove.sort()).toEqual([label.ci, label.triage].sort()) + }) + + test("never touches hand-set labels", () => { + const plan = decide([label.high, label.keep, label.adopt], { state: "approved" }, false) + expect(plan).toEqual({ add: [], remove: [] }) + }) +}) + +describe("digest", () => { + const row = (num: number, state: Row["state"], more: Partial = {}): Row => ({ + number: num, + title: `PR ${num}`, + url: `https://github.com/o/r/pull/${num}`, + author: "dev", + created: ago(4), + state, + failing: false, + adopt: false, + closed: false, + ...more, + }) + + test("groups rows and skips empty sections", () => { + const text = digest( + [row(1, { state: "triage" }), row(2, { state: "awaiting", since: ago(9), days: 9 })], + "md", + "o/r", + now, + ) + expect(text).toContain("2 open") + expect(text).toContain("**Needs triage (1)**") + expect(text).toContain("[#1 PR 1](https://github.com/o/r/pull/1)") + expect(text).toContain("9d of 14") + expect(text).not.toContain("Approved") + }) + + test("lists adopt candidates and closed PRs", () => { + const text = digest( + [ + row(1, { state: "awaiting", since: ago(20), days: 20 }, { adopt: true }), + row(2, { state: "awaiting", since: ago(30), days: 30 }, { closed: true }), + ], + "slack", + "o/r", + now, + ) + expect(text).toContain("*Adopt: high-value, no reply in 14 days (1)*") + expect(text).toContain("") + expect(text).toContain("*Closed in this run (1)*") + expect(text).toContain("1 open") + }) + + test("caps long lists", () => { + const rows = Array.from({ length: 20 }, (_, i) => row(i + 1, { state: "triage" })) + expect(digest(rows, "md", "o/r", now)).toContain("...and 5 more") + }) +}) diff --git a/script/kilocode/community-triage.ts b/script/kilocode/community-triage.ts new file mode 100644 index 00000000000..86b388d4221 --- /dev/null +++ b/script/kilocode/community-triage.ts @@ -0,0 +1,455 @@ +// Triage automation for community (non-maintainer) pull requests and issues. +// +// label Runs on PR/issue events (community-label.yml). Adds `community`, +// `needs-triage` and `area:*` labels. +// sweep Runs twice a week (community-sweep.yml). Derives each community PR's +// state, syncs status labels, closes stale ones and writes the digest. +// +// Flow and rules: docs/community-triage.md + +export const DAY = 24 * 60 * 60 * 1000 +export const WAIT_DAYS = 14 +const LIMIT = 15 + +export const label = { + community: "community", + triage: "needs-triage", + review: "needs-review", + awaiting: "awaiting-contributor", + ci: "ci-failing", + high: "high-value", + low: "low-value", + adopt: "needs-reimplementation", + keep: "keep-open", +} + +// Labels the sweep owns. It adds and removes these on its own. +const managed = [label.triage, label.review, label.awaiting, label.ci] + +const labels = [ + { name: label.community, color: "0e8a16", description: "Opened by someone outside the Kilo team" }, + { name: label.triage, color: "fbca04", description: "Community PR no maintainer has looked at yet" }, + { name: label.review, color: "1d76db", description: "Community PR waiting for a maintainer" }, + { name: label.awaiting, color: "d4c5f9", description: "Waiting for the contributor to reply" }, + { name: label.ci, color: "b60205", description: "Community PR with failing checks" }, + { name: label.high, color: "5319e7", description: "Maintainers want this change. Set by hand." }, + { name: label.low, color: "cfd3d7", description: "Triaged and not a priority. Set by hand." }, + { + name: label.adopt, + color: "e99695", + description: "High-value PR with no contributor reply. The team takes it over.", + }, + { name: label.keep, color: "bfdadc", description: "Never auto-close this PR. Set by hand." }, + { name: "area:vscode", color: "ededed", description: "VS Code extension" }, + { name: "area:cli", color: "ededed", description: "CLI, TUI and core engine" }, + { name: "area:agent-manager", color: "ededed", description: "Agent Manager" }, + { name: "area:jetbrains", color: "ededed", description: "JetBrains plugin" }, + { name: "area:desktop", color: "ededed", description: "Desktop and web app" }, + { name: "area:docs", color: "ededed", description: "Documentation" }, + { name: "area:gateway", color: "ededed", description: "Kilo Gateway and auth" }, + { name: "area:sdk", color: "ededed", description: "SDK" }, + { name: "area:ui", color: "ededed", description: "Shared UI components" }, + { name: "area:i18n", color: "ededed", description: "Translations" }, +] + +// Title scope, as in `fix(vscode): ...` +const scopes: Record = { + vscode: "area:vscode", + cli: "area:cli", + opencode: "area:cli", + tui: "area:cli", + core: "area:cli", + "agent-manager": "area:agent-manager", + jetbrains: "area:jetbrains", + desktop: "area:desktop", + app: "area:desktop", + docs: "area:docs", + "kilo-docs": "area:docs", + gateway: "area:gateway", + sdk: "area:sdk", + ui: "area:ui", + i18n: "area:i18n", +} + +// Changed file path prefix. The first matching rule for a file wins. +const paths: [string, string][] = [ + ["packages/kilo-vscode/src/agent-manager/", "area:agent-manager"], + ["packages/kilo-vscode/webview-ui/agent-manager/", "area:agent-manager"], + ["packages/kilo-vscode/", "area:vscode"], + ["packages/opencode/", "area:cli"], + ["packages/kilo-jetbrains/", "area:jetbrains"], + ["packages/kilo-docs/", "area:docs"], + ["packages/kilo-gateway/", "area:gateway"], + ["packages/sdk/", "area:sdk"], + ["packages/kilo-ui/", "area:ui"], + ["packages/kilo-i18n/", "area:i18n"], + ["packages/app/", "area:desktop"], + ["packages/desktop/", "area:desktop"], +] + +// Options of the "Component" dropdown in the issue templates. +const components: Record = { + "vs code extension": "area:vscode", + "cli / tui": "area:cli", + "agent manager": "area:agent-manager", + "jetbrains plugin": "area:jetbrains", + "desktop or web app": "area:desktop", + documentation: "area:docs", + "kilo gateway or account": "area:gateway", + sdk: "area:sdk", +} + +export function areas(title: string, files: string[]) { + const set = new Set() + const scope = title.match(/^\w+\(([^)]+)\)!?:/)?.[1] + const hit = scope ? scopes[scope.toLowerCase()] : undefined + if (hit) set.add(hit) + for (const file of files) { + const rule = paths.find(([prefix]) => file.startsWith(prefix)) + if (rule) set.add(rule[1]) + } + return [...set].sort() +} + +export function component(body: string) { + const value = body.match(/^###\s+Component\s*\n+(.+)$/im)?.[1] + return value ? components[value.trim().toLowerCase()] : undefined +} + +export type Event = { who: "author" | "maintainer"; at: number; approve?: boolean } + +export type State = + | { state: "triage" } + | { state: "review" } + | { state: "approved" } + | { state: "awaiting"; since: number; days: number } + +// Whose turn it is. The latest maintainer reaction decides, unless the author +// answered (comment, review reply or new commit) after it. +export function classify(events: Event[], tags: string[], now: number): State { + const last = (who: Event["who"]) => + events + .filter((e) => e.who === who) + .sort((a, b) => b.at - a.at) + .at(0) + const maintainer = last("maintainer") + const author = last("author") + if (!maintainer) return { state: tags.includes(label.high) || tags.includes(label.low) ? "review" : "triage" } + if (author && author.at > maintainer.at) return { state: "review" } + if (maintainer.approve) return { state: "approved" } + return { state: "awaiting", since: maintainer.at, days: Math.floor((now - maintainer.at) / DAY) } +} + +export function decide(current: string[], state: State, failing: boolean) { + const want = new Set() + if (state.state === "triage") want.add(label.triage) + if (state.state === "review") want.add(label.review) + if (state.state === "awaiting") want.add(label.awaiting) + if (failing) want.add(label.ci) + return { + add: [...want].filter((name) => !current.includes(name)), + remove: managed.filter((name) => !want.has(name) && current.includes(name)), + } +} + +export type Row = { + number: number + title: string + url: string + author: string + created: number + state: State + failing: boolean + adopt: boolean + closed: boolean +} + +export function digest(rows: Row[], mode: "md" | "slack", repo: string, now: number) { + const link = (row: Row) => { + const text = `#${row.number} ${row.title.replace(/[<>|]/g, "")}` + return mode === "md" ? `[${text}](${row.url})` : `<${row.url}|${text}>` + } + const age = (row: Row) => Math.floor((now - row.created) / DAY) + const bold = (text: string) => (mode === "md" ? `**${text}**` : `*${text}*`) + const open = rows.filter((row) => !row.closed) + const section = (title: string, list: Row[], note: (row: Row) => string) => { + if (list.length === 0) return [] + const shown = list.slice(0, LIMIT).map((row) => `- ${link(row)} (@${row.author}, ${note(row)})`) + const more = list.length > LIMIT ? [`- ...and ${list.length - LIMIT} more`] : [] + return ["", bold(`${title} (${list.length})`), ...shown, ...more] + } + const awaiting = (row: Row) => (row.state.state === "awaiting" ? row.state : undefined) + const state = (name: State["state"]) => open.filter((row) => row.state.state === name) + const oldest = (a: Row, b: Row) => a.created - b.created + + return [ + bold(`Community PR queue for ${repo}: ${open.length} open`), + ...section( + "Adopt: high-value, no reply in 14 days", + open.filter((row) => row.adopt), + (row) => `${awaiting(row)?.days}d silent`, + ), + ...section( + "Needs triage", + state("triage").sort(oldest), + (row) => `${age(row)}d old${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Contributor replied, needs review", + state("review").sort(oldest), + (row) => `${age(row)}d old${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Approved, ready to merge", + state("approved"), + (row) => `${age(row)}d old${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Waiting for contributor", + state("awaiting").sort((a, b) => (awaiting(b)?.days ?? 0) - (awaiting(a)?.days ?? 0)), + (row) => `${awaiting(row)?.days}d of ${WAIT_DAYS}${row.failing ? ", CI failing" : ""}`, + ), + ...section( + "Closed in this run", + rows.filter((row) => row.closed), + () => `no reply in ${WAIT_DAYS} days`, + ), + ].join("\n") +} + +// ---- GitHub I/O ---- + +const token = process.env.GH_TOKEN +const repo = process.env.REPO ?? "Kilo-Org/kilocode" +const [owner, name] = repo.split("/") +const bots = (login: string) => login.endsWith("[bot]") +const trusted = new Set(["OWNER", "MEMBER", "COLLABORATOR"]) +const extra = new Set( + (process.env.MAINTAINERS ?? "") + .split(",") + .map((x) => x.trim().toLowerCase()) + .filter(Boolean), +) +const cache = new Map() + +async function api(path: string, init?: RequestInit & { token?: string; ok?: number[] }) { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + redirect: "manual", + headers: { + Authorization: `Bearer ${init?.token ?? token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + }, + }) + if (res.ok || init?.ok?.includes(res.status)) return res + throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status} ${await res.text()}`) +} + +// GITHUB_TOKEN cannot see private org members, so they show up as plain +// contributors. An optional MEMBER_TOKEN (read:org) or MAINTAINERS list fixes that. +async function maintainer(login: string, assoc: string) { + if (trusted.has(assoc) || extra.has(login.toLowerCase())) return true + const key = login.toLowerCase() + const hit = cache.get(key) + if (hit !== undefined) return hit + const lookup = process.env.MEMBER_TOKEN + const res = lookup ? await api(`/orgs/${owner}/members/${login}`, { token: lookup, ok: [302, 404] }) : undefined + const found = res?.status === 204 + cache.set(key, found) + return found +} + +async function ensure() { + for (const item of labels) + await api(`/repos/${repo}/labels`, { method: "POST", body: JSON.stringify(item), ok: [422] }) +} + +const add = (num: number, list: string[]) => + list.length === 0 + ? undefined + : api(`/repos/${repo}/issues/${num}/labels`, { method: "POST", body: JSON.stringify({ labels: list }) }) +const drop = (num: number, list: string[]) => + Promise.all( + list.map((item) => + api(`/repos/${repo}/issues/${num}/labels/${encodeURIComponent(item)}`, { method: "DELETE", ok: [404] }), + ), + ) +const comment = (num: number, body: string) => + api(`/repos/${repo}/issues/${num}/comments`, { method: "POST", body: JSON.stringify({ body }) }) + +async function files(num: number) { + const out: string[] = [] + for (let page = 1; ; page++) { + const list: { filename: string }[] = await ( + await api(`/repos/${repo}/pulls/${num}/files?per_page=100&page=${page}`) + ).json() + out.push(...list.map((f) => f.filename)) + if (list.length < 100 || page >= 30) return out + } +} + +async function onevent() { + const event = await Bun.file(process.env.GITHUB_EVENT_PATH!).json() + const pr = event.pull_request + const item = pr ?? event.issue + const login: string = item.user.login + if (item.user.type === "Bot" || bots(login)) return console.log(`skip bot ${login}`) + + const fresh = ["opened", "reopened"].includes(event.action) + const community = !(await maintainer(login, item.author_association)) + const tags = new Set() + if (community) tags.add(label.community) + if (community && fresh) tags.add(label.triage) + if (pr) for (const tag of areas(pr.title, await files(pr.number))) tags.add(tag) + if (!pr) { + const tag = component(item.body ?? "") + if (tag) tags.add(tag) + } + const have = new Set(item.labels.map((l: { name: string }) => l.name)) + const next = [...tags].filter((tag) => !have.has(tag)) + console.log(`#${item.number} ${community ? "community" : "maintainer"}: adding [${next.join(", ")}]`) + await ensure() + await add(item.number, next) +} + +type Actor = { login: string; __typename: string } | null +type Node = { createdAt: string; authorAssociation: string; author: Actor; state?: string } + +const query = ` +query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 25, states: OPEN, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + number title url isDraft createdAt + authorAssociation + author { login __typename } + labels(first: 30) { nodes { name } } + commits(last: 1) { nodes { commit { committedDate statusCheckRollup { state } } } } + comments(last: 20) { nodes { createdAt authorAssociation author { login __typename } } } + reviews(last: 20) { nodes { createdAt state authorAssociation author { login __typename } } } + reviewThreads(last: 20) { nodes { comments(last: 5) { nodes { createdAt authorAssociation author { login __typename } } } } } + } + } + } +}` + +async function graphql(cursor: string | null) { + const res = await api("/graphql", { + method: "POST", + body: JSON.stringify({ query, variables: { owner, repo: name, cursor } }), + }) + const json = await res.json() + if (json.errors) throw new Error(JSON.stringify(json.errors)) + return json.data.repository.pullRequests +} + +async function sweep() { + const now = Date.now() + const enabled = process.env.CLOSE_ENABLED === "true" + const cap = Number(process.env.MAX_COMMENTS ?? 20) + let spent = 0 + await ensure() + + const prs: any[] = [] + let cursor: string | null = null + do { + const page = await graphql(cursor) + prs.push(...page.nodes) + cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null + } while (cursor) + console.log(`${prs.length} open PRs`) + + const rows: Row[] = [] + for (const pr of prs) { + const login: string | undefined = pr.author?.login + if (!login || pr.author.__typename === "Bot" || bots(login) || pr.isDraft) continue + if (await maintainer(login, pr.authorAssociation)) continue + + const tags: string[] = pr.labels.nodes.map((l: { name: string }) => l.name) + const nodes: Node[] = [ + ...pr.comments.nodes, + ...pr.reviews.nodes.filter((r: Node) => r.state !== "PENDING" && r.state !== "DISMISSED"), + ...pr.reviewThreads.nodes.flatMap((t: { comments: { nodes: Node[] } }) => t.comments.nodes), + ] + const events: Event[] = [] + for (const node of nodes) { + const who = node.author?.login + if (!who || node.author?.__typename === "Bot" || bots(who)) continue + const at = Date.parse(node.createdAt) + if (who === login) events.push({ who: "author", at }) + else if (await maintainer(who, node.authorAssociation)) + events.push({ who: "maintainer", at, approve: node.state === "APPROVED" }) + } + const commit = pr.commits.nodes.at(0)?.commit + if (commit) events.push({ who: "author", at: Date.parse(commit.committedDate) }) + + const state = classify(events, tags, now) + const failing = ["FAILURE", "ERROR"].includes(commit?.statusCheckRollup?.state) + const plan = decide(tags, state, failing) + const more = [...(tags.includes(label.community) ? [] : [label.community])] + if (!tags.some((tag) => tag.startsWith("area:"))) more.push(...areas(pr.title, await files(pr.number))) + const row: Row = { + number: pr.number, + title: pr.title, + url: pr.url, + author: login, + created: Date.parse(pr.createdAt), + state, + failing, + adopt: false, + closed: false, + } + + if (state.state === "awaiting" && state.days >= WAIT_DAYS) { + if (tags.includes(label.high)) { + row.adopt = true + if (!tags.includes(label.adopt)) more.push(label.adopt) + } else if (!tags.includes(label.keep)) { + if (!enabled) console.log(`[dry-run] would close #${pr.number} (${state.days}d without reply)`) + else if (spent < cap) { + spent++ + await comment( + pr.number, + `Closing this pull request because a maintainer asked for changes on ${new Date(state.since).toISOString().slice(0, 10)} and we have not heard back in ${WAIT_DAYS} days. Feel free to reopen it when you have updates, or open a new one.`, + ) + await api(`/repos/${repo}/pulls/${pr.number}`, { method: "PATCH", body: JSON.stringify({ state: "closed" }) }) + row.closed = true + console.log(`closed #${pr.number}`) + } + } + } else if (state.state === "awaiting" && plan.add.includes(label.awaiting) && enabled && spent < cap) { + spent++ + const due = new Date(state.since + WAIT_DAYS * DAY).toISOString().slice(0, 10) + await comment( + pr.number, + `Thanks for the contribution! A maintainer left feedback and is waiting for your reply. If we do not hear back by ${due}, this pull request will be closed. Push a fix or leave a comment to keep it open.`, + ) + } + + if (state.state !== "awaiting" && tags.includes(label.adopt)) plan.remove.push(label.adopt) + console.log( + `#${pr.number} ${state.state}${failing ? " ci-failing" : ""} +[${[...plan.add, ...more]}] -[${plan.remove}]`, + ) + await add(pr.number, [...plan.add, ...more]) + await drop(pr.number, row.closed ? [] : plan.remove) + rows.push(row) + } + + const out = process.env.GITHUB_STEP_SUMMARY + if (out) await Bun.write(out, digest(rows, "md", repo, now) + "\n") + const slack = process.env.SLACK_PAYLOAD + if (slack) await Bun.write(slack, JSON.stringify({ text: digest(rows, "slack", repo, now) })) + console.log(digest(rows, "md", repo, now)) +} + +if (import.meta.main) { + const cmd = process.argv[2] + if (cmd === "label") await onevent() + else if (cmd === "sweep") await sweep() + else { + console.error("usage: community-triage.ts ") + process.exit(1) + } +}