mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-13 07:47:46 +08:00
chore: add community PR and issue triage automation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user