Repository navigation
Codex issue labeler #6914
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Codex issue labeler | |
| on: | |
| issues: | |
| types: [opened] | |
| schedule: | |
| - cron: "*/5 * * * *" | |
| workflow_dispatch: | |
| permissions: {} | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event_name == 'issues' && github.run_id || 'processor' }} | |
| cancel-in-progress: false | |
| env: | |
| DUPLICATE_LABEL: duplicate | |
| QUEUE_LABEL: needs-codex-triage | |
| jobs: | |
| enqueue: | |
| if: ${{ github.event_name == 'issues' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Queue issue for Codex triage | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| ISSUE_NUMBER: ${{ github.event.issue.number }} | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_WRITE_TOKEN }} | |
| script: | | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| SET_ISSUE_FIELDS_MUTATION, | |
| TRIAGE_CATALOG_QUERY, | |
| fetchIssuePlanningMetadata, | |
| fetchRepositoryLabelCatalog, | |
| loadTriagePolicy, | |
| resolveLiveTriageCatalog, | |
| } = await import(policyUrl); | |
| const policy = await loadTriagePolicy( | |
| join( | |
| process.env.GITHUB_WORKSPACE, | |
| ".github/codex/issue-triage-policy.json", | |
| ), | |
| ); | |
| const label = process.env.QUEUE_LABEL; | |
| const issueNumber = Number(process.env.ISSUE_NUMBER); | |
| try { | |
| await github.rest.issues.getLabel({ | |
| ...context.repo, | |
| name: label, | |
| }); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| try { | |
| await github.rest.issues.createLabel({ | |
| ...context.repo, | |
| name: label, | |
| color: "D4C5F9", | |
| description: "Queued for automated Codex issue triage", | |
| }); | |
| } catch (createError) { | |
| if (createError.status !== 422) throw createError; | |
| } | |
| } | |
| const catalogResponse = await github.graphql(TRIAGE_CATALOG_QUERY, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const repositoryLabels = await fetchRepositoryLabelCatalog( | |
| github, | |
| context.repo, | |
| ); | |
| const liveCatalog = resolveLiveTriageCatalog({ | |
| issueTypes: catalogResponse.repository.issueTypes.nodes, | |
| fields: catalogResponse.organization.issueFields.nodes, | |
| labels: repositoryLabels, | |
| }, policy); | |
| const issue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| if (!issue) throw new Error(`Issue #${issueNumber} does not exist`); | |
| const currentFields = new Map( | |
| issue.issueFieldValues.nodes | |
| .filter((value) => value?.field?.name && value?.name) | |
| .map((value) => [value.field.name, value.name]), | |
| ); | |
| if (!currentFields.has(liveCatalog.fields.priority.name)) { | |
| const low = liveCatalog.fields.priority.options.find( | |
| (option) => option.name === "Low", | |
| ); | |
| if (!low) throw new Error("Priority Low is unavailable"); | |
| try { | |
| await github.graphql(SET_ISSUE_FIELDS_MUTATION, { | |
| input: { | |
| issueId: issue.id, | |
| issueFields: [{ | |
| fieldId: liveCatalog.fields.priority.id, | |
| singleSelectOptionId: low.id, | |
| confidence: "HIGH", | |
| rationale: "Default while the issue awaits automated triage.", | |
| }], | |
| }, | |
| }); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| core.warning( | |
| `Could not set default Priority Low for issue #${issueNumber}; ` | |
| + `continuing to queue it for triage: ${message}`, | |
| ); | |
| } | |
| } | |
| await github.rest.issues.addLabels({ | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| labels: [label], | |
| }); | |
| collect: | |
| if: ${{ github.event_name != 'issues' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| has_work: ${{ steps.collect.outputs.has_work }} | |
| issue_numbers: ${{ steps.collect.outputs.issue_numbers }} | |
| prompt: ${{ steps.collect.outputs.prompt }} | |
| schema: ${{ steps.collect.outputs.schema }} | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Collect queued issues and Planning Field catalog | |
| id: collect | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_READ_TOKEN }} | |
| script: | | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| buildClassificationPrompt, | |
| buildOutputSchema, | |
| TRIAGE_CATALOG_QUERY, | |
| fetchIssuePlanningMetadata, | |
| fetchRepositoryLabelCatalog, | |
| loadTriagePolicy, | |
| missingLabelsIgnoreCase, | |
| resolveLiveTriageCatalog, | |
| } = await import(policyUrl); | |
| const configPath = join( | |
| process.env.GITHUB_WORKSPACE, | |
| ".github/codex/issue-triage-policy.json", | |
| ); | |
| const policy = await loadTriagePolicy(configPath); | |
| const repositoryLabels = await fetchRepositoryLabelCatalog( | |
| github, | |
| context.repo, | |
| ); | |
| const repositoryLabelNames = repositoryLabels.map( | |
| (label) => label.name, | |
| ); | |
| const requiredLabels = [...policy.labels, process.env.DUPLICATE_LABEL]; | |
| const missingLabels = missingLabelsIgnoreCase( | |
| requiredLabels, | |
| repositoryLabelNames, | |
| ); | |
| if (missingLabels.length > 0) { | |
| throw new Error( | |
| `Required triage labels do not exist in the repository: ${missingLabels.join(", ")}`, | |
| ); | |
| } | |
| const catalogResponse = await github.graphql(TRIAGE_CATALOG_QUERY, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const liveCatalog = resolveLiveTriageCatalog({ | |
| issueTypes: catalogResponse.repository.issueTypes.nodes, | |
| fields: catalogResponse.organization.issueFields.nodes, | |
| labels: repositoryLabels, | |
| }, policy); | |
| const issueNumbers = []; | |
| const pages = github.paginate.iterator( | |
| github.rest.issues.listForRepo, | |
| { | |
| ...context.repo, | |
| state: "all", | |
| labels: process.env.QUEUE_LABEL, | |
| sort: "created", | |
| direction: "asc", | |
| per_page: 100, | |
| }, | |
| ); | |
| for await (const { data: page } of pages) { | |
| for (const issue of page) { | |
| if (issue.pull_request) continue; | |
| issueNumbers.push(issue.number); | |
| if (issueNumbers.length === 10) break; | |
| } | |
| if (issueNumbers.length === 10) break; | |
| } | |
| if (issueNumbers.length === 0) { | |
| core.info("No queued issues found."); | |
| core.setOutput("has_work", "false"); | |
| core.setOutput("issue_numbers", "[]"); | |
| return; | |
| } | |
| const issues = []; | |
| for (const issueNumber of issueNumbers) { | |
| const issue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| if (!issue) throw new Error(`Queued issue #${issueNumber} does not exist`); | |
| const fields = Object.fromEntries( | |
| issue.issueFieldValues.nodes | |
| .filter((value) => value?.field?.name && value?.name) | |
| .map((value) => [value.field.name, value.name]), | |
| ); | |
| issues.push({ | |
| number: issue.number, | |
| title: issue.title, | |
| body: issue.body ?? "", | |
| currentIssueType: issue.issueType?.name ?? null, | |
| currentPriority: fields[liveCatalog.fields.priority.name] ?? null, | |
| currentSecurityStatus: | |
| fields[liveCatalog.fields.securityStatus.name] ?? null, | |
| currentSecurityNature: | |
| fields[liveCatalog.fields.securityNature.name] ?? null, | |
| }); | |
| } | |
| core.setOutput("has_work", "true"); | |
| core.setOutput("issue_numbers", JSON.stringify(issueNumbers)); | |
| core.setOutput( | |
| "prompt", | |
| buildClassificationPrompt(policy, liveCatalog, issues), | |
| ); | |
| core.setOutput( | |
| "schema", | |
| JSON.stringify(buildOutputSchema(policy, issueNumbers)), | |
| ); | |
| core.info(`Prepared ${issues.length} issue(s) for classification.`); | |
| preflight-write: | |
| needs: collect | |
| if: ${{ needs.collect.outputs.has_work == 'true' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Validate write credential and perform idempotent queue probe | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| ISSUE_NUMBERS: ${{ needs.collect.outputs.issue_numbers }} | |
| WRITE_TOKEN: ${{ secrets.CODEX_ISSUE_TRIAGE_WRITE_TOKEN }} | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_WRITE_TOKEN }} | |
| script: | | |
| if (!process.env.WRITE_TOKEN?.trim()) { | |
| throw new Error("CODEX_ISSUE_TRIAGE_WRITE_TOKEN is not configured"); | |
| } | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| TRIAGE_CATALOG_QUERY, | |
| fetchRepositoryLabelCatalog, | |
| loadTriagePolicy, | |
| resolveLiveTriageCatalog, | |
| validateExpectedIssueNumbers, | |
| } = await import(policyUrl); | |
| const policy = await loadTriagePolicy( | |
| join( | |
| process.env.GITHUB_WORKSPACE, | |
| ".github/codex/issue-triage-policy.json", | |
| ), | |
| ); | |
| await github.rest.users.getAuthenticated(); | |
| await github.rest.repos.get(context.repo); | |
| const catalogResponse = await github.graphql(TRIAGE_CATALOG_QUERY, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| if (!catalogResponse.organization || !catalogResponse.repository) { | |
| throw new Error( | |
| "Write credential cannot access the organization Issue Fields " | |
| + "and repository Issue type catalog", | |
| ); | |
| } | |
| const repositoryLabels = await fetchRepositoryLabelCatalog( | |
| github, | |
| context.repo, | |
| ); | |
| resolveLiveTriageCatalog({ | |
| issueTypes: catalogResponse.repository.issueTypes.nodes, | |
| fields: catalogResponse.organization.issueFields.nodes, | |
| labels: repositoryLabels, | |
| }, policy); | |
| let parsedIssueNumbers; | |
| try { | |
| parsedIssueNumbers = JSON.parse(process.env.ISSUE_NUMBERS ?? ""); | |
| } catch (error) { | |
| throw new Error( | |
| "Collected issue numbers are not valid JSON", | |
| { cause: error }, | |
| ); | |
| } | |
| const issueNumbers = validateExpectedIssueNumbers(parsedIssueNumbers); | |
| if (issueNumbers.length === 0) { | |
| throw new Error("Write preflight requires at least one queued issue"); | |
| } | |
| const probeIssueNumber = issueNumbers[0]; | |
| const queueLabel = process.env.QUEUE_LABEL; | |
| const issueResponse = await github.rest.issues.get({ | |
| ...context.repo, | |
| issue_number: probeIssueNumber, | |
| }); | |
| const hasQueueLabel = issueResponse.data.labels.some((label) => ( | |
| (typeof label === "string" ? label : label.name) | |
| ?.toLowerCase() === queueLabel.toLowerCase() | |
| )); | |
| if (!hasQueueLabel) { | |
| throw new Error( | |
| `Issue #${probeIssueNumber} left the triage queue before write preflight`, | |
| ); | |
| } | |
| const writeResponse = await github.rest.issues.addLabels({ | |
| ...context.repo, | |
| issue_number: probeIssueNumber, | |
| labels: [queueLabel], | |
| }); | |
| const writePreservedQueueLabel = writeResponse.data.some((label) => ( | |
| label.name?.toLowerCase() === queueLabel.toLowerCase() | |
| )); | |
| if (!writePreservedQueueLabel) { | |
| throw new Error( | |
| `Issue #${probeIssueNumber} lost queue membership during write preflight`, | |
| ); | |
| } | |
| core.info( | |
| `Write credential and idempotent queue probe succeeded on ` | |
| + `issue #${probeIssueNumber}.`, | |
| ); | |
| classify: | |
| needs: [collect, preflight-write] | |
| if: ${{ needs.collect.outputs.has_work == 'true' && needs.preflight-write.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| permissions: {} | |
| outputs: | |
| result: ${{ steps.codex.outputs.final-message }} | |
| steps: | |
| - name: Classify issues with Codex | |
| id: codex | |
| uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 | |
| with: | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| prompt: ${{ needs.collect.outputs.prompt }} | |
| output-schema: ${{ needs.collect.outputs.schema }} | |
| codex-version: 0.144.6 | |
| model: gpt-5.6-luna | |
| effort: high | |
| permission-profile: ":read-only" | |
| safety-strategy: drop-sudo | |
| # Scheduled runs inherit the actor that last changed the workflow. | |
| # Merge-queued changes therefore run as this GitHub-owned account, | |
| # which has no repository collaborator record for the action to check. | |
| allow-users: github-merge-queue | |
| apply-labels: | |
| needs: [collect, classify] | |
| if: ${{ needs.collect.outputs.has_work == 'true' && needs.classify.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| bug_issue_numbers: ${{ steps.apply.outputs.bug_issue_numbers }} | |
| has_bugs: ${{ steps.apply.outputs.has_bugs }} | |
| security_issue_numbers: ${{ steps.apply.outputs.security_issue_numbers }} | |
| has_security: ${{ steps.apply.outputs.has_security }} | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Validate and apply classifications | |
| id: apply | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| CLASSIFICATION_RESULT: ${{ needs.classify.outputs.result }} | |
| EXPECTED_ISSUE_NUMBERS: ${{ needs.collect.outputs.issue_numbers }} | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_WRITE_TOKEN }} | |
| script: | | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| buildInitialPlanningUpdates, | |
| computeLabelChanges, | |
| includesLabelIgnoreCase, | |
| fetchIssuePlanningMetadata, | |
| fetchRepositoryLabelCatalog, | |
| loadTriagePolicy, | |
| removeIssueLabelIfPresent, | |
| requiresDuplicateTriage, | |
| resolveLiveTriageCatalog, | |
| SET_ISSUE_FIELDS_MUTATION, | |
| TRIAGE_CATALOG_QUERY, | |
| UPDATE_ISSUE_TYPE_MUTATION, | |
| validateClassificationResult, | |
| } = await import(policyUrl); | |
| const policy = await loadTriagePolicy( | |
| join( | |
| process.env.GITHUB_WORKSPACE, | |
| ".github/codex/issue-triage-policy.json", | |
| ), | |
| ); | |
| const catalogResponse = await github.graphql(TRIAGE_CATALOG_QUERY, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const repositoryLabels = await fetchRepositoryLabelCatalog( | |
| github, | |
| context.repo, | |
| ); | |
| const liveCatalog = resolveLiveTriageCatalog({ | |
| issueTypes: catalogResponse.repository.issueTypes.nodes, | |
| fields: catalogResponse.organization.issueFields.nodes, | |
| labels: repositoryLabels, | |
| }, policy); | |
| const expectedIssueNumbers = JSON.parse( | |
| process.env.EXPECTED_ISSUE_NUMBERS, | |
| ); | |
| const classifications = validateClassificationResult( | |
| process.env.CLASSIFICATION_RESULT, | |
| policy, | |
| expectedIssueNumbers, | |
| ); | |
| const bugIssueNumbers = []; | |
| const securityIssueNumbers = []; | |
| const failures = []; | |
| for (const classification of classifications) { | |
| const issueNumber = classification.issueNumber; | |
| try { | |
| const issue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| if (!issue) throw new Error(`Issue #${issueNumber} does not exist`); | |
| const currentLabels = issue.labels.nodes.map((label) => label.name); | |
| if (!includesLabelIgnoreCase(currentLabels, process.env.QUEUE_LABEL)) { | |
| core.info( | |
| `Skipping #${issueNumber}: it is no longer queued for Codex triage.`, | |
| ); | |
| continue; | |
| } | |
| const { add, remove } = computeLabelChanges( | |
| currentLabels, | |
| classification, | |
| policy, | |
| ); | |
| const planningUpdates = buildInitialPlanningUpdates( | |
| classification, | |
| liveCatalog, | |
| ); | |
| // For security candidates this atomically records Triage before | |
| // any enrichment or secondary mutation. | |
| await github.graphql(SET_ISSUE_FIELDS_MUTATION, { | |
| input: { | |
| issueId: issue.id, | |
| issueFields: planningUpdates.issueFields, | |
| }, | |
| }); | |
| await github.graphql(UPDATE_ISSUE_TYPE_MUTATION, { | |
| input: { | |
| issueId: issue.id, | |
| issueTypeId: planningUpdates.issueTypeId, | |
| }, | |
| }); | |
| if (add.length > 0) { | |
| await github.rest.issues.addLabels({ | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| labels: add, | |
| }); | |
| } | |
| for (const label of remove) { | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| label, | |
| ); | |
| } | |
| const reconciledIssue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| if (!reconciledIssue) { | |
| throw new Error( | |
| `Issue #${issueNumber} disappeared after classification was applied`, | |
| ); | |
| } | |
| if (classification.isSecurity) securityIssueNumbers.push(issueNumber); | |
| if (requiresDuplicateTriage(reconciledIssue.issueType)) { | |
| bugIssueNumbers.push(issueNumber); | |
| core.info( | |
| `Reconciled Bug #${issueNumber}; retaining ${process.env.QUEUE_LABEL} for remaining triage.`, | |
| ); | |
| } else if (!classification.isSecurity) { | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| process.env.QUEUE_LABEL, | |
| ); | |
| core.info( | |
| `Reconciled non-security, non-Bug #${issueNumber}; triage is complete.`, | |
| ); | |
| } else { | |
| core.info( | |
| `Reconciled security issue #${issueNumber}; retaining ${process.env.QUEUE_LABEL} for Terra.`, | |
| ); | |
| } | |
| core.info( | |
| `Label changes for #${issueNumber}: added [${add.join(", ")}], removed [${remove.join(", ")}].`, | |
| ); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| failures.push(`#${issueNumber}: ${message}`); | |
| core.error(`Failed to reconcile #${issueNumber}: ${message}`); | |
| } | |
| } | |
| core.setOutput("bug_issue_numbers", JSON.stringify(bugIssueNumbers)); | |
| core.setOutput("has_bugs", bugIssueNumbers.length > 0 ? "true" : "false"); | |
| core.setOutput( | |
| "security_issue_numbers", | |
| JSON.stringify(securityIssueNumbers), | |
| ); | |
| core.setOutput( | |
| "has_security", | |
| securityIssueNumbers.length > 0 ? "true" : "false", | |
| ); | |
| if (failures.length > 0) { | |
| core.setFailed( | |
| `Failed to reconcile ${failures.length} issue(s): ${failures.join("; ")}`, | |
| ); | |
| } | |
| collect-security: | |
| needs: apply-labels | |
| if: ${{ always() && (needs.apply-labels.outputs.has_security == 'true' || needs.apply-labels.outputs.has_bugs == 'true') }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| evidence: ${{ steps.collect.outputs.evidence }} | |
| has_work: ${{ steps.collect.outputs.has_work }} | |
| issue_numbers: ${{ steps.collect.outputs.issue_numbers }} | |
| prompt: ${{ steps.collect.outputs.prompt }} | |
| schema: ${{ steps.collect.outputs.schema }} | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Collect sanitized Security and Quality evidence | |
| id: collect | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| SECURITY_ISSUE_NUMBERS: ${{ needs.apply-labels.outputs.security_issue_numbers }} | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_READ_TOKEN }} | |
| script: | | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| buildSecurityClassificationPrompt, | |
| buildSecurityClassificationSchema, | |
| issueContentFingerprint, | |
| fetchIssuePlanningMetadata, | |
| fetchRepositoryLabelCatalog, | |
| loadTriagePolicy, | |
| resolveLiveTriageCatalog, | |
| sanitizeSecurityApiEvidence, | |
| SECURITY_API_MAX_RESULTS_PER_SOURCE, | |
| selectSecurityEvidenceForIssue, | |
| TRIAGE_CATALOG_QUERY, | |
| } = await import(policyUrl); | |
| const issueNumbers = JSON.parse( | |
| process.env.SECURITY_ISSUE_NUMBERS || "[]", | |
| ); | |
| core.setOutput("issue_numbers", JSON.stringify(issueNumbers)); | |
| if (issueNumbers.length === 0) { | |
| core.setOutput("has_work", "false"); | |
| core.setOutput("evidence", "[]"); | |
| return; | |
| } | |
| const policy = await loadTriagePolicy( | |
| join( | |
| process.env.GITHUB_WORKSPACE, | |
| ".github/codex/issue-triage-policy.json", | |
| ), | |
| ); | |
| const catalogResponse = await github.graphql(TRIAGE_CATALOG_QUERY, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const repositoryLabels = await fetchRepositoryLabelCatalog( | |
| github, | |
| context.repo, | |
| ); | |
| const liveCatalog = resolveLiveTriageCatalog({ | |
| issueTypes: catalogResponse.repository.issueTypes.nodes, | |
| fields: catalogResponse.organization.issueFields.nodes, | |
| labels: repositoryLabels, | |
| }, policy); | |
| const rawEvidence = { | |
| dependabot: [], | |
| codeScanning: [], | |
| secretScanning: [], | |
| advisories: [], | |
| }; | |
| const accessIssues = []; | |
| const SECURITY_API_PER_PAGE = 50; | |
| const SECURITY_API_MAX_PAGES_PER_STATE = 2; | |
| const SECURITY_API_MAX_RESULTS_PER_STATE = | |
| SECURITY_API_PER_PAGE * SECURITY_API_MAX_PAGES_PER_STATE; | |
| async function collectRoute(key, route, variants) { | |
| for (const variant of variants) { | |
| let collectedForState = 0; | |
| try { | |
| const iterator = github.paginate.iterator(route, { | |
| ...context.repo, | |
| ...variant, | |
| per_page: SECURITY_API_PER_PAGE, | |
| }); | |
| let pageCount = 0; | |
| for await (const response of iterator) { | |
| pageCount += 1; | |
| if (!Array.isArray(response.data)) { | |
| throw new Error(`${key} returned a non-array response`); | |
| } | |
| const remaining = Math.min( | |
| SECURITY_API_MAX_RESULTS_PER_STATE - collectedForState, | |
| SECURITY_API_MAX_RESULTS_PER_SOURCE - rawEvidence[key].length, | |
| ); | |
| if (remaining <= 0) return; | |
| const boundedPage = response.data.slice(0, remaining); | |
| rawEvidence[key].push(...boundedPage); | |
| collectedForState += boundedPage.length; | |
| if ( | |
| pageCount >= SECURITY_API_MAX_PAGES_PER_STATE | |
| || collectedForState >= SECURITY_API_MAX_RESULTS_PER_STATE | |
| || rawEvidence[key].length | |
| >= SECURITY_API_MAX_RESULTS_PER_SOURCE | |
| ) { | |
| break; | |
| } | |
| } | |
| } catch (error) { | |
| const status = Number(error?.status); | |
| if ([403, 404, 422].includes(status)) { | |
| accessIssues.push(`${key} unavailable (${status})`); | |
| return; | |
| } | |
| throw error; | |
| } | |
| } | |
| } | |
| await collectRoute( | |
| "dependabot", | |
| "GET /repos/{owner}/{repo}/dependabot/alerts", | |
| ["open", "dismissed", "fixed", "auto_dismissed"].map((state) => ({ state })), | |
| ); | |
| await collectRoute( | |
| "codeScanning", | |
| "GET /repos/{owner}/{repo}/code-scanning/alerts", | |
| ["open", "dismissed", "fixed"].map((state) => ({ state })), | |
| ); | |
| await collectRoute( | |
| "secretScanning", | |
| "GET /repos/{owner}/{repo}/secret-scanning/alerts", | |
| ["open", "resolved"].map((state) => ({ state })), | |
| ); | |
| await collectRoute( | |
| "advisories", | |
| "GET /repos/{owner}/{repo}/security-advisories", | |
| [{}], | |
| ); | |
| const sanitized = sanitizeSecurityApiEvidence(rawEvidence); | |
| const issues = []; | |
| const evidence = []; | |
| for (const issueNumber of issueNumbers) { | |
| const issue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| if (!issue) throw new Error(`Security issue #${issueNumber} does not exist`); | |
| const fields = Object.fromEntries( | |
| issue.issueFieldValues.nodes | |
| .filter((value) => value?.field?.name && value?.name) | |
| .map((value) => [value.field.name, value.name]), | |
| ); | |
| const matched = selectSecurityEvidenceForIssue(issue, sanitized); | |
| const entry = { | |
| issueNumber, | |
| title: issue.title, | |
| body: issue.body ?? "", | |
| currentSecurityStatus: | |
| fields[liveCatalog.fields.securityStatus.name] ?? "Triage", | |
| currentSecurityNature: | |
| fields[liveCatalog.fields.securityNature.name] ?? null, | |
| evidence: matched, | |
| accessIssues, | |
| }; | |
| issues.push(entry); | |
| evidence.push({ | |
| issueNumber, | |
| fingerprint: issueContentFingerprint(issue), | |
| }); | |
| } | |
| core.setOutput("has_work", "true"); | |
| core.setOutput("evidence", JSON.stringify(evidence)); | |
| core.setOutput( | |
| "prompt", | |
| buildSecurityClassificationPrompt(policy, liveCatalog, issues), | |
| ); | |
| core.setOutput( | |
| "schema", | |
| JSON.stringify( | |
| buildSecurityClassificationSchema(policy, issueNumbers), | |
| ), | |
| ); | |
| classify-security: | |
| needs: collect-security | |
| if: ${{ needs.collect-security.outputs.has_work == 'true' }} | |
| runs-on: ubuntu-latest | |
| permissions: {} | |
| outputs: | |
| result: ${{ steps.codex.outputs.final-message }} | |
| steps: | |
| - name: Classify security fields with Codex | |
| id: codex | |
| uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 | |
| with: | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| prompt: ${{ needs.collect-security.outputs.prompt }} | |
| output-schema: ${{ needs.collect-security.outputs.schema }} | |
| codex-version: 0.144.6 | |
| model: gpt-5.6-terra | |
| effort: high | |
| permission-profile: ":read-only" | |
| safety-strategy: drop-sudo | |
| allow-users: github-merge-queue | |
| apply-security: | |
| needs: [apply-labels, collect-security, classify-security] | |
| if: ${{ always() && needs.collect-security.result == 'success' && (needs.apply-labels.outputs.has_security == 'true' || needs.apply-labels.outputs.has_bugs == 'true') }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| bug_issue_numbers: ${{ steps.apply.outputs.bug_issue_numbers }} | |
| has_bugs: ${{ steps.apply.outputs.has_bugs }} | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Apply security decisions and route Bugs | |
| id: apply | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| BUG_ISSUE_NUMBERS: ${{ needs.apply-labels.outputs.bug_issue_numbers }} | |
| SECURITY_EVIDENCE: ${{ needs.collect-security.outputs.evidence }} | |
| SECURITY_HAS_WORK: ${{ needs.collect-security.outputs.has_work }} | |
| SECURITY_ISSUE_NUMBERS: ${{ needs.collect-security.outputs.issue_numbers }} | |
| SECURITY_CLASSIFICATION_STATUS: ${{ needs.classify-security.result }} | |
| SECURITY_RESULT: ${{ needs.classify-security.outputs.result }} | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_WRITE_TOKEN }} | |
| script: | | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| buildSecurityPlanningUpdates, | |
| fetchIssuePlanningMetadata, | |
| fetchRepositoryLabelCatalog, | |
| includesLabelIgnoreCase, | |
| issueContentFingerprint, | |
| loadTriagePolicy, | |
| parseSecurityClassificationForApplication, | |
| removeIssueLabelIfPresent, | |
| requiresDuplicateTriage, | |
| resolveLiveTriageCatalog, | |
| SET_ISSUE_FIELDS_MUTATION, | |
| TRIAGE_CATALOG_QUERY, | |
| } = await import(policyUrl); | |
| const policy = await loadTriagePolicy( | |
| join( | |
| process.env.GITHUB_WORKSPACE, | |
| ".github/codex/issue-triage-policy.json", | |
| ), | |
| ); | |
| const catalogResponse = await github.graphql(TRIAGE_CATALOG_QUERY, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const repositoryLabels = await fetchRepositoryLabelCatalog( | |
| github, | |
| context.repo, | |
| ); | |
| const liveCatalog = resolveLiveTriageCatalog({ | |
| issueTypes: catalogResponse.repository.issueTypes.nodes, | |
| fields: catalogResponse.organization.issueFields.nodes, | |
| labels: repositoryLabels, | |
| }, policy); | |
| const failures = []; | |
| const securityIssueNumbers = new Set( | |
| JSON.parse(process.env.SECURITY_ISSUE_NUMBERS || "[]"), | |
| ); | |
| const appliedSecurityIssueNumbers = new Set(); | |
| if (process.env.SECURITY_HAS_WORK === "true") { | |
| const issueNumbers = [...securityIssueNumbers]; | |
| const evidence = JSON.parse(process.env.SECURITY_EVIDENCE); | |
| const evidenceByIssue = new Map( | |
| evidence.map((entry) => [entry.issueNumber, entry]), | |
| ); | |
| let decisions = []; | |
| try { | |
| decisions = parseSecurityClassificationForApplication({ | |
| hasWork: true, | |
| classificationStatus: process.env.SECURITY_CLASSIFICATION_STATUS, | |
| output: process.env.SECURITY_RESULT, | |
| policy, | |
| expectedIssueNumbers: issueNumbers, | |
| }); | |
| } catch { | |
| const failure = "classification output unavailable or invalid"; | |
| core.error( | |
| `Security classification failed closed; leaving ` | |
| + `${issueNumbers.length} security issue(s) queued.`, | |
| ); | |
| core.setFailed(`Failed security triage: ${failure}`); | |
| return; | |
| } | |
| for (const decision of decisions) { | |
| try { | |
| const issue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| decision.issueNumber, | |
| ); | |
| if (!issue) throw new Error(`Issue #${decision.issueNumber} does not exist`); | |
| const labels = issue.labels.nodes.map((label) => label.name); | |
| if (!includesLabelIgnoreCase(labels, process.env.QUEUE_LABEL)) { | |
| core.info( | |
| `Skipping #${decision.issueNumber}: security triage was manually cancelled.`, | |
| ); | |
| continue; | |
| } | |
| if ( | |
| issueContentFingerprint(issue) | |
| !== evidenceByIssue.get(decision.issueNumber)?.fingerprint | |
| ) { | |
| throw new Error( | |
| `Security issue #${decision.issueNumber} changed after evidence collection`, | |
| ); | |
| } | |
| if (!policy.securityIssueTypes.includes(issue.issueType?.name)) { | |
| throw new Error( | |
| `Security issue #${decision.issueNumber} no longer has Issue type Chore or Bug`, | |
| ); | |
| } | |
| const updates = buildSecurityPlanningUpdates(decision, liveCatalog); | |
| await github.graphql(SET_ISSUE_FIELDS_MUTATION, { | |
| input: { issueId: issue.id, issueFields: updates }, | |
| }); | |
| appliedSecurityIssueNumbers.add(decision.issueNumber); | |
| if (!requiresDuplicateTriage(issue.issueType)) { | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| decision.issueNumber, | |
| process.env.QUEUE_LABEL, | |
| ); | |
| } | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| failures.push(`#${decision.issueNumber}: ${message}`); | |
| core.error(message); | |
| } | |
| } | |
| } | |
| const readyBugs = []; | |
| for (const issueNumber of JSON.parse(process.env.BUG_ISSUE_NUMBERS || "[]")) { | |
| if ( | |
| securityIssueNumbers.has(issueNumber) | |
| && !appliedSecurityIssueNumbers.has(issueNumber) | |
| ) { | |
| core.info( | |
| `Keeping security bug #${issueNumber} queued until security classification succeeds.`, | |
| ); | |
| continue; | |
| } | |
| const issue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| const labels = issue?.labels.nodes.map((label) => label.name) ?? []; | |
| if ( | |
| issue | |
| && includesLabelIgnoreCase(labels, process.env.QUEUE_LABEL) | |
| && requiresDuplicateTriage(issue.issueType) | |
| ) { | |
| readyBugs.push(issueNumber); | |
| } | |
| } | |
| core.setOutput("bug_issue_numbers", JSON.stringify(readyBugs)); | |
| core.setOutput("has_bugs", readyBugs.length > 0 ? "true" : "false"); | |
| if (failures.length > 0) { | |
| core.setFailed(`Failed security triage: ${failures.join("; ")}`); | |
| } | |
| collect-duplicates: | |
| needs: apply-security | |
| if: ${{ always() && needs.apply-security.outputs.has_bugs == 'true' && needs.apply-security.outputs.bug_issue_numbers != '' && needs.apply-security.outputs.bug_issue_numbers != '[]' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| evidence: ${{ steps.collect.outputs.evidence }} | |
| has_issues: ${{ steps.collect.outputs.has_issues }} | |
| prompt: ${{ steps.collect.outputs.prompt }} | |
| schema: ${{ steps.collect.outputs.schema }} | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Search duplicate candidates for reconciled bugs | |
| id: collect | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| BUG_ISSUE_NUMBERS: ${{ needs.apply-security.outputs.bug_issue_numbers }} | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_READ_TOKEN }} | |
| script: | | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| buildDuplicatePrompt, | |
| buildDuplicateSchema, | |
| buildDuplicateSearchQuery, | |
| discoverDuplicateCandidateNumbers, | |
| fetchDuplicateCandidates, | |
| fetchIssuePlanningMetadata, | |
| findDuplicateCommentTarget, | |
| includesLabelIgnoreCase, | |
| issueContentFingerprint, | |
| requiresDuplicateTriage, | |
| } = await import(policyUrl); | |
| const issueNumbers = JSON.parse(process.env.BUG_ISSUE_NUMBERS); | |
| const duplicateIssues = []; | |
| const evidence = []; | |
| const failures = []; | |
| for (const issueNumber of issueNumbers) { | |
| try { | |
| const { data: source } = await github.rest.issues.get({ | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| }); | |
| if (source.pull_request) { | |
| throw new Error(`Queued bug #${issueNumber} is a pull request`); | |
| } | |
| const sourceLabels = source.labels.map((label) => | |
| typeof label === "string" ? label : label.name, | |
| ); | |
| if (!includesLabelIgnoreCase(sourceLabels, process.env.QUEUE_LABEL)) { | |
| core.info( | |
| `Skipping #${issueNumber}: duplicate triage was manually cancelled.`, | |
| ); | |
| continue; | |
| } | |
| const planningIssue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| if (!planningIssue) { | |
| throw new Error(`Queued bug #${issueNumber} does not exist`); | |
| } | |
| if (!requiresDuplicateTriage(planningIssue.issueType)) { | |
| throw new Error( | |
| `Issue #${issueNumber} no longer has Issue type Bug`, | |
| ); | |
| } | |
| const sourceCreatedAt = source.created_at; | |
| const comments = await github.paginate( | |
| github.rest.issues.listComments, | |
| { | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }, | |
| ); | |
| const markedCanonical = findDuplicateCommentTarget(comments); | |
| const query = buildDuplicateSearchQuery( | |
| context.repo.owner, | |
| context.repo.repo, | |
| source, | |
| ); | |
| const queryPrefix = | |
| `repo:${context.repo.owner}/${context.repo.repo} is:issue`; | |
| let selectedCandidateNumbers = []; | |
| if (query !== queryPrefix) { | |
| selectedCandidateNumbers = | |
| await discoverDuplicateCandidateNumbers( | |
| github, | |
| source, | |
| query, | |
| markedCanonical, | |
| ); | |
| } else if (markedCanonical !== null) { | |
| selectedCandidateNumbers = [markedCanonical]; | |
| } | |
| const candidates = await fetchDuplicateCandidates( | |
| github, | |
| context.repo, | |
| source, | |
| selectedCandidateNumbers, | |
| { | |
| duplicateLabel: process.env.DUPLICATE_LABEL, | |
| rejectDuplicateIssueNumbers: | |
| markedCanonical === null ? [] : [markedCanonical], | |
| }, | |
| ); | |
| duplicateIssues.push({ | |
| source: { | |
| number: issueNumber, | |
| title: source.title, | |
| body: source.body ?? "", | |
| state: source.state, | |
| createdAt: sourceCreatedAt, | |
| }, | |
| candidates, | |
| }); | |
| evidence.push({ | |
| issueNumber, | |
| sourceFingerprint: issueContentFingerprint(source), | |
| sourceCreatedAt, | |
| candidates: candidates.map((candidate) => ({ | |
| number: candidate.number, | |
| fingerprint: candidate.fingerprint, | |
| createdAt: candidate.createdAt, | |
| state: candidate.state, | |
| stateReason: candidate.stateReason, | |
| })), | |
| }); | |
| core.info( | |
| `Prepared ${candidates.length} duplicate candidate(s) for bug #${issueNumber}.`, | |
| ); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| failures.push(`#${issueNumber}: ${message}`); | |
| core.error(`Failed duplicate collection for #${issueNumber}: ${message}`); | |
| } | |
| } | |
| core.setOutput("evidence", JSON.stringify(evidence)); | |
| core.setOutput("has_issues", evidence.length > 0 ? "true" : "false"); | |
| core.setOutput("prompt", buildDuplicatePrompt(duplicateIssues)); | |
| core.setOutput( | |
| "schema", | |
| JSON.stringify(buildDuplicateSchema(evidence)), | |
| ); | |
| if (failures.length > 0) { | |
| core.setFailed( | |
| `Failed duplicate collection for ${failures.length} issue(s): ${failures.join("; ")}`, | |
| ); | |
| } | |
| classify-duplicates: | |
| needs: [preflight-write, apply-security, collect-duplicates] | |
| if: ${{ always() && needs.preflight-write.result == 'success' && needs.apply-security.outputs.has_bugs == 'true' && needs.collect-duplicates.outputs.has_issues == 'true' && needs.collect-duplicates.outputs.evidence != '' && needs.collect-duplicates.outputs.prompt != '' && needs.collect-duplicates.outputs.schema != '' }} | |
| runs-on: ubuntu-latest | |
| permissions: {} | |
| outputs: | |
| result: ${{ steps.codex.outputs.final-message }} | |
| steps: | |
| - name: Identify duplicate bugs with Codex | |
| id: codex | |
| uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 | |
| with: | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| prompt: ${{ needs.collect-duplicates.outputs.prompt }} | |
| output-schema: ${{ needs.collect-duplicates.outputs.schema }} | |
| codex-version: 0.144.6 | |
| model: gpt-5.6-luna | |
| effort: high | |
| permission-profile: ":read-only" | |
| safety-strategy: drop-sudo | |
| allow-users: github-merge-queue | |
| apply-duplicates: | |
| needs: [apply-security, collect-duplicates, classify-duplicates] | |
| if: ${{ always() && needs.apply-security.outputs.has_bugs == 'true' && needs.collect-duplicates.outputs.has_issues == 'true' && needs.collect-duplicates.outputs.evidence != '' && needs.classify-duplicates.result == 'success' && needs.classify-duplicates.outputs.result != '' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout trusted workflow code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Validate and apply duplicate decisions | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| DUPLICATE_EVIDENCE: ${{ needs.collect-duplicates.outputs.evidence }} | |
| DUPLICATE_RESULT: ${{ needs.classify-duplicates.outputs.result }} | |
| with: | |
| github-token: ${{ secrets.CODEX_ISSUE_TRIAGE_WRITE_TOKEN }} | |
| script: | | |
| const { join } = await import("node:path"); | |
| const { pathToFileURL } = await import("node:url"); | |
| const policyUrl = pathToFileURL( | |
| join(process.env.GITHUB_WORKSPACE, ".github/scripts/issue-label-policy.mjs"), | |
| ).href; | |
| const { | |
| duplicateCommentBody, | |
| fetchDuplicateCandidates, | |
| fetchIssuePlanningMetadata, | |
| findDuplicateCommentTarget, | |
| includesLabelIgnoreCase, | |
| issueContentFingerprint, | |
| parseAndValidateDuplicateResult, | |
| removeIssueLabelIfPresent, | |
| resolveDuplicateCanonicalTarget, | |
| requiresDuplicateTriage, | |
| validateLiveDuplicateCandidates, | |
| } = await import(policyUrl); | |
| const evidence = JSON.parse(process.env.DUPLICATE_EVIDENCE); | |
| const decisions = parseAndValidateDuplicateResult( | |
| process.env.DUPLICATE_RESULT, | |
| evidence, | |
| ); | |
| const evidenceByIssue = new Map( | |
| evidence.map((candidateSet) => [ | |
| candidateSet.issueNumber, | |
| candidateSet, | |
| ]), | |
| ); | |
| const failures = []; | |
| for (const decision of decisions) { | |
| const issueNumber = decision.issueNumber; | |
| try { | |
| const { data: source } = await github.rest.issues.get({ | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| }); | |
| const sourceLabels = source.labels.map((label) => | |
| typeof label === "string" ? label : label.name, | |
| ); | |
| if (!includesLabelIgnoreCase(sourceLabels, process.env.QUEUE_LABEL)) { | |
| core.info( | |
| `Skipping #${issueNumber}: duplicate triage was manually cancelled.`, | |
| ); | |
| continue; | |
| } | |
| const planningIssue = await fetchIssuePlanningMetadata( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| ); | |
| if (!planningIssue) { | |
| throw new Error(`Queued bug #${issueNumber} does not exist`); | |
| } | |
| if (!requiresDuplicateTriage(planningIssue.issueType)) { | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| process.env.QUEUE_LABEL, | |
| ); | |
| core.info( | |
| `Dequeued #${issueNumber} without duplicate actions because it is no longer Issue type Bug.`, | |
| ); | |
| continue; | |
| } | |
| const comments = await github.paginate( | |
| github.rest.issues.listComments, | |
| { | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }, | |
| ); | |
| const markedCanonical = findDuplicateCommentTarget(comments); | |
| const candidateSet = evidenceByIssue.get(issueNumber); | |
| if ( | |
| issueContentFingerprint(source) | |
| !== candidateSet.sourceFingerprint | |
| ) { | |
| throw new Error( | |
| `Bug #${issueNumber} changed after duplicate collection`, | |
| ); | |
| } | |
| const canonicalNumber = resolveDuplicateCanonicalTarget( | |
| decision.duplicateOf, | |
| markedCanonical, | |
| ); | |
| if (source.state !== "open" && markedCanonical === null) { | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| process.env.QUEUE_LABEL, | |
| ); | |
| core.info( | |
| `Preserved human closure of #${issueNumber} without automated duplicate actions.`, | |
| ); | |
| continue; | |
| } | |
| const candidateNumbers = candidateSet.candidates.map( | |
| (candidate) => candidate.number, | |
| ); | |
| const liveCandidates = await fetchDuplicateCandidates( | |
| github, | |
| context.repo, | |
| source, | |
| candidateNumbers, | |
| { | |
| duplicateLabel: process.env.DUPLICATE_LABEL, | |
| rejectDuplicateIssueNumbers: candidateNumbers, | |
| }, | |
| ); | |
| validateLiveDuplicateCandidates( | |
| liveCandidates, | |
| candidateSet.candidates, | |
| ); | |
| if (source.state !== "open") { | |
| if ( | |
| markedCanonical !== null | |
| && !includesLabelIgnoreCase( | |
| sourceLabels, | |
| process.env.DUPLICATE_LABEL, | |
| ) | |
| ) { | |
| throw new Error( | |
| `Closed marked duplicate #${issueNumber} is missing the ${process.env.DUPLICATE_LABEL} label`, | |
| ); | |
| } | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| process.env.QUEUE_LABEL, | |
| ); | |
| core.info( | |
| `Preserved existing closure of marked duplicate #${issueNumber}.`, | |
| ); | |
| continue; | |
| } | |
| if (canonicalNumber === null) { | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| process.env.QUEUE_LABEL, | |
| ); | |
| core.info(`No high-confidence duplicate found for bug #${issueNumber}.`); | |
| continue; | |
| } | |
| const canonical = liveCandidates.find( | |
| (candidate) => candidate.number === canonicalNumber, | |
| ); | |
| if (!canonical) { | |
| throw new Error( | |
| `Canonical issue #${canonicalNumber} was not validated during duplicate collection`, | |
| ); | |
| } | |
| if (markedCanonical === null) { | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| body: duplicateCommentBody(canonicalNumber), | |
| }); | |
| } | |
| if (!includesLabelIgnoreCase(sourceLabels, process.env.DUPLICATE_LABEL)) { | |
| await github.rest.issues.addLabels({ | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| labels: [process.env.DUPLICATE_LABEL], | |
| }); | |
| } | |
| await github.rest.issues.update({ | |
| ...context.repo, | |
| issue_number: issueNumber, | |
| state: "closed", | |
| state_reason: "not_planned", | |
| }); | |
| await removeIssueLabelIfPresent( | |
| github, | |
| context.repo, | |
| issueNumber, | |
| process.env.QUEUE_LABEL, | |
| ); | |
| core.info( | |
| `Closed bug #${issueNumber} as a duplicate of #${canonicalNumber}.`, | |
| ); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| failures.push(`#${issueNumber}: ${message}`); | |
| core.error(`Failed duplicate triage for #${issueNumber}: ${message}`); | |
| } | |
| } | |
| if (failures.length > 0) { | |
| core.setFailed( | |
| `Failed duplicate triage for ${failures.length} issue(s): ${failures.join("; ")}`, | |
| ); | |
| } |