Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
ci:
- changed-files:
- any-glob-to-any-file:
- ".github/workflows/**"

platform:
- changed-files:
- any-glob-to-any-file:
- "packages/desktop-electron/**"

app:
- changed-files:
- any-glob-to-any-file:
- "packages/app/**"

ui:
- changed-files:
- any-glob-to-any-file:
- "packages/app/src/components/**"
- "packages/ui/**"
- "**/*.tsx"

harness:
- changed-files:
- any-glob-to-any-file:
- "packages/opencode/**"

documentation:
- changed-files:
- any-glob-to-any-file:
- "docs/**"
- "**/*.md"
95 changes: 95 additions & 0 deletions .github/scripts/pr-priority-triage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
export const TRIAGE_MARKER = "<!-- pawwork-pr-priority-triage-v1 -->"

const LOW_RISK_GLOBS = [
"docs/**",
"**/*.md",
".github/workflows/**",
"**/test/**",
"**/e2e/**",
]

const USER_PATH_GLOBS = ["packages/app/src/**", "packages/desktop-electron/src/**"]

function escapeRegex(value) {
return value.replace(/[.+^${}()|[\]\\]/g, "\\$&")
}

function globToRegExp(glob) {
let out = "^"
for (let index = 0; index < glob.length; index += 1) {
const char = glob[index]
const next = glob[index + 1]

if (char === "*") {
if (next === "*") {
index += 1
if (glob[index + 1] === "/") {
index += 1
out += "(?:.*/)?"
continue
}
out += ".*"
continue
}
out += "[^/]*"
continue
}

if (char === "?") {
out += "."
continue
}

out += escapeRegex(char)
}
out += "$"
return new RegExp(out)
}

function matchesAny(path, globs) {
return globs.some((glob) => globToRegExp(glob).test(path))
}
Comment thread
Astro-Han marked this conversation as resolved.

export function classifyPriority(paths) {
const normalized = paths.map((path) => path.replace(/\\/g, "/"))

if (normalized.length === 0) {
return {
priority: "P3",
reason: "no changed files were available from the pull request payload",
}
}

if (normalized.every((path) => matchesAny(path, LOW_RISK_GLOBS))) {
return {
priority: "P3",
reason: `only low-risk paths changed (${normalized.join(", ")})`,
}
}

if (normalized.some((path) => matchesAny(path, USER_PATH_GLOBS))) {
return {
priority: "P2",
reason: `includes user-path files (${normalized.filter((path) => matchesAny(path, USER_PATH_GLOBS)).join(", ")})`,
}
}

return {
priority: "P2",
reason: "includes non-doc, non-test paths outside the low-risk bucket",
}
}
Comment thread
Astro-Han marked this conversation as resolved.

export function buildPriorityReview(paths) {
const verdict = classifyPriority(paths)
const manualOverride =
"P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure."

return {
...verdict,
body: `${TRIAGE_MARKER}
Suggested priority: ${verdict.priority} (${verdict.reason}).

${manualOverride}`,
}
}
18 changes: 18 additions & 0 deletions .github/workflows/labeler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: labeler

on:
pull_request_target:
types: [opened, synchronize, reopened]
branches: [dev]

permissions:
contents: read
pull-requests: write

jobs:
labeler:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # actions/labeler@v5
with:
sync-labels: true
70 changes: 70 additions & 0 deletions .github/workflows/pr-priority-triage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: pr-priority-triage

on:
pull_request_target:
types: [opened, synchronize, reopened]
branches: [dev]

concurrency:
group: pr-priority-triage-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: write

jobs:
pr-priority-triage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event.pull_request.base.sha }}

- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # actions/github-script@v7
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const path = require("node:path")
const { pathToFileURL } = require("node:url")
const triage = await import(
pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, ".github/scripts/pr-priority-triage.js")).href,
)
const { buildPriorityReview, TRIAGE_MARKER } = triage
const pull_number = Number(process.env.PR_NUMBER)
const owner = context.repo.owner
const repo = context.repo.repo

const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number,
per_page: 100,
})
const paths = files.map((file) => file.filename)

const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number,
per_page: 100,
})

if (reviews.some((review) => typeof review.body === "string" && review.body.includes(TRIAGE_MARKER))) {
core.info("Priority recommendation comment already exists; skipping.")
return
}

const review = buildPriorityReview(paths)
await github.rest.pulls.createReview({
owner,
repo,
pull_number,
event: "COMMENT",
body: review.body,
})

core.info(`Posted priority recommendation: ${review.priority}`)
139 changes: 139 additions & 0 deletions packages/opencode/test/github/pr-routing-triage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { buildPriorityReview, classifyPriority, TRIAGE_MARKER } from "../../../../.github/scripts/pr-priority-triage.js"
import { parseWorkflow, readWorkflow } from "./workflow-parser"

const repoRoot = path.join(import.meta.dir, "../../../..")
const labelerConfigPath = path.join(repoRoot, ".github", "labeler.yml")
const labelerWorkflowPath = path.join(repoRoot, ".github", "workflows", "labeler.yml")
const triageWorkflowPath = path.join(repoRoot, ".github", "workflows", "pr-priority-triage.yml")

describe("pr routing workflows", () => {
test("defines labeler routing on current repo labels", () => {
const config = readWorkflow(labelerConfigPath)
expect(config).toContain("ci:")
expect(config).toContain("platform:")
expect(config).toContain("app:")
expect(config).toContain("ui:")
expect(config).toContain("harness:")
expect(config).toContain("documentation:")
expect(config).toContain(".github/workflows/**")
expect(config).toContain("packages/desktop-electron/**")
expect(config).toContain("packages/app/**")
expect(config).toContain("packages/opencode/**")
expect(config).toContain("**/*.tsx")
expect(config).toContain("**/*.md")
})

test("pins labeler and triage workflow contracts", () => {
const labelerWorkflow = readWorkflow(labelerWorkflowPath)
const labelerParsed = parseWorkflow(labelerWorkflowPath)
const labelerSteps = labelerParsed.jobs?.labeler?.steps ?? []
const labelerStep = labelerSteps[0]

expect(labelerParsed.name).toBe("labeler")
expect(labelerParsed.on?.pull_request_target).toEqual({
types: ["opened", "synchronize", "reopened"],
branches: ["dev"],
})
expect(labelerParsed.permissions).toEqual({
contents: "read",
"pull-requests": "write",
})
expect(labelerStep?.uses).toBe("actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9")
expect(labelerStep?.with).toEqual({ "sync-labels": true })
expect(labelerWorkflow).not.toContain("persist-credentials: true")

const triageWorkflow = readWorkflow(triageWorkflowPath)
const triageParsed = parseWorkflow(triageWorkflowPath)
const triageSteps = triageParsed.jobs?.["pr-priority-triage"]?.steps ?? []
const checkout = triageSteps.find((step) => step.uses?.startsWith("actions/checkout@"))
const script = triageSteps.find((step) => step.uses?.startsWith("actions/github-script@"))

expect(triageParsed.name).toBe("pr-priority-triage")
expect(triageParsed.on?.pull_request_target).toEqual({
types: ["opened", "synchronize", "reopened"],
branches: ["dev"],
})
expect(triageParsed.permissions).toEqual({
contents: "read",
"pull-requests": "write",
})
expect(triageParsed.concurrency?.group).toBe("pr-priority-triage-${{ github.event.pull_request.number }}")
expect(triageParsed.concurrency?.["cancel-in-progress"]).toBe(true)
expect(checkout?.uses).toBe("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd")
expect(checkout?.with).toEqual({
"persist-credentials": false,
ref: "${{ github.event.pull_request.base.sha }}",
})
expect(script?.uses).toBe("actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b")
expect(script?.env).toEqual({ PR_NUMBER: "${{ github.event.pull_request.number }}" })
expect(script?.run).toBeUndefined()
expect(triageWorkflow).toContain('event: "COMMENT"')
expect(triageWorkflow).toContain("TRIAGE_MARKER")
expect(triageWorkflow).toContain("github.rest.pulls.listFiles")
expect(triageWorkflow).toContain("github.rest.pulls.listReviews")
expect(triageWorkflow).toContain("pathToFileURL")
expect(triageWorkflow).toContain("process.env.GITHUB_WORKSPACE")
expect(triageWorkflow).toContain("await import(")
expect(triageWorkflow).not.toContain('require("./.github/scripts/pr-priority-triage.js")')
expect(triageWorkflow).not.toContain("issues:")
})
})

describe("pr priority triage helper", () => {
test("marks doc/workflow/test only changes as P3", () => {
expect(classifyPriority([".github/workflows/officecli-bump.yml"]).priority).toBe("P3")
expect(classifyPriority(["packages/app/e2e/session/session-composer-dock.spec.ts"]).priority).toBe("P3")
expect(classifyPriority(["docs/release.md", "README.md"]).priority).toBe("P3")
})

test("marks user path changes as P2", () => {
expect(
classifyPriority([
"packages/app/src/context/global-sync.tsx",
"packages/app/src/context/global-sync/event-reducer.ts",
]),
).toEqual({
priority: "P2",
reason:
"includes user-path files (packages/app/src/context/global-sync.tsx, packages/app/src/context/global-sync/event-reducer.ts)",
})
expect(classifyPriority(["packages/desktop-electron/src/main/index.ts"]).priority).toBe("P2")
})

test("defaults mixed non-low-risk changes to P2", () => {
expect(classifyPriority(["packages/opencode/src/cli/cmd/github.ts"]).priority).toBe("P2")
})

test("builds one-shot review comments with marker and maintainer override", () => {
const review = buildPriorityReview(["packages/app/e2e/session/session-composer-dock.spec.ts"])
expect(review.priority).toBe("P3")
expect(review.body).toContain(TRIAGE_MARKER)
expect(review.body).toContain("Suggested priority: P3")
expect(review.body).toContain("P1/P0 are reserved for maintainer confirmation")
})

test("matches recent PR sanity cases", () => {
expect(
classifyPriority([
"packages/app/e2e/session/session-composer-dock.spec.ts",
"packages/app/src/context/global-sdk.tsx",
"packages/app/src/context/global-sdk/sse-cursor.test.ts",
"packages/app/src/context/global-sdk/sse-cursor.ts",
"packages/app/src/context/global-sync.tsx",
"packages/app/src/context/global-sync/event-reducer.test.ts",
"packages/app/src/context/global-sync/event-reducer.ts",
"packages/app/src/testing/terminal.ts",
]).priority,
).toBe("P2")

expect(classifyPriority(["packages/app/e2e/session/session-composer-dock.spec.ts"]).priority).toBe("P3")
expect(
classifyPriority([
".github/workflows/officecli-bump.yml",
"packages/opencode/test/github/officecli-bump-workflow.test.ts",
]).priority,
).toBe("P3")
})
})
Loading