Skip to content

Codex issue labeler #6914

Codex issue labeler

Codex issue labeler #6914

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("; ")}`,
);
}