diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 09734d7b5..4a2503137 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,8 +35,12 @@ body: - Dashboard - Provider adapter - Authentication and account pool + - Catalog / models + - Streaming + - Tools / MCP / web search - Installation or packaging - Service lifecycle + - Platform (Windows / macOS / Linux) - Documentation - Other validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index c5df37f43..5ac7b510c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -19,8 +19,12 @@ body: - Dashboard - Provider adapters - Authentication and account pool + - Catalog / models + - Streaming + - Tools / MCP / web search - Installation or packaging - Service lifecycle + - Platform (Windows / macOS / Linux) - Documentation - Multiple areas - Other diff --git a/.github/ISSUE_TEMPLATE/provider_compatibility.yml b/.github/ISSUE_TEMPLATE/provider_compatibility.yml index 6525cbed7..ab02cf379 100644 --- a/.github/ISSUE_TEMPLATE/provider_compatibility.yml +++ b/.github/ISSUE_TEMPLATE/provider_compatibility.yml @@ -2,6 +2,7 @@ name: Provider or API compatibility description: Report an incompatible provider, endpoint, request shape, response shape, or client integration. labels: - provider-compatibility + - provider body: - type: markdown attributes: diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs index 9dccb742f..b2657f655 100644 --- a/.github/scripts/issue-quality.cjs +++ b/.github/scripts/issue-quality.cjs @@ -250,6 +250,184 @@ const KIND_TO_LABEL = { "provider-compatibility": "provider-compatibility", }; +/** + * Orthogonal product-area labels (additive beside kind/process labels). + * Colors/descriptions are used when the workflow ensures labels exist. + */ +const AREA_LABELS = { + provider: { + color: "1D76DB", + description: "Provider adapters, OpenAI-compat presets, upstream API quirks", + }, + "account-pool": { + color: "5319E7", + description: "OAuth, credentials, Codex pool, quota, failover, plans", + }, + catalog: { + color: "006B75", + description: "Model catalog, slugs, visibility, routed entries", + }, + gui: { + color: "D93F0B", + description: "Dashboard, tray, settings UI", + }, + cli: { + color: "FBCA04", + description: "CLI, config inject, packaging flags", + }, + proxy: { + color: "0E8A16", + description: "HTTP proxy, routing, reverse-proxy / management auth", + }, + platform: { + color: "BFDADC", + description: "OS/service/tray/ACL (Windows-heavy, not Windows-only)", + }, + streaming: { + color: "C5DEF5", + description: "SSE, WebSocket, terminal stream frames", + }, + tools: { + color: "F9D0C4", + description: "tool_calls, MCP, web-search / sidecar tools", + }, + install: { + color: "EDEDED", + description: "Installation or packaging", + }, + service: { + color: "EDEDED", + description: "Service lifecycle (WinSW/launchd/scheduler)", + }, +}; + +/** Canonical Area dropdown text → area label(s). Keys are lowercased. */ +const AREA_FIELD_TO_LABELS = { + cli: ["cli"], + "proxy and routing": ["proxy"], + dashboard: ["gui"], + "provider adapter": ["provider"], + "provider adapters": ["provider"], + "authentication and account pool": ["account-pool"], + "catalog / models": ["catalog"], + streaming: ["streaming"], + "tools / mcp / web search": ["tools"], + "installation or packaging": ["install"], + "service lifecycle": ["service"], + "service lifecycle (config injection)": ["service"], + "platform (windows / macos / linux)": ["platform"], + // Do not map to kind label `documentation` — that collides with labelBasedKind + // when a feature/bug form picks Area: Documentation. Docs form already seeds + // the kind label; Area selection alone does not add an area tag. + documentation: [], + // No dedicated label; heuristics still run in detectAreaLabels. + "multiple areas": [], + other: [], +}; + +/** Body headings used for area heuristics (excludes Environment / OS metadata). */ +const AREA_HEURISTIC_BODY_HEADINGS = [ + "Summary", + "Reproduction", + "What are you trying to accomplish?", + "What prevents this today?", + "What should OpenCodex do?", + "Example usage or interface", + "Current behaviour", + "Expected behaviour", + "Minimal redacted request or reproduction", + "What is wrong or missing?", + "Documentation problem type", + "Documentation location", +]; + +/** + * Heuristic rules. `scope: "title"` avoids false hits from template Environment / + * OS fields in the body; `scope: "full"` is for distinctive technical tokens. + */ +const AREA_HEURISTICS = [ + { + label: "account-pool", + scope: "full", + re: /\b(oauth|reauth|needsreauth|account pool|codex.?auth|auto[- ]?switch|account failover|refresh token|plan_type|chatgpt[- ]account|reset credit)\b/i, + }, + { + label: "account-pool", + scope: "title", + re: /\b(quota|failover|pool account|account switch)\b/i, + }, + { + label: "catalog", + scope: "full", + re: /\b(model catalog|opencodex-catalog|model list|model visibility|virtual model|routed (catalog|entries|slug)|model slug)\b/i, + }, + { + label: "catalog", + scope: "title", + re: /\bcatalog\b/i, + }, + { + label: "gui", + scope: "title", + re: /\b(dashboard|\bgui\b|tray|sidebar|settings (page|tab|ui))\b/i, + }, + { + label: "cli", + scope: "title", + re: /\b(ocx\b|config\.toml|config inject)\b/i, + }, + { + label: "proxy", + scope: "full", + re: /\b(reverse[- ]proxy|management api|admin[- ]token|\/api\/\*|bind(s)? the (old )?port)\b/i, + }, + { + label: "proxy", + scope: "title", + re: /\b(reverse[- ]proxy|management api|admin[- ]token)\b/i, + }, + { + label: "platform", + scope: "full", + re: /\b(winsw|launchd|schtasks|icacls|windows-latest|tray host|scheduler backend)\b/i, + }, + { + label: "platform", + scope: "title", + re: /\b(\[windows\]|\[macos\]|windows|macos|darwin|win32|wsl)\b/i, + }, + { + label: "streaming", + scope: "full", + re: /\b(sse|websocket|\bws\b|stream(ing)?\b.{0,40}\btruncat\w*|stream(ing)?\b.{0,40}\bterminal\b|terminal (sse )?frame|without a terminal)\b/i, + }, + { + label: "tools", + scope: "full", + re: /\b(tool_calls?|tool[- ]calls?|\bmcp\b|web[- ]search|tool[- ]recall)\b/i, + }, + { + label: "install", + scope: "full", + re: /\b(npm (global )?install|packaging|release asset|npx ocx)\b/i, + }, + { + label: "service", + scope: "full", + re: /\b(ocx service|winsw|scheduler backend|launchd service)\b/i, + }, + { + label: "provider", + scope: "full", + re: /\b(provider adapter|openai[- ]compatible|provider[- ]compat|adapter quirk|built[- ]in provider|provider preset)\b/i, + }, + { + label: "provider", + scope: "title", + re: /\b(\[provider\]|provider compat|openai[- ]compatible)\b/i, + }, +]; + /** * Map a detected issue kind to its triage label. Returns null when unknown. */ @@ -258,6 +436,99 @@ function labelForKind(kind) { return KIND_TO_LABEL[kind] || null; } +/** + * Map a template Area dropdown value to orthogonal area label names. + * Returns [] for Other / Multiple areas / unknown / empty. + * + * @param {unknown} areaText + * @returns {string[]} + */ +function mapAreaFieldToLabels(areaText) { + if (typeof areaText !== "string") return []; + const key = areaText.replace(/\s+/g, " ").trim().toLowerCase(); + if (!key) return []; + return AREA_FIELD_TO_LABELS[key] ? [...AREA_FIELD_TO_LABELS[key]] : []; +} + +/** + * Build heuristic text from title-relevant semantic sections only — never from + * Operating system / Version / Checks metadata that every template includes. + * + * @param {string} body + * @returns {string} + */ +function bodyForAreaHeuristics(body) { + if (typeof body !== "string" || !body.trim()) return ""; + const parts = []; + for (const heading of AREA_HEURISTIC_BODY_HEADINGS) { + const section = extractSection(body, heading); + if (section) parts.push(section); + } + return parts.join("\n\n"); +} + +/** + * Conservative title/body heuristics for orthogonal area labels. + * + * @param {string} title + * @param {string} body semantic body text (already filtered) + * @returns {string[]} + */ +function heuristicAreaLabels(title, body) { + const titleText = title || ""; + const fullText = `${titleText}\n${body || ""}`; + const seen = new Set(); + const out = []; + for (const { label, re, scope } of AREA_HEURISTICS) { + const text = scope === "title" ? titleText : fullText; + if (!re.test(text) || seen.has(label)) continue; + seen.add(label); + out.push(label); + } + return out; +} + +/** + * Detect additive product-area labels from Area field, form defaults, and + * title/body heuristics. Never invents per-provider labels. + * + * @param {{ + * title?: string, + * body?: string, + * labels?: string[], + * heuristicBody?: string, + * }} issue + * `body` is the source form (for Area / provider headings). + * `heuristicBody` may include English translation text for heuristics only. + * @returns {string[]} + */ +function detectAreaLabels(issue) { + const title = typeof issue?.title === "string" ? issue.title : ""; + const body = typeof issue?.body === "string" ? issue.body : ""; + const labels = Array.isArray(issue?.labels) ? issue.labels : []; + const heuristicSource = typeof issue?.heuristicBody === "string" ? issue.heuristicBody : body; + + const areaSection = extractSection(body, "Area"); + const fromArea = mapAreaFieldToLabels(areaSection); + const fromHeur = heuristicAreaLabels(title, bodyForAreaHeuristics(heuristicSource)); + const fromForm = []; + if (labels.includes("provider-compatibility")) fromForm.push("provider"); + // Provider-compat form uses this heading instead of Area. + if (extractSection(body, "Provider or upstream service") !== null) { + fromForm.push("provider"); + } + + const seen = new Set(); + const out = []; + for (const label of [...fromArea, ...fromForm, ...fromHeur]) { + if (!label || seen.has(label)) continue; + if (!AREA_LABELS[label]) continue; + seen.add(label); + out.push(label); + } + return out; +} + function countHeadings(body, headings) { let n = 0; for (const h of headings) { @@ -952,6 +1223,12 @@ module.exports = { hasConcreteDetail, labelForKind, KIND_TO_LABEL, + AREA_LABELS, + AREA_FIELD_TO_LABELS, + mapAreaFieldToLabels, + bodyForAreaHeuristics, + heuristicAreaLabels, + detectAreaLabels, hasSubstantialStructuredContent, rejectsWorkflowDispatchPullRequest, rejectsWorkflowDispatchNonDefaultBranch, diff --git a/.github/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index c629a3d78..fedb496fe 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -13,6 +13,9 @@ const { shouldReopen, shouldEnforceClosure, labelForKind, + AREA_LABELS, + mapAreaFieldToLabels, + detectAreaLabels, isPlaceholderOnlyValue, isPlaceholder, isRawPlaceholder, @@ -1650,3 +1653,152 @@ describe("rejectsWorkflowDispatchNonDefaultBranch", () => { ); }); }); + +// --------------------------------------------------------------------------- +// Orthogonal area labels +// --------------------------------------------------------------------------- + +describe("mapAreaFieldToLabels", () => { + it("maps canonical Area dropdown values", () => { + assert.deepEqual(mapAreaFieldToLabels("CLI"), ["cli"]); + assert.deepEqual(mapAreaFieldToLabels("Proxy and routing"), ["proxy"]); + assert.deepEqual(mapAreaFieldToLabels("Dashboard"), ["gui"]); + assert.deepEqual(mapAreaFieldToLabels("Provider adapter"), ["provider"]); + assert.deepEqual(mapAreaFieldToLabels("Provider adapters"), ["provider"]); + assert.deepEqual(mapAreaFieldToLabels("Authentication and account pool"), ["account-pool"]); + assert.deepEqual(mapAreaFieldToLabels("Catalog / models"), ["catalog"]); + assert.deepEqual(mapAreaFieldToLabels("Streaming"), ["streaming"]); + assert.deepEqual(mapAreaFieldToLabels("Tools / MCP / web search"), ["tools"]); + assert.deepEqual(mapAreaFieldToLabels("Installation or packaging"), ["install"]); + assert.deepEqual(mapAreaFieldToLabels("Service lifecycle"), ["service"]); + assert.deepEqual(mapAreaFieldToLabels("Platform (Windows / macOS / Linux)"), ["platform"]); + assert.deepEqual(mapAreaFieldToLabels("Documentation"), []); + }); + + it("maps legacy Service lifecycle wording and ignores Other / Multiple areas", () => { + assert.deepEqual(mapAreaFieldToLabels("Service lifecycle (config injection)"), ["service"]); + assert.deepEqual(mapAreaFieldToLabels("Other"), []); + assert.deepEqual(mapAreaFieldToLabels("Multiple areas"), []); + assert.deepEqual(mapAreaFieldToLabels(""), []); + assert.deepEqual(mapAreaFieldToLabels(null), []); + }); + + it("exposes metadata for every non-documentation area label", () => { + for (const name of Object.keys(AREA_LABELS)) { + assert.ok(AREA_LABELS[name].color, name); + assert.ok(AREA_LABELS[name].description, name); + } + }); +}); + +describe("detectAreaLabels", () => { + it("applies Area mapping plus orthogonal heuristics", () => { + const labels = detectAreaLabels({ + title: "Pool failover stalls on SSE without terminal frame", + body: [ + "### Area", + "Authentication and account pool", + "### Summary", + "Account pool failover waits forever when the upstream SSE stream ends without a terminal frame.", + ].join("\n"), + labels: ["bug"], + }); + assert.ok(labels.includes("account-pool")); + assert.ok(labels.includes("streaming")); + }); + + it("adds provider for provider-compatibility form and label", () => { + const fromLabel = detectAreaLabels({ + title: "AgentRouter Anthropic streams can end without terminal SSE frames", + body: "### Summary\nStream ends early.", + labels: ["provider-compatibility"], + }); + assert.ok(fromLabel.includes("provider")); + assert.ok(fromLabel.includes("streaming")); + + const fromHeading = detectAreaLabels({ + title: "Custom relay rejects tool_calls", + body: [ + "### Provider or upstream service", + "Volcengine Ark", + "### Current behaviour", + "tool_calls with empty content return 400.", + ].join("\n"), + labels: [], + }); + assert.ok(fromHeading.includes("provider")); + assert.ok(fromHeading.includes("tools")); + }); + + it("runs heuristics for Multiple areas / Other without inventing per-provider labels", () => { + const labels = detectAreaLabels({ + title: "Dashboard ACL hardening blocks management API on Windows", + body: [ + "### Area", + "Multiple areas", + "### Summary", + "Management API fails closed when icacls hardening cannot be verified.", + ].join("\n"), + labels: ["bug"], + }); + assert.ok(labels.includes("gui"), `got ${labels.join(",")}`); + assert.ok(labels.includes("platform"), `got ${labels.join(",")}`); + assert.ok(labels.includes("proxy"), `got ${labels.join(",")}`); + assert.equal(labels.includes("kiro"), false); + assert.equal(labels.includes("gemini"), false); + assert.equal(labels.includes("windows"), false); + }); + + it("does not map Documentation Area onto the documentation kind label", () => { + const labels = detectAreaLabels({ + title: "Codex Auth UI/docs conflate usage-based switching", + body: ["### Area", "Documentation", "### Summary", "Docs misdefine new session."].join("\n"), + labels: ["enhancement"], + }); + assert.equal(labels.includes("documentation"), false); + assert.equal(labels.includes("docs"), false); + }); + + it("ignores Operating system metadata for platform heuristics", () => { + const labels = detectAreaLabels({ + title: "Dashboard shows empty providers tab", + body: [ + "### Area", + "Dashboard", + "### Summary", + "Providers tab is blank after login.", + "### Operating system", + "Windows 11", + "### Reproduction", + "1. Open the dashboard", + ].join("\n"), + labels: ["bug"], + }); + assert.ok(labels.includes("gui")); + assert.equal(labels.includes("platform"), false); + }); + + it("uses heuristicBody translation text when Area is Other", () => { + const labels = detectAreaLabels({ + title: "问题报告", + body: ["### Area", "Other", "### Summary", "原始描述"].join("\n"), + heuristicBody: [ + "### Area", + "Other", + "### Summary", + "Account pool failover fails when refresh token is already used.", + ].join("\n"), + labels: ["bug"], + }); + assert.ok(labels.includes("account-pool"), `got ${labels.join(",")}`); + }); + + it("matches truncated streaming wording via truncat stem", () => { + const labels = detectAreaLabels({ + title: "Upstream streaming response truncated mid-turn", + body: ["### Area", "Other", "### Summary", "The streaming response was truncated."].join("\n"), + labels: ["bug"], + }); + assert.ok(labels.includes("streaming"), `got ${labels.join(",")}`); + }); +}); diff --git a/.github/workflows/enforce-issue-quality.yml b/.github/workflows/enforce-issue-quality.yml index 2ef8ddc59..1afef369e 100644 --- a/.github/workflows/enforce-issue-quality.yml +++ b/.github/workflows/enforce-issue-quality.yml @@ -17,18 +17,27 @@ on: workflow_dispatch: inputs: issue_number: - description: Issue number to validate and enforce - required: true + description: Issue number to validate and enforce (omit when backfilling open area labels) + required: false type: number + backfill_open_areas: + description: Apply orthogonal area labels to all open issues (area pass only; no quality closure) + required: false + type: boolean + default: false concurrency: - group: issue-quality-${{ github.event.issue.number || inputs.issue_number }} + group: issue-quality-${{ github.event.issue.number || inputs.issue_number || (inputs.backfill_open_areas && 'backfill-open-areas') || 'manual' }} cancel-in-progress: false jobs: translate: name: Translate non-English issues - if: github.event_name == 'issues' || github.event_name == 'workflow_dispatch' + if: > + github.event_name == 'issues' || + (github.event_name == 'workflow_dispatch' && + inputs.backfill_open_areas != true && + inputs.issue_number != '') runs-on: ubuntu-latest # Serialize per-issue control-state RMW; workflow concurrency still cancels # superseded runs, but this queue avoids interleaved comment upserts. @@ -686,7 +695,17 @@ jobs: } validate: - if: github.event_name == 'issues' || github.event_name == 'workflow_dispatch' + # Wait for translate so area heuristics can read the inline English block + # when translation wrote one. Still run if translate was skipped or failed — + # quality closure must not depend on translation success. + needs: translate + if: > + always() && + needs.translate.result != 'cancelled' && + (github.event_name == 'issues' || + (github.event_name == 'workflow_dispatch' && + inputs.backfill_open_areas != true && + inputs.issue_number != '')) runs-on: ubuntu-latest permissions: contents: read @@ -716,10 +735,12 @@ jobs: shouldReopen, shouldEnforceClosure, labelForKind, + detectAreaLabels, + AREA_LABELS, rejectsWorkflowDispatchPullRequest, rejectsWorkflowDispatchNonDefaultBranch, } = require(path.join(process.cwd(), ".github", "scripts", "issue-quality.cjs")); - const { stripTranslationBlock } = require( + const { stripTranslationBlock, splitTranslationBlock } = require( path.join(process.cwd(), ".github", "scripts", "issue-translation.cjs"), ); @@ -741,6 +762,17 @@ jobs: } else { issue_number = context.payload.issue.number; } + + function translationPlainText(block) { + if (!block) return ""; + return String(block) + .replace(//g, " ") + .replace(/<\/?details[^>]*>/gi, "\n") + .replace(/<\/?summary[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/[ \t]+\n/g, "\n") + .trim(); + } const actor = context.actor; const eventType = context.eventName === "issues" ? context.payload.action @@ -776,7 +808,12 @@ jobs: return; } - const issueBody = stripTranslationBlock(issue.body || ""); + const translationSplit = splitTranslationBlock(issue.body || ""); + const issueBody = translationSplit.sourceBody; + const areaHeuristicBody = [ + issueBody, + translationPlainText(translationSplit.block), + ].filter(Boolean).join("\n\n"); // ----------------------------------------------------------------- // Trusted-user exemption @@ -880,6 +917,59 @@ jobs: } } + // ----------------------------------------------------------------- + // Orthogonal area labels (additive only) + // ----------------------------------------------------------------- + async function ensureLabel(name) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + return true; + } catch (err) { + if (err.status !== 404) { + core.warning(`Failed to look up label "${name}": ${err.message || err}`); + return false; + } + } + const meta = AREA_LABELS[name]; + if (!meta) return false; + try { + await github.rest.issues.createLabel({ + owner, repo, name, color: meta.color, description: meta.description, + }); + core.info(`Created label "${name}".`); + return true; + } catch (err) { + if (err.status === 422) { + core.info(`Label "${name}" appeared concurrently; continuing.`); + return true; + } + core.warning(`Failed to create label "${name}": ${err.message || err}`); + return false; + } + } + + const areaLabels = detectAreaLabels({ + title: issue.title, + body: issueBody, + heuristicBody: areaHeuristicBody, + labels, + }); + const areaLabelsToAdd = []; + for (const name of areaLabels.filter((candidate) => !labels.includes(candidate))) { + if (await ensureLabel(name)) areaLabelsToAdd.push(name); + } + if (areaLabelsToAdd.length > 0) { + try { + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: areaLabelsToAdd, + }); + labels.push(...areaLabelsToAdd); + core.info(`Applied area label(s): ${areaLabelsToAdd.join(", ")}`); + } catch (err) { + core.warning(`Failed to apply area labels: ${err.message || err}`); + } + } + // ----------------------------------------------------------------- // Validate // ----------------------------------------------------------------- @@ -1044,3 +1134,131 @@ jobs: "", "Once the report passes the automated checks, it will be reopened automatically unless a maintainer has changed its state.", ].join("\n")); + + backfill-open-areas: + name: Backfill open issue area labels + if: github.event_name == 'workflow_dispatch' && inputs.backfill_open_areas == true + runs-on: ubuntu-latest + permissions: + # Read-only checkout of trusted scripts from the default branch. + contents: read + # Required to list open issues, create missing area labels, and apply them. + issues: write + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Apply area labels to open issues + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const path = require("path"); + const { + detectAreaLabels, + AREA_LABELS, + rejectsWorkflowDispatchNonDefaultBranch, + } = require(path.join(process.cwd(), ".github", "scripts", "issue-quality.cjs")); + const { splitTranslationBlock } = require( + path.join(process.cwd(), ".github", "scripts", "issue-translation.cjs"), + ); + + const nonDefaultBranchFailure = rejectsWorkflowDispatchNonDefaultBranch( + context.eventName, + context.ref, + context.payload.repository?.default_branch, + ); + if (nonDefaultBranchFailure) { + core.setFailed(nonDefaultBranchFailure); + return; + } + + const { owner, repo } = context.repo; + + function translationPlainText(block) { + if (!block) return ""; + return String(block) + .replace(//g, " ") + .replace(/<\/?details[^>]*>/gi, "\n") + .replace(/<\/?summary[^>]*>/gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/[ \t]+\n/g, "\n") + .trim(); + } + + async function ensureLabel(name) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + return true; + } catch (err) { + if (err.status !== 404) { + core.warning(`Failed to look up label "${name}": ${err.message || err}`); + return false; + } + } + const meta = AREA_LABELS[name]; + if (!meta) return false; + try { + await github.rest.issues.createLabel({ + owner, repo, name, color: meta.color, description: meta.description, + }); + core.info(`Created label "${name}".`); + return true; + } catch (err) { + if (err.status === 422) return true; + core.warning(`Failed to create label "${name}": ${err.message || err}`); + return false; + } + } + + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }); + // listForRepo includes PRs; skip pull requests. + const issues = openIssues.filter((item) => !item.pull_request); + core.info(`Backfilling area labels on ${issues.length} open issue(s).`); + + let updated = 0; + for (const issue of issues) { + const labels = (issue.labels || []).map((l) => + typeof l === "string" ? l : l.name, + ); + const translationSplit = splitTranslationBlock(issue.body || ""); + const issueBody = translationSplit.sourceBody; + const areaHeuristicBody = [ + issueBody, + translationPlainText(translationSplit.block), + ].filter(Boolean).join("\n\n"); + const areaLabels = detectAreaLabels({ + title: issue.title, + body: issueBody, + heuristicBody: areaHeuristicBody, + labels, + }); + const toAdd = []; + for (const name of areaLabels.filter((candidate) => !labels.includes(candidate))) { + if (await ensureLabel(name)) toAdd.push(name); + } + if (toAdd.length === 0) continue; + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issue.number, + labels: toAdd, + }); + updated += 1; + core.info(`#${issue.number}: +${toAdd.join(", ")}`); + } catch (err) { + core.warning(`#${issue.number}: failed to label: ${err.message || err}`); + } + // Soft rate-limit pacing for large open queues. + await new Promise((resolve) => setTimeout(resolve, 200)); + } + core.info(`Backfill complete: updated ${updated} / ${issues.length} open issue(s).`); diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index bc285f7a7..c505881c0 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -75,6 +75,15 @@ GitHub Actions intentionally stay small: Open issues labeled `needs-info` with no activity for 14 days get a warning; after 7 more idle days they close as not planned. Any update clears the stale warning. To keep long-lived work open, remove `needs-info` (for example when promoting an issue to `roadmap`). +- **Issue quality** (`.github/workflows/enforce-issue-quality.yml`) validates template structure on + new and edited issues, applies kind labels (`bug`, `enhancement`, `provider-compatibility`, + `documentation`), and adds orthogonal **area** labels from the form Area field plus light + title/Summary heuristics: `provider`, `account-pool`, `catalog`, `gui`, `cli`, `proxy`, + `platform`, `streaming`, `tools`, `install`, and `service`. Kind/process labels stay separate so + you can filter `bug` + `account-pool` without collapsing those axes. Prefer the Area dropdown + over inventing per-provider labels. Area: Documentation does not add a second area tag (the docs + form already seeds `documentation`). Maintainers can re-apply area labels to all open issues with + workflow_dispatch `backfill_open_areas` after the workflow is on the default branch. Use the helper for releases: diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index f5b6374d6..172267589 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -1974,11 +1974,16 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("group: issue-translation-${{ github.event.issue.number }}"); expect(workflow).not.toContain("issue-comment-translation-${{ github.event.comment.id }}"); expect(workflow).toContain("if: github.event_name == 'issue_comment'"); + // translate/validate skip open-area backfill; backfill job is area-only. + expect(workflow).toContain("backfill_open_areas"); + expect(workflow).toContain("backfill-open-areas:"); + expect(workflow).toMatch(/inputs\.backfill_open_areas != true/); + expect(workflow).toMatch(/inputs\.backfill_open_areas == true/); expect(workflow).toMatch( - /translate:\s*\n\s*name: Translate non-English issues\s*\n\s*if: github\.event_name == 'issues' \|\| github\.event_name == 'workflow_dispatch'/, + /translate:\s*\n\s*name: Translate non-English issues\s*\n\s*if: >\s*\n\s*github\.event_name == 'issues' \|\|\s*\n\s*\(github\.event_name == 'workflow_dispatch' &&\s*\n\s*inputs\.backfill_open_areas != true &&\s*\n\s*inputs\.issue_number != ''\)/, ); expect(workflow).toMatch( - /validate:\s*\n\s*if: github\.event_name == 'issues' \|\| github\.event_name == 'workflow_dispatch'/, + /validate:\s*\n\s*# Wait for translate[\s\S]*?\n\s*needs: translate\s*\n\s*if: >\s*\n\s*always\(\) &&\s*\n\s*needs\.translate\.result != 'cancelled' &&\s*\n\s*\(github\.event_name == 'issues' \|\|\s*\n\s*\(github\.event_name == 'workflow_dispatch' &&\s*\n\s*inputs\.backfill_open_areas != true &&\s*\n\s*inputs\.issue_number != ''\)\)/, ); const commentJob = workflow.split(/\n {2}translate-comment:\n/)[1]!.split(/\n {2}[a-zA-Z]/)[0]!; @@ -2024,7 +2029,9 @@ describe("GitHub Actions hardening", () => { expect(beforeJobs).not.toMatch(/^\s*permissions:/m); // Non-cancelling per-issue concurrency at workflow and translate-job scope. - expect(workflow).toContain("group: issue-quality-${{ github.event.issue.number || inputs.issue_number }}"); + expect(workflow).toContain( + "group: issue-quality-${{ github.event.issue.number || inputs.issue_number || (inputs.backfill_open_areas && 'backfill-open-areas') || 'manual' }}", + ); expect(workflow).toContain("group: issue-translation-${{ github.event.issue.number || inputs.issue_number }}"); const workflowConcurrency = workflow.split(/jobs:\s*\n/)[0]!; expect(workflowConcurrency).toMatch(