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