From 2695787fe0d0929d454dd1fbd25fd5c8df7b8d0b Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Thu, 24 Sep 2026 16:10:12 +0200 Subject: [PATCH 1/4] feat(labeling): take the issue type label from GitHub's issue type (#3545) Section 4 of #3545: on #3554 (created from Linear with issue type Chore) the agent added type:task (default) and type:bug (the body contained the word "issue"); Linear's sync later corrected them. - Issues: the GitHub issue type, read live with issues.get, is the source of truth for type:*. It maps through .github/issue-types.yml (read-only), falling back to type: only when that label is canonical (Decision is not in issue-types.yml). When set, no keyword guess or default is added and it wins reconciliation. - Keyword fallback matches whole words ("decision" no longer means type:ci, "prefix" no longer type:bug) and drops `issue` -> type:bug. A conventional title prefix (`decision:`, `fix(ci):`) outranks keywords. - The workflow also runs on issues typed/untyped, so changing an issue's type updates its label. Simulated against real issues via the live API with every write intercepted: from the creation state, the 5 issues with an issue type get exactly their type's label (the previous agent gave 4 of them type:bug); 0 errors, never more than one type label. --- .github/workflows/labeling-unified.yml | 4 +- CHANGELOG.md | 1 + .../agents/__tests__/label-contracts.test.js | 138 ++++++++++++++ scripts/agents/labeling.agent.js | 179 ++++++++++++++++-- 4 files changed, 300 insertions(+), 22 deletions(-) diff --git a/.github/workflows/labeling-unified.yml b/.github/workflows/labeling-unified.yml index 44c741178f..4998e6b8e5 100644 --- a/.github/workflows/labeling-unified.yml +++ b/.github/workflows/labeling-unified.yml @@ -7,8 +7,10 @@ on: # Issues and discussions react to content, not label changes (#3531): a # bulk relabel queued ~660 runs, and each re-applied default labels from # a stale payload, undoing the manual changes. + # typed/untyped: an issue type change (UI, Linear, issue form) must + # update its type label, which is derived from the issue type (#3545). issues: - types: [opened, edited, reopened] + types: [opened, edited, reopened, typed, untyped] discussion: types: [created, edited, answered] schedule: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3081622550..352fe11d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Issue Type Labels** — An issue's `type:` label now follows its GitHub issue type, so issues created from Linear or with a type set get the right label instead of a guess. ([#3545](https://github.com/lightspeedwp/.github/issues/3545)) - **Label Ownership Reconciled** — Routing, labeler and agent each own one label family, ending add/remove fights. ([PR #3549](https://github.com/lightspeedwp/.github/pull/3549), [#3545](https://github.com/lightspeedwp/.github/issues/3545)) - **Changelog Check Timeouts** — Fixed the changelog check failing at random on slow checkouts; it now reads only the files it needs. (#3520) - **Issue Labelling Floods** — Relabelling issues in bulk no longer queues hundreds of labelling runs or puts back labels that were just removed. ([#3531](https://github.com/lightspeedwp/.github/issues/3531)) diff --git a/scripts/agents/__tests__/label-contracts.test.js b/scripts/agents/__tests__/label-contracts.test.js index 878e9e394a..5fd5e51584 100644 --- a/scripts/agents/__tests__/label-contracts.test.js +++ b/scripts/agents/__tests__/label-contracts.test.js @@ -448,6 +448,144 @@ describe('label governance contracts (#3545)', () => { }); }); + describe('issue type is the source of type:* (#3545 section 4, #3554 case)', () => { + // The live issue carries a GitHub issue type (issues.get); the event + // payload may not, or may predate a type change. + function withIssueType(octokit, typeName) { + octokit.rest.issues.get = async () => ({ + data: { type: typeName ? { name: typeName } : null }, + }); + return octokit; + } + + const LINEAR_BODY = [ + '## Chore Summary', + '', + 'Add five approved conflict-resolution labels to the canonical label taxonomy.', + 'No issue-level label retirement or migration in this request.', + 'The decision and approver must be recorded.', + ].join('\n'); + + test('#3554: Chore issue whose body says "issue" gets only type:chore', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Chore'); + const report = await agent.runLabelingAgent({ + context: issueContext({ + title: '[LABEL-UPDATE-REQUEST] Add conflict-resolution labels to canonical taxonomy', + body: LINEAR_BODY, + }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + expect(octokit.calls.added).not.toContain('type:bug'); + expect(octokit.calls.added).not.toContain('type:task'); + expect(report.errors).toEqual([]); + }); + + test('live issue type wins over a stale payload type', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Chore'); + const context = issueContext({ title: 'Tidy labels', body: '' }); + context.payload.issue.type = { name: 'Bug' }; + await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + }); + + test('existing wrong type labels converge to the issue type', async () => { + const octokit = withIssueType(createMockOctokit(['type:task', 'type:bug']), 'Chore'); + const report = await agent.runLabelingAgent({ + context: issueContext({ title: 'Tidy labels', labels: ['type:task', 'type:bug'] }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + expect(report.errors).toEqual([]); + }); + + test('a type missing from issue-types.yml falls back to its canonical slug', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Decision'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Choose a label policy', body: 'fix the process' }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:decision']); + }); + + test('an issue type with no canonical label falls back to content detection', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Widget'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'fix: broken thing' }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:bug']); + }); + + test('no issue type: the word "issue" alone no longer means type:bug', async () => { + const octokit = withIssueType(createMockOctokit([]), null); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Label taxonomy request', body: LINEAR_BODY }), + github: octokit, + dryRun: false, + }); + // "Chore Summary" is the first whole-word match; "decision" no + // longer matches `ci`, and "issue" no longer maps to type:bug. + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + }); + + test('rerun with the same issue type mutates nothing (idempotent)', async () => { + const first = withIssueType(createMockOctokit([]), 'Chore'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Tidy labels' }), + github: first, + dryRun: false, + }); + const second = withIssueType(createMockOctokit([...first.state.labels]), 'Chore'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Tidy labels', labels: [...first.state.labels] }), + github: second, + dryRun: false, + }); + expect(second.calls.added).toEqual([]); + expect(second.calls.removed).toEqual([]); + }); + + test('keywords match whole words only', () => { + const detect = agent.detectIssueTypeFromContent; + expect(detect('', 'a decision was recorded')).not.toBe('type:ci'); + expect(detect('', 'update the prefix handling')).not.toBe('type:bug'); + expect(detect('', 'see the document')).not.toBe('type:docs'); + expect(detect('ci: pin actions', '')).toBe('type:ci'); + expect(detect('', 'this fixes #12')).toBe('type:bug'); + expect(detect('Report an issue', '')).toBeNull(); + }); + + test('a conventional title prefix outranks body keywords', () => { + const detect = agent.detectIssueTypeFromContent; + const canonical = canonicalLabelSet(); + // #3541: "decision: …" with no issue type. + expect(detect('decision: specs - pick one checklist', 'routine maintenance', canonical)).toBe( + 'type:decision' + ); + expect(detect('fix(ci): pin actions', 'new feature', canonical)).toBe('type:bug'); + expect(detect('review: label consolidation', '', canonical)).toBe('type:review'); + // An unknown prefix falls through to keywords. + expect(detect('wibble: tidy', 'routine maintenance', canonical)).toBe('type:chore'); + }); + + test('every issue-types.yml entry maps to a canonical label', () => { + const canonical = canonicalLabelSet(); + const map = agent.loadIssueTypeLabelMap(path.join(REPO_ROOT, '.github/issue-types.yml')); + expect(map.size).toBeGreaterThan(0); + for (const [name] of map) { + expect([name, agent.labelForIssueType(name, map, canonical)]).toEqual([ + name, + expect.stringMatching(/^type:/), + ]); + } + }); + }); + describe('router/agent convergence (#3525 case)', () => { test('docs/ PR converges to type:docs regardless of order', async () => { // Router-first: router applied type:docs, agent must keep exactly it. diff --git a/scripts/agents/labeling.agent.js b/scripts/agents/labeling.agent.js index 6b7d0d038b..e1f5c69756 100644 --- a/scripts/agents/labeling.agent.js +++ b/scripts/agents/labeling.agent.js @@ -28,6 +28,7 @@ import { buildLabelingReport } from './includes/label-reporting.js'; // Environment configurable paths (fallback to repo defaults) const LABELS_CONFIG = process.env.LABELS_CONFIG || '.github/labels.yml'; const LABELER_RULES = process.env.LABELER_RULES || '.github/labeler.yml'; +const ISSUE_TYPES_CONFIG = process.env.ISSUE_TYPES_CONFIG || '.github/issue-types.yml'; // Enhanced content-based type detection heuristics const KEYWORD_TYPE_MAP = { @@ -37,7 +38,6 @@ const KEYWORD_TYPE_MAP = { 'closes #': 'type:bug', defect: 'type:bug', error: 'type:bug', - issue: 'type:bug', feature: 'type:feature', feat: 'type:feature', enhancement: 'type:feature', @@ -156,20 +156,52 @@ function detectTypeFromBranch(branchName = '') { * @param {string} body - Issue/PR body * @returns {string|null} Canonical type label or null if none matched */ -function detectIssueTypeFromContent(title = '', body = '') { +/** + * Keyword matchers for content-based type detection. Keys match as whole + * words, not substrings, so `ci` no longer matches "decision" or `fix` + * "prefix" (#3545). A boundary is required only on an alphanumeric edge, + * so keys such as "fixes #" still match "fixes #12". + */ +const KEYWORD_MATCHERS = Object.entries(KEYWORD_TYPE_MAP).map(([keyword, typeLabel]) => { + const escaped = keyword.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const lead = /^[a-z0-9]/.test(keyword) ? '(?:^|[^a-z0-9])' : ''; + const trail = /[a-z0-9]$/.test(keyword) ? '(?![a-z0-9])' : ''; + return { keyword, typeLabel, pattern: new RegExp(`${lead}${escaped}${trail}`, 'i') }; +}); + +/** + * Type from a conventional title prefix, e.g. `decision: …`, `fix(ci): …`, + * `[Review] …` is not matched. The prefix is an explicit author signal, so + * it outranks keywords. Resolves through KEYWORD_TYPE_MAP (fix -> type:bug) + * or directly to `type:` when that label is canonical. + */ +function detectTypeFromTitlePrefix(title = '', canonicalSet = null) { + const match = /^\s*([a-z][a-z-]*)(?:\([^)]*\))?!?:\s/i.exec(title || ''); + if (!match) return null; + const prefix = match[1].toLowerCase(); + const direct = `type:${prefix}`; + if (canonicalSet && canonicalSet.has(direct)) return direct; + return KEYWORD_TYPE_MAP[prefix] || null; +} + +function detectIssueTypeFromContent(title = '', body = '', canonicalSet = null) { + const fromPrefix = detectTypeFromTitlePrefix(title, canonicalSet); + if (fromPrefix) { + core.info(`[labeling.agent] Detected type from title prefix: ${fromPrefix}`); + return fromPrefix; + } + // Check title first (higher confidence) - const lowerTitle = title.toLowerCase(); - for (const [keyword, typeLabel] of Object.entries(KEYWORD_TYPE_MAP)) { - if (lowerTitle.includes(keyword.toLowerCase())) { + for (const { keyword, typeLabel, pattern } of KEYWORD_MATCHERS) { + if (pattern.test(title || '')) { core.info(`[labeling.agent] Detected type from title keyword '${keyword}': ${typeLabel}`); return typeLabel; } } // Check body if no match in title - const lowerBody = body.toLowerCase(); - for (const [keyword, typeLabel] of Object.entries(KEYWORD_TYPE_MAP)) { - if (lowerBody.includes(keyword.toLowerCase())) { + for (const { keyword, typeLabel, pattern } of KEYWORD_MATCHERS) { + if (pattern.test(body || '')) { core.info(`[labeling.agent] Detected type from body keyword '${keyword}': ${typeLabel}`); return typeLabel; } @@ -178,6 +210,53 @@ function detectIssueTypeFromContent(title = '', body = '') { return null; } +/** + * Issue type name (lower-case) -> canonical type label, from + * .github/issue-types.yml. That file is the curated mapping; it is read, + * never written, here. + * + * @returns {Map} + */ +function loadIssueTypeLabelMap(configPath = ISSUE_TYPES_CONFIG) { + const data = load(fs.readFileSync(configPath, 'utf8')) || {}; + const map = new Map(); + for (const entry of data.issue_types || []) { + if (entry && entry.name && entry.label) map.set(String(entry.name).toLowerCase(), entry.label); + } + return map; +} + +/** + * Canonical type label for a GitHub issue type, or null. Uses the + * issue-types.yml mapping; for a type that file does not list, falls back + * to `type:` only when that label is canonical. Never invents a + * label that labels.yml does not define. + */ +function labelForIssueType(typeName, issueTypeMap, canonicalSet) { + if (!typeName) return null; + const key = String(typeName).toLowerCase(); + const mapped = issueTypeMap.get(key); + if (mapped && canonicalSet.has(mapped)) return mapped; + const slug = `type:${key.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`; + return canonicalSet.has(slug) ? slug : null; +} + +/** + * The issue's GitHub issue type name, read live (the event payload may + * predate a type change). Falls back to the payload on API failure. + * + * @returns {Promise} + */ +async function fetchIssueTypeName(octokit, owner, repo, number, payloadIssue) { + try { + const { data } = await octokit.rest.issues.get({ owner, repo, issue_number: number }); + return (data && data.type && data.type.name) || null; + } catch (error) { + core.warning(`[labeling.agent] Issue type fetch failed, using event payload: ${error.message}`); + return (payloadIssue && payloadIssue.type && payloadIssue.type.name) || null; + } +} + /** * Removes a label, treating "already absent" (404) as success so a label * removed earlier in the run (or by a concurrent workflow) is never an @@ -244,14 +323,16 @@ const PRIORITY_SEVERITY_ORDER = [ * Deterministically picks the single surviving type label. * * Precedence (no reliance on API/event ordering): - * 1. branchType, when it is live (the router's branch intent); - * 2. contentType, when it is live (fresh content signal); - * 3. otherwise the earliest label in canonical labels.yml order. + * 1. issueTypeLabel, when it is live (the issue's GitHub issue type); + * 2. branchType, when it is live (the router's branch intent); + * 3. contentType, when it is live (fresh content signal); + * 4. otherwise the earliest label in canonical labels.yml order. * * @returns {string|null} Winning label, or null when none are live. */ -function resolveTypeWinner({ liveTypes, branchType, contentType, canonicalOrder }) { +function resolveTypeWinner({ liveTypes, issueTypeLabel, branchType, contentType, canonicalOrder }) { const live = new Set(liveTypes); + if (issueTypeLabel && live.has(issueTypeLabel)) return issueTypeLabel; if (branchType && live.has(branchType)) return branchType; if (contentType && live.has(contentType)) return contentType; for (const name of canonicalOrder) { @@ -375,6 +456,7 @@ async function runLabelingAgent(opts = {}) { // Load canonical configurations with error handling let canonicalSet, aliasMap, labelerRules; + let issueTypeMap = new Map(); try { core.startGroup('Loading canonical configurations'); canonicalSet = loadCanonicalLabels(); @@ -385,6 +467,14 @@ async function runLabelingAgent(opts = {}) { labelerRules = fetchLabelerRules(LABELER_RULES); core.info(`[labeling.agent] Loaded ${Object.keys(labelerRules).length} labeler rules`); + + try { + issueTypeMap = loadIssueTypeLabelMap(ISSUE_TYPES_CONFIG); + core.info(`[labeling.agent] Loaded ${issueTypeMap.size} issue type mappings`); + } catch (error) { + issueTypeMap = new Map(); + core.warning(`[labeling.agent] Issue type mapping unavailable: ${error.message}`); + } core.endGroup(); } catch (error) { core.error(`[labeling.agent] Configuration loading failed: ${error.message}`); @@ -439,6 +529,38 @@ async function runLabelingAgent(opts = {}) { const branchName = isPR ? context.payload.pull_request.head.ref : ''; const branchType = isPR ? detectTypeFromBranch(branchName) : null; + // Issues: the GitHub issue type (set in the UI, by Linear, or by an + // issue form) is the source of truth for type:*. When set, it decides + // the type label and no keyword guess or default is added (#3545). + let issueTypeLabel = null; + if (isIssue) { + const issueTypeName = await fetchIssueTypeName( + octokit, + owner, + repo, + number, + context.payload.issue + ); + issueTypeLabel = labelForIssueType(issueTypeName, issueTypeMap, canonicalSet); + if (issueTypeName) { + core.info( + `[labeling.agent] Issue type: ${issueTypeName} -> ${issueTypeLabel || 'no canonical label'}` + ); + } + if (issueTypeLabel && !knownLabels.has(issueTypeLabel)) { + if (!dryRun) { + await octokit.rest.issues.addLabels({ + owner, + repo, + issue_number: number, + labels: [issueTypeLabel], + }); + } + markAdded(issueTypeLabel); + report.rulesApplied.push(`Issue type: ${issueTypeLabel}`); + } + } + // Step 1: Apply labeler rules (branch patterns and file changes). // On PRs, type:* is router-owned and skipped here so the agent never // writes a family another writer owns (#3545). @@ -477,11 +599,17 @@ async function runLabelingAgent(opts = {}) { core.startGroup('Enforcing one-hot label constraints'); const preTypes = [...knownLabels].filter((l) => l.startsWith('type:')); if (preTypes.length > 1) { - const prestatement = isPR - ? null - : detectIssueTypeFromContent(context.payload.issue.title, context.payload.issue.body); + const prestatement = + isPR || issueTypeLabel + ? null + : detectIssueTypeFromContent( + context.payload.issue.title, + context.payload.issue.body, + canonicalSet + ); const preWinner = resolveTypeWinner({ liveTypes: preTypes, + issueTypeLabel, branchType: isPR ? branchType : null, contentType: !isPR ? prestatement : null, canonicalOrder, @@ -550,7 +678,12 @@ async function runLabelingAgent(opts = {}) { if (![...knownLabels].some((l) => l.startsWith('priority:')) && !dryRun) { markAdded('priority:normal'); } - if (!isPR && ![...knownLabels].some((l) => l.startsWith('type:')) && !dryRun) { + if ( + !isPR && + !issueTypeLabel && + ![...knownLabels].some((l) => l.startsWith('type:')) && + !dryRun + ) { markAdded('type:task'); } } @@ -568,11 +701,11 @@ async function runLabelingAgent(opts = {}) { // double-add one second apart). const liveTypeLabels = [...knownLabels].filter((l) => l.startsWith('type:')); let contentType = null; - if (!isPR && liveTypeLabels.length === 0) { + if (!isPR && !issueTypeLabel && liveTypeLabels.length === 0) { try { const title = context.payload.issue.title; const body = context.payload.issue.body; - contentType = detectIssueTypeFromContent(title, body); + contentType = detectIssueTypeFromContent(title, body, canonicalSet); if (contentType && !knownLabels.has(contentType)) { if (!dryRun) { @@ -590,17 +723,18 @@ async function runLabelingAgent(opts = {}) { core.warning(`[labeling.agent] Content type detection failed: ${error.message}`); report.errors.push(`Content detection error: ${error.message}`); } - } else if (!isPR && liveTypeLabels.length > 0) { + } else if (!isPR && !issueTypeLabel && liveTypeLabels.length > 0) { // Recompute the signal for final precedence even when no add happens. contentType = detectIssueTypeFromContent( context.payload.issue.title, - context.payload.issue.body + context.payload.issue.body, + canonicalSet ); } // Deferred type default for issues: only when content detection found // nothing, so exactly one type is ever introduced per run. - if (!isPR && ![...knownLabels].some((l) => l.startsWith('type:'))) { + if (!isPR && !issueTypeLabel && ![...knownLabels].some((l) => l.startsWith('type:'))) { try { await applyDefaultType({ github: octokit, @@ -661,6 +795,7 @@ async function runLabelingAgent(opts = {}) { if (liveTypes.length > 1) { const winner = resolveTypeWinner({ liveTypes, + issueTypeLabel, branchType: isPR ? branchType : null, contentType: !isPR ? contentType : null, canonicalOrder, @@ -780,6 +915,8 @@ export { fetchLiveLabels, removeLabelSafe, resolveTypeWinner, + labelForIssueType, + loadIssueTypeLabelMap, resolvePriorityWinner, standardizeLabelsOnItem, BRANCH_PREFIX_TYPE_MAP, From 1f298872149f94f0cddc88c577113e2b35892d65 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Thu, 24 Sep 2026 16:29:11 +0200 Subject: [PATCH 2/4] fix(labeling): re-derive the type label when an issue loses its type (#3545) On issues untyped the label derived from the removed type stayed live. The webhook does not say which type was removed, so the agent now clears the issue's type labels on untyped and re-derives one (title prefix, keywords, then the default), as for a new issue. Other events on an issue without a type keep its label. Simulated on #3554's real data: untyped clears type:chore and sets one derived label; typed Chore -> Bug converges to type:bug; an unchanged edit writes nothing. --- .../agents/__tests__/label-contracts.test.js | 28 +++++++++++++++++++ scripts/agents/labeling.agent.js | 20 +++++++++++++ 2 files changed, 48 insertions(+) diff --git a/scripts/agents/__tests__/label-contracts.test.js b/scripts/agents/__tests__/label-contracts.test.js index 5fd5e51584..05a02ea9ab 100644 --- a/scripts/agents/__tests__/label-contracts.test.js +++ b/scripts/agents/__tests__/label-contracts.test.js @@ -550,6 +550,34 @@ describe('label governance contracts (#3545)', () => { expect(second.calls.removed).toEqual([]); }); + test('removing the issue type (untyped) re-derives the type label', async () => { + const octokit = withIssueType(createMockOctokit(['type:chore']), null); + const context = issueContext({ title: 'Tidy labels', labels: ['type:chore'] }); + context.payload.action = 'untyped'; + const report = await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + // No title prefix or keyword: the default replaces the stale label. + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:task']); + expect(octokit.calls.removed).toContain('type:chore'); + expect(report.errors).toEqual([]); + }); + + test('untyped re-derives from the title prefix when there is one', async () => { + const octokit = withIssueType(createMockOctokit(['type:chore']), null); + const context = issueContext({ title: 'fix: broken thing', labels: ['type:chore'] }); + context.payload.action = 'untyped'; + await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:bug']); + }); + + test('other events on an issue without a type keep its existing type label', async () => { + const octokit = withIssueType(createMockOctokit(['type:chore']), null); + const context = issueContext({ title: 'fix: broken thing', labels: ['type:chore'] }); + context.payload.action = 'edited'; + await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + expect(octokit.calls.removed).toEqual([]); + }); + test('keywords match whole words only', () => { const detect = agent.detectIssueTypeFromContent; expect(detect('', 'a decision was recorded')).not.toBe('type:ci'); diff --git a/scripts/agents/labeling.agent.js b/scripts/agents/labeling.agent.js index e1f5c69756..8350d37588 100644 --- a/scripts/agents/labeling.agent.js +++ b/scripts/agents/labeling.agent.js @@ -559,6 +559,26 @@ async function runLabelingAgent(opts = {}) { markAdded(issueTypeLabel); report.rulesApplied.push(`Issue type: ${issueTypeLabel}`); } + + // The issue type was removed (`untyped`). Its type label was derived + // from it, and the webhook does not say which type was removed, so + // clear the type labels and re-derive one below (title prefix, + // keywords, then the default), exactly as for a new issue. + if (!issueTypeName && context.payload.action === 'untyped') { + const staleTypes = [...knownLabels].filter((l) => l.startsWith('type:')); + for (const label of staleTypes) { + if (!dryRun) { + await removeLabelSafe(octokit, owner, repo, number, label); + } + markRemoved(label); + } + if (staleTypes.length > 0) { + core.info( + `[labeling.agent] Issue type removed; cleared ${staleTypes.join(', ')} to re-derive the type` + ); + report.rulesApplied.push(`Issue type removed: cleared ${staleTypes.join(', ')}`); + } + } } // Step 1: Apply labeler rules (branch patterns and file changes). From 38c34a3ebae6f210a66592713df5230bf6d730d7 Mon Sep 17 00:00:00 2001 From: Chris Vancoillie Date: Thu, 24 Sep 2026 16:29:11 +0200 Subject: [PATCH 3/4] fix(labeling): re-derive the type label when an issue loses its type (#3545) On issues untyped the label derived from the removed type stayed live. The webhook does not say which type was removed, so the agent now clears the issue's type labels on untyped and re-derives one (title prefix, keywords, then the default), as for a new issue. Other events on an issue without a type keep its label. Simulated on #3554's real data: untyped clears type:chore and sets one derived label; typed Chore -> Bug converges to type:bug; an unchanged edit writes nothing. --- .../agents/__tests__/label-contracts.test.js | 168 +++++++++++++++++- scripts/agents/labeling.agent.js | 20 +++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/scripts/agents/__tests__/label-contracts.test.js b/scripts/agents/__tests__/label-contracts.test.js index 80f0c6de23..d2e4f57ee9 100644 --- a/scripts/agents/__tests__/label-contracts.test.js +++ b/scripts/agents/__tests__/label-contracts.test.js @@ -630,7 +630,173 @@ describe('label governance contracts (#3545)', () => { }); }); - describe('router/agent convergence (#3525 case)', () => { + describe('issue type is the source of type:* (#3545 section 4, #3554 case)', () => { + // The live issue carries a GitHub issue type (issues.get); the event + // payload may not, or may predate a type change. + function withIssueType(octokit, typeName) { + octokit.rest.issues.get = async () => ({ + data: { type: typeName ? { name: typeName } : null }, + }); + return octokit; + } + + const LINEAR_BODY = [ + '## Chore Summary', + '', + 'Add five approved conflict-resolution labels to the canonical label taxonomy.', + 'No issue-level label retirement or migration in this request.', + 'The decision and approver must be recorded.', + ].join('\n'); + + test('#3554: Chore issue whose body says "issue" gets only type:chore', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Chore'); + const report = await agent.runLabelingAgent({ + context: issueContext({ + title: '[LABEL-UPDATE-REQUEST] Add conflict-resolution labels to canonical taxonomy', + body: LINEAR_BODY, + }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + expect(octokit.calls.added).not.toContain('type:bug'); + expect(octokit.calls.added).not.toContain('type:task'); + expect(report.errors).toEqual([]); + }); + + test('live issue type wins over a stale payload type', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Chore'); + const context = issueContext({ title: 'Tidy labels', body: '' }); + context.payload.issue.type = { name: 'Bug' }; + await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + }); + + test('existing wrong type labels converge to the issue type', async () => { + const octokit = withIssueType(createMockOctokit(['type:task', 'type:bug']), 'Chore'); + const report = await agent.runLabelingAgent({ + context: issueContext({ title: 'Tidy labels', labels: ['type:task', 'type:bug'] }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + expect(report.errors).toEqual([]); + }); + + test('a type missing from issue-types.yml falls back to its canonical slug', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Decision'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Choose a label policy', body: 'fix the process' }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:decision']); + }); + + test('an issue type with no canonical label falls back to content detection', async () => { + const octokit = withIssueType(createMockOctokit([]), 'Widget'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'fix: broken thing' }), + github: octokit, + dryRun: false, + }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:bug']); + }); + + test('no issue type: the word "issue" alone no longer means type:bug', async () => { + const octokit = withIssueType(createMockOctokit([]), null); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Label taxonomy request', body: LINEAR_BODY }), + github: octokit, + dryRun: false, + }); + // "Chore Summary" is the first whole-word match; "decision" no + // longer matches `ci`, and "issue" no longer maps to type:bug. + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + }); + + test('rerun with the same issue type mutates nothing (idempotent)', async () => { + const first = withIssueType(createMockOctokit([]), 'Chore'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Tidy labels' }), + github: first, + dryRun: false, + }); + const second = withIssueType(createMockOctokit([...first.state.labels]), 'Chore'); + await agent.runLabelingAgent({ + context: issueContext({ title: 'Tidy labels', labels: [...first.state.labels] }), + github: second, + dryRun: false, + }); + expect(second.calls.added).toEqual([]); + expect(second.calls.removed).toEqual([]); + }); + + test('removing the issue type (untyped) re-derives the type label', async () => { + const octokit = withIssueType(createMockOctokit(['type:chore']), null); + const context = issueContext({ title: 'Tidy labels', labels: ['type:chore'] }); + context.payload.action = 'untyped'; + const report = await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + // No title prefix or keyword: the default replaces the stale label. + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:task']); + expect(octokit.calls.removed).toContain('type:chore'); + expect(report.errors).toEqual([]); + }); + + test('untyped re-derives from the title prefix when there is one', async () => { + const octokit = withIssueType(createMockOctokit(['type:chore']), null); + const context = issueContext({ title: 'fix: broken thing', labels: ['type:chore'] }); + context.payload.action = 'untyped'; + await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:bug']); + }); + + test('other events on an issue without a type keep its existing type label', async () => { + const octokit = withIssueType(createMockOctokit(['type:chore']), null); + const context = issueContext({ title: 'fix: broken thing', labels: ['type:chore'] }); + context.payload.action = 'edited'; + await agent.runLabelingAgent({ context, github: octokit, dryRun: false }); + expect(octokit.state.labels.filter((l) => l.startsWith('type:'))).toEqual(['type:chore']); + expect(octokit.calls.removed).toEqual([]); + }); + + test('keywords match whole words only', () => { + const detect = agent.detectIssueTypeFromContent; + expect(detect('', 'a decision was recorded')).not.toBe('type:ci'); + expect(detect('', 'update the prefix handling')).not.toBe('type:bug'); + expect(detect('', 'see the document')).not.toBe('type:docs'); + expect(detect('ci: pin actions', '')).toBe('type:ci'); + expect(detect('', 'this fixes #12')).toBe('type:bug'); + expect(detect('Report an issue', '')).toBeNull(); + }); + + test('a conventional title prefix outranks body keywords', () => { + const detect = agent.detectIssueTypeFromContent; + const canonical = canonicalLabelSet(); + // #3541: "decision: …" with no issue type. + expect(detect('decision: specs - pick one checklist', 'routine maintenance', canonical)).toBe( + 'type:decision' + ); + expect(detect('fix(ci): pin actions', 'new feature', canonical)).toBe('type:bug'); + expect(detect('review: label consolidation', '', canonical)).toBe('type:review'); + // An unknown prefix falls through to keywords. + expect(detect('wibble: tidy', 'routine maintenance', canonical)).toBe('type:chore'); + }); + + test('every issue-types.yml entry maps to a canonical label', () => { + const canonical = canonicalLabelSet(); + const map = agent.loadIssueTypeLabelMap(path.join(REPO_ROOT, '.github/issue-types.yml')); + expect(map.size).toBeGreaterThan(0); + for (const [name] of map) { + expect([name, agent.labelForIssueType(name, map, canonical)]).toEqual([ + name, + expect.stringMatching(/^type:/), + ]); + } + }); + }); + +> describe('router/agent convergence (#3525 case)', () => { test('docs/ PR converges to type:docs regardless of order', async () => { // Router-first: router applied type:docs, agent must keep exactly it. const routerFirst = createMockOctokit(routeBranch('docs/spec-x')); diff --git a/scripts/agents/labeling.agent.js b/scripts/agents/labeling.agent.js index df0ca67eec..64e9fb32f4 100644 --- a/scripts/agents/labeling.agent.js +++ b/scripts/agents/labeling.agent.js @@ -500,6 +500,26 @@ async function runLabelingAgent(opts = {}) { core.warning(`[labeling.agent] Native issue type lookup failed: ${error.message}`); report.errors.push(`Native issue type lookup error: ${error.message}`); } + + // The issue type was removed (`untyped`). Its type label was derived + // from it, and the webhook does not say which type was removed, so + // clear the type labels and re-derive one below (title prefix, + // keywords, then the default), exactly as for a new issue. + if (!issueTypeName && context.payload.action === 'untyped') { + const staleTypes = [...knownLabels].filter((l) => l.startsWith('type:')); + for (const label of staleTypes) { + if (!dryRun) { + await removeLabelSafe(octokit, owner, repo, number, label); + } + markRemoved(label); + } + if (staleTypes.length > 0) { + core.info( + `[labeling.agent] Issue type removed; cleared ${staleTypes.join(', ')} to re-derive the type` + ); + report.rulesApplied.push(`Issue type removed: cleared ${staleTypes.join(', ')}`); + } + } } // Step 1: Apply labeler rules (branch patterns and file changes). From fe9967622434edb0dc0e7b7f806b0285b88a4bb1 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 25 Sep 2026 09:36:31 +0200 Subject: [PATCH 4/4] fix(labeling): align untyped recovery with parent --- .../agents/__tests__/label-contracts.test.js | 2 +- scripts/agents/labeling.agent.js | 53 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/scripts/agents/__tests__/label-contracts.test.js b/scripts/agents/__tests__/label-contracts.test.js index d2e4f57ee9..d9f8c3f945 100644 --- a/scripts/agents/__tests__/label-contracts.test.js +++ b/scripts/agents/__tests__/label-contracts.test.js @@ -796,7 +796,7 @@ describe('label governance contracts (#3545)', () => { }); }); -> describe('router/agent convergence (#3525 case)', () => { + describe('router/agent convergence (#3525 case)', () => { test('docs/ PR converges to type:docs regardless of order', async () => { // Router-first: router applied type:docs, agent must keep exactly it. const routerFirst = createMockOctokit(routeBranch('docs/spec-x')); diff --git a/scripts/agents/labeling.agent.js b/scripts/agents/labeling.agent.js index 64e9fb32f4..23052524b8 100644 --- a/scripts/agents/labeling.agent.js +++ b/scripts/agents/labeling.agent.js @@ -35,6 +35,7 @@ const KEYWORD_TYPE_MAP = { bug: 'type:bug', defect: 'type:bug', error: 'type:bug', + fix: 'type:bug', 'fix:': 'type:bug', fixes: 'type:bug', 'closes #': 'type:bug', @@ -45,6 +46,7 @@ const KEYWORD_TYPE_MAP = { 'new feature': 'type:feature', improvement: 'type:feature', docs: 'type:docs', + doc: 'type:docs', documentation: 'type:docs', readme: 'type:docs', guide: 'type:docs', @@ -146,7 +148,21 @@ function loadIssueTypeMap(path = ISSUE_TYPES_CONFIG) { ); } -async function fetchNativeIssueTypeLabel(octokit, owner, repo, number, issueTypeMap) { +function loadIssueTypeLabelMap(path = ISSUE_TYPES_CONFIG) { + return loadIssueTypeMap(path); +} + +function labelForIssueType(typeName, issueTypeMap, canonicalSet) { + if (!typeName) return null; + const key = String(typeName).toLowerCase(); + const mapped = issueTypeMap.get(key); + if (mapped && (!canonicalSet || canonicalSet.has(mapped))) return mapped; + + const slug = `type:${key.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`; + return canonicalSet && canonicalSet.has(slug) ? slug : null; +} + +async function fetchNativeIssueTypeLabel(octokit, owner, repo, number, issueTypeMap, canonicalSet) { const { data } = await octokit.rest.issues.get({ owner, repo, @@ -158,7 +174,7 @@ async function fetchNativeIssueTypeLabel(octokit, owner, repo, number, issueType null; if (!nativeType) return null; - const label = issueTypeMap.get(String(nativeType).toLowerCase()) || null; + const label = labelForIssueType(nativeType, issueTypeMap, canonicalSet); if (!label) { core.warning(`[labeling.agent] Native issue type '${nativeType}' is not mapped`); } @@ -198,7 +214,22 @@ function containsKeyword(text, keyword) { * @param {string} body - Issue/PR body * @returns {string|null} Canonical type label or null if none matched */ -function detectIssueTypeFromContent(title = '', body = '') { +function detectTypeFromTitlePrefix(title = '', canonicalSet = null) { + const match = /^\s*([a-z][a-z-]*)(?:\([^)]*\))?!?:\s/i.exec(title || ''); + if (!match) return null; + const prefix = match[1].toLowerCase(); + const direct = `type:${prefix}`; + if (canonicalSet && canonicalSet.has(direct)) return direct; + return KEYWORD_TYPE_MAP[prefix] || null; +} + +function detectIssueTypeFromContent(title = '', body = '', canonicalSet = null) { + const fromPrefix = detectTypeFromTitlePrefix(title, canonicalSet); + if (fromPrefix) { + core.info(`[labeling.agent] Detected type from title prefix: ${fromPrefix}`); + return fromPrefix; + } + for (const [keyword, typeLabel] of Object.entries(KEYWORD_TYPE_MAP)) { if (containsKeyword(title, keyword)) { core.info(`[labeling.agent] Detected type from title keyword '${keyword}': ${typeLabel}`); @@ -490,7 +521,8 @@ async function runLabelingAgent(opts = {}) { owner, repo, number, - issueTypeMap + issueTypeMap, + canonicalSet ); if (nativeTypeLabel) { core.info(`[labeling.agent] Using native issue type label: ${nativeTypeLabel}`); @@ -505,7 +537,7 @@ async function runLabelingAgent(opts = {}) { // from it, and the webhook does not say which type was removed, so // clear the type labels and re-derive one below (title prefix, // keywords, then the default), exactly as for a new issue. - if (!issueTypeName && context.payload.action === 'untyped') { + if (!nativeTypeLookupFailed && !nativeTypeLabel && context.payload.action === 'untyped') { const staleTypes = [...knownLabels].filter((l) => l.startsWith('type:')); for (const label of staleTypes) { if (!dryRun) { @@ -563,7 +595,11 @@ async function runLabelingAgent(opts = {}) { const prestatement = isPR || nativeTypeLabel || nativeTypeLookupFailed ? null - : detectIssueTypeFromContent(context.payload.issue.title, context.payload.issue.body); + : detectIssueTypeFromContent( + context.payload.issue.title, + context.payload.issue.body, + canonicalSet + ); const preWinner = resolveTypeWinner({ liveTypes: preTypes, branchType: isPR ? branchType : null, @@ -669,7 +705,8 @@ async function runLabelingAgent(opts = {}) { try { contentType = detectIssueTypeFromContent( context.payload.issue.title, - context.payload.issue.body + context.payload.issue.body, + canonicalSet ); if (contentType && !knownLabels.has(contentType)) { @@ -875,6 +912,8 @@ export { containsKeyword, detectTypeFromBranch, loadIssueTypeMap, + loadIssueTypeLabelMap, + labelForIssueType, fetchNativeIssueTypeLabel, loadCanonicalLabels, loadAliasMap,