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/scripts/agents/__tests__/label-contracts.test.js b/scripts/agents/__tests__/label-contracts.test.js index 80f0c6de23..d9f8c3f945 100644 --- a/scripts/agents/__tests__/label-contracts.test.js +++ b/scripts/agents/__tests__/label-contracts.test.js @@ -630,6 +630,172 @@ 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('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. diff --git a/scripts/agents/labeling.agent.js b/scripts/agents/labeling.agent.js index df0ca67eec..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}`); @@ -500,6 +532,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 (!nativeTypeLookupFailed && !nativeTypeLabel && 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). @@ -543,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, @@ -649,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)) { @@ -855,6 +912,8 @@ export { containsKeyword, detectTypeFromBranch, loadIssueTypeMap, + loadIssueTypeLabelMap, + labelForIssueType, fetchNativeIssueTypeLabel, loadCanonicalLabels, loadAliasMap,