diff --git a/.github/labeler.yml b/.github/labeler.yml index 7324ff23a..fe50f79ad 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -24,9 +24,3 @@ harness: - changed-files: - any-glob-to-any-file: - "packages/opencode/**" - -documentation: - - changed-files: - - any-glob-to-any-file: - - "docs/**" - - "**/*.md" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c262c0676..46d482d79 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -40,7 +40,7 @@ Required for visible UI changes. - [ ] Human review status is stated above as pending, approved, or not required - [ ] I linked the related issue, or stated why there is no issue -- [ ] This PR has type, primary area, and priority labels, or I requested maintainer labeling +- [ ] This PR has exactly one type label (`bug`, `enhancement`, `task`, or `documentation`), at least one primary routing label (`app`, `ui`, `platform`, `harness`, or `ci`), and exactly one priority label (`P0` to `P3`), or I requested maintainer labeling - [ ] I described the review focus and any meaningful risks - [ ] I listed the relevant verification steps and the key result for each - [ ] I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope diff --git a/.github/scripts/label-policy-check.js b/.github/scripts/label-policy-check.js new file mode 100644 index 000000000..8abeae754 --- /dev/null +++ b/.github/scripts/label-policy-check.js @@ -0,0 +1,56 @@ +export const POLICY = { + priorities: ["P0", "P1", "P2", "P3"], + types: ["bug", "enhancement", "task", "documentation"], + routing: ["app", "ui", "platform", "harness", "ci"], + issueForbiddenLabels: ["dependencies", "github_actions", "javascript"], +} + +function intersection(labels, allowed) { + return allowed.filter((label) => labels.has(label)) +} + +function error(message, labels) { + return { message, labels } +} + +function labelList(labels) { + if (labels.length <= 1) return labels.join("") + return `${labels.slice(0, -1).join(", ")}, or ${labels[labels.length - 1]}` +} + +export function validateLabelPolicy({ itemType, labels = [] }) { + const labelSet = new Set(labels) + const errors = [] + + const priorities = intersection(labelSet, POLICY.priorities) + if (priorities.length !== 1) { + errors.push(error(`${itemType} must have exactly one priority label: ${labelList(POLICY.priorities)}`, priorities)) + } + + const types = intersection(labelSet, POLICY.types) + if (types.length !== 1) { + errors.push(error(`${itemType} must have exactly one type label: ${labelList(POLICY.types)}`, types)) + } + + const routing = intersection(labelSet, POLICY.routing) + if (routing.length < 1) { + errors.push(error(`${itemType} must have at least one primary routing label: ${labelList(POLICY.routing)}`, routing)) + } + + if (labelSet.has("tech-debt") && !labelSet.has("task")) { + errors.push(error("tech-debt is only allowed with the task type label", ["tech-debt"])) + } + + const forbiddenIssueLabels = + itemType === "issue" ? intersection(labelSet, POLICY.issueForbiddenLabels) : [] + if (forbiddenIssueLabels.length > 0) { + errors.push( + error(`issue must not use PR automation labels: ${labelList(POLICY.issueForbiddenLabels)}`, forbiddenIssueLabels), + ) + } + + return { + ok: errors.length === 0, + errors, + } +} diff --git a/.github/scripts/label-policy-check.test.js b/.github/scripts/label-policy-check.test.js new file mode 100644 index 000000000..3900497d2 --- /dev/null +++ b/.github/scripts/label-policy-check.test.js @@ -0,0 +1,128 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { validateLabelPolicy } from "./label-policy-check.js" + +function messages(result) { + return result.errors.map((error) => error.message) +} + +test("accepts a valid issue label set", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["task", "P2", "app", "tech-debt"], + }) + + assert.deepEqual(result.errors, []) +}) + +test("accepts a valid pull request label set", () => { + const result = validateLabelPolicy({ + itemType: "pull_request", + labels: ["enhancement", "P2", "app", "ui"], + }) + + assert.deepEqual(result.errors, []) +}) + +test("accepts ci as a primary routing label", () => { + const result = validateLabelPolicy({ + itemType: "pull_request", + labels: ["task", "P2", "ci"], + }) + + assert.deepEqual(result.errors, []) +}) + +test("rejects missing priority labels", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["bug", "app"], + }) + + assert.deepEqual(messages(result), ["issue must have exactly one priority label: P0, P1, P2, or P3"]) +}) + +test("treats missing labels input as an empty label set", () => { + const result = validateLabelPolicy({ + itemType: "issue", + }) + + assert.deepEqual(messages(result), [ + "issue must have exactly one priority label: P0, P1, P2, or P3", + "issue must have exactly one type label: bug, enhancement, task, or documentation", + "issue must have at least one primary routing label: app, ui, platform, harness, or ci", + ]) +}) + +test("rejects multiple priority labels", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["bug", "P1", "P2", "app"], + }) + + assert.deepEqual(messages(result), ["issue must have exactly one priority label: P0, P1, P2, or P3"]) +}) + +test("rejects missing type labels", () => { + const result = validateLabelPolicy({ + itemType: "pull_request", + labels: ["P2", "app"], + }) + + assert.deepEqual(messages(result), [ + "pull_request must have exactly one type label: bug, enhancement, task, or documentation", + ]) +}) + +test("rejects multiple type labels", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["bug", "task", "P2", "app"], + }) + + assert.deepEqual(messages(result), ["issue must have exactly one type label: bug, enhancement, task, or documentation"]) +}) + +test("rejects missing primary routing labels", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["task", "P2"], + }) + + assert.deepEqual(messages(result), ["issue must have at least one primary routing label: app, ui, platform, harness, or ci"]) +}) + +test("rejects tech-debt outside task issues", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["bug", "P2", "app", "tech-debt"], + }) + + assert.deepEqual(messages(result), ["tech-debt is only allowed with the task type label"]) +}) + +test("rejects dependency automation labels on issues", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["task", "P3", "app", "dependencies"], + }) + + assert.deepEqual(messages(result), [ + "issue must not use PR automation labels: dependencies, github_actions, or javascript", + ]) +}) + +test("reports all independent label policy failures", () => { + const result = validateLabelPolicy({ + itemType: "issue", + labels: ["bug", "enhancement", "P1", "P2", "dependencies"], + }) + + assert.deepEqual(messages(result), [ + "issue must have exactly one priority label: P0, P1, P2, or P3", + "issue must have exactly one type label: bug, enhancement, task, or documentation", + "issue must have at least one primary routing label: app, ui, platform, harness, or ci", + "issue must not use PR automation labels: dependencies, github_actions, or javascript", + ]) +}) diff --git a/.github/workflows/label-policy.yml b/.github/workflows/label-policy.yml new file mode 100644 index 000000000..18fbdd6a5 --- /dev/null +++ b/.github/workflows/label-policy.yml @@ -0,0 +1,53 @@ +name: label-policy + +on: + issues: + types: [opened, edited, labeled, unlabeled, reopened] + pull_request_target: + types: [opened, edited, labeled, unlabeled, synchronize, reopened] + branches: [dev] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + label-policy: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout@v6 + if: github.event_name == 'pull_request_target' + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout@v6 + if: github.event_name == 'issues' + with: + persist-credentials: false + + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # actions/github-script@v7 + with: + script: | + const path = require("node:path") + const { pathToFileURL } = require("node:url") + const policy = await import( + pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, ".github/scripts/label-policy-check.js")).href, + ) + + const item = + context.eventName === "issues" ? context.payload.issue : context.payload.pull_request + const itemType = context.eventName === "issues" ? "issue" : "pull_request" + const labels = item.labels.map((label) => label.name) + const result = policy.validateLabelPolicy({ itemType, labels }) + + if (!result.ok) { + const labelText = labels.length > 0 ? labels.join(", ") : "(none)" + const errorText = result.errors.map((error) => `- ${error.message}`).join("\n") + core.setFailed(`Label policy failed for ${itemType} #${item.number}.\n\nLabels: ${labelText}\n\n${errorText}`) + return + } + + core.info(`Label policy passed for ${itemType} #${item.number}: ${labels.join(", ")}`) diff --git a/packages/opencode/test/github/pr-routing-triage.test.ts b/packages/opencode/test/github/pr-routing-triage.test.ts index 0c1786195..74ba33f64 100644 --- a/packages/opencode/test/github/pr-routing-triage.test.ts +++ b/packages/opencode/test/github/pr-routing-triage.test.ts @@ -9,20 +9,20 @@ const labelerWorkflowPath = path.join(repoRoot, ".github", "workflows", "labeler const triageWorkflowPath = path.join(repoRoot, ".github", "workflows", "pr-priority-triage.yml") describe("pr routing workflows", () => { - test("defines labeler routing on current repo labels", () => { + test("defines labeler routing without automatic type 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).not.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") + expect(config).not.toContain("**/*.md") }) test("pins labeler and triage workflow contracts", () => {