diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..7324ff23a --- /dev/null +++ b/.github/labeler.yml @@ -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" diff --git a/.github/scripts/pr-priority-triage.js b/.github/scripts/pr-priority-triage.js new file mode 100644 index 000000000..4021b1960 --- /dev/null +++ b/.github/scripts/pr-priority-triage.js @@ -0,0 +1,95 @@ +export const TRIAGE_MARKER = "" + +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)) +} + +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", + } +} + +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}`, + } +} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..c3fc6859d --- /dev/null +++ b/.github/workflows/labeler.yml @@ -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 diff --git a/.github/workflows/pr-priority-triage.yml b/.github/workflows/pr-priority-triage.yml new file mode 100644 index 000000000..3dd92895d --- /dev/null +++ b/.github/workflows/pr-priority-triage.yml @@ -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}`) diff --git a/packages/opencode/test/github/pr-routing-triage.test.ts b/packages/opencode/test/github/pr-routing-triage.test.ts new file mode 100644 index 000000000..0c1786195 --- /dev/null +++ b/packages/opencode/test/github/pr-routing-triage.test.ts @@ -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") + }) +})