chore: add community PR and issue triage automation

This commit is contained in:
Bruno Agatao
2026-10-08 02:18:39 +02:00
parent 08981b3a30
commit 89a01cf230
10 changed files with 885 additions and 0 deletions
+20
View File
@@ -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:
@@ -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
+2
View File
@@ -1,3 +1,5 @@
<!-- Community PRs are triaged twice a week. If a maintainer asks for changes and we hear nothing for 14 days, the PR is closed. See docs/community-triage.md. -->
## Issue
<!-- Reference an existing issue with `Fixes #123`, `Closes #123`, or equivalent linked issue wording. -->
+47
View File
@@ -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
+61
View File
@@ -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
+2
View File
@@ -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
+153
View File
@@ -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<br/>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.
+2
View File
@@ -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",
+123
View File
@@ -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> = {}): 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("<https://github.com/o/r/pull/1|#1 PR 1>")
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")
})
})
+455
View File
@@ -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<string, string> = {
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<string, string> = {
"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<string>()
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<string>()
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<string, boolean>()
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<string>()
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<string>(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 <label|sweep>")
process.exit(1)
}
}