Repository navigation
Use repository labels for work-queue Issue status #67223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
66a6097
3083c72
e832201
3664808
ae3d601
c02d3f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,80 +9,54 @@ const MAX_PROJECTION_TARGETS = 25; | |
| const MAX_PROJECTION_MUTATIONS = 50; | ||
| const LABELS_PAGE_SIZE = 100; | ||
| const STATUSES = ["Queued", "Blocked", "Assigned", "Running", "Verifying", "Needs review", "Done", "Needs attention", "Cancelled"]; | ||
| const FIELD_SELECTION = `__typename ... on IssueFieldText { id name } ... on IssueFieldNumber { id name } | ||
| ... on IssueFieldDate { id name } ... on IssueFieldMultiSelect { id name } | ||
| ... on IssueFieldSingleSelect { id name options { id name } }`; | ||
| const VALUE_SELECTION = `... on IssueFieldSingleSelectValue { optionId name field { ... on IssueFieldSingleSelect { id } } }`; | ||
| const ISSUE_SELECTION = `id databaseId number state viewerCanSetFields repository { id databaseId nameWithOwner } author { login } | ||
| labels(first:${LABELS_PAGE_SIZE}) { nodes { id name } pageInfo { hasNextPage endCursor } } | ||
| issueFieldValues(first:${MAX_PROJECTION_TARGETS}) { nodes { ${VALUE_SELECTION} } pageInfo { hasNextPage endCursor } }`; | ||
| const ISSUE_SELECTION = `id databaseId number state repository { id databaseId nameWithOwner } author { login } | ||
| labels(first:${LABELS_PAGE_SIZE}) { nodes { id name } pageInfo { hasNextPage endCursor } }`; | ||
| const QUEUE_LABEL_COLOR = "7057FF"; | ||
|
|
||
| async function ensureTrackingLabel(github, repositoryId, label, name) { | ||
| async function ensureLabel(github, repositoryId, label, name) { | ||
| if (label) { | ||
| if (label.name !== name || typeof label.id !== "string") throw queueError("projection_label_pending", "tracking label identity is invalid"); | ||
| if (label.color?.toUpperCase() !== QUEUE_LABEL_COLOR) { | ||
| const response = await github.graphql("mutation WorkQueueLabelColor($input:UpdateLabelInput!) { updateLabel(input:$input) { label { id name color } } }", { | ||
| input: { id: label.id, color: QUEUE_LABEL_COLOR }, | ||
| request: { retries: 0, timeout: 15000 }, | ||
| }); | ||
| if (response?.updateLabel?.label?.id !== label.id || response.updateLabel.label.name !== name || response.updateLabel.label.color?.toUpperCase() !== QUEUE_LABEL_COLOR) | ||
| throw queueError("projection_label_pending", "work queue label color update is uncertain"); | ||
| } | ||
| return label.id; | ||
| } | ||
| const response = await github.graphql("mutation WorkQueueTrackingLabel($input:CreateLabelInput!) { createLabel(input:$input) { label { id name } } }", { | ||
| input: { repositoryId, name, color: "808080", description: "Git-backed Work queue tracking" }, | ||
| const response = await github.graphql("mutation WorkQueueTrackingLabel($input:CreateLabelInput!) { createLabel(input:$input) { label { id name color } } }", { | ||
| input: { repositoryId, name, color: QUEUE_LABEL_COLOR, description: "Git-backed Work queue" }, | ||
| request: { retries: 0, timeout: 15000 }, | ||
| }); | ||
| if (!response?.createLabel?.label?.id || response.createLabel.label.name !== name) throw queueError("projection_label_pending", "tracking label creation is uncertain"); | ||
| if (!response?.createLabel?.label?.id || response.createLabel.label.name !== name || response.createLabel.label.color?.toUpperCase() !== QUEUE_LABEL_COLOR) | ||
| throw queueError("projection_label_pending", "work queue label creation is uncertain"); | ||
| return response.createLabel.label.id; | ||
| } | ||
|
|
||
| function statusLabelName(config, status) { | ||
| return `${config.label}: ${status}`; | ||
| } | ||
|
|
||
| async function ensureStatusLabel(github, repositoryId, config, status) { | ||
| const name = statusLabelName(config, status); | ||
| const response = await github.graphql("query WorkQueueStatusLabel($repositoryId:ID!,$name:String!) { node(id:$repositoryId) { ... on Repository { id label(name:$name) { id name color } } } }", { repositoryId, name }); | ||
| if (response?.node?.id !== repositoryId) throw queueError("projection_label_pending", "status label target is inaccessible"); | ||
| return ensureLabel(github, repositoryId, response.node.label, name); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Implemented duplicate-label recovery: on an already-taken response, the projector re-queries the status label and verifies/recolors it. Added a concurrent provisioning test. Fixed in |
||
| } | ||
|
|
||
| async function discoverTarget(github, repository, config, firstPage = undefined) { | ||
| const [owner, name] = repository.split("/"); | ||
| /** @type {string | null} */ | ||
| let cursor = null; | ||
| let field; | ||
| let repositoryId; | ||
| let label; | ||
| try { | ||
| for (let page = 0; page < 16; page++) { | ||
| const fieldQuery = config["status-field"] ? `issueFields(first:${MAX_PROJECTION_TARGETS},after:$cursor) { nodes { ${FIELD_SELECTION} } pageInfo { hasNextPage endCursor } }` : ""; | ||
| const result = | ||
| page === 0 && firstPage | ||
| ? { repository: firstPage } | ||
| : await github.graphql( | ||
| `query WorkQueueIssueTarget($owner:String!,$name:String!,$label:String!${config["status-field"] ? ",$cursor:String" : ""}) { | ||
| repository(owner:$owner,name:$name) { id nameWithOwner label(name:$label) { id name } ${fieldQuery} } | ||
| }`, | ||
| { owner, name, label: config.label, ...(config["status-field"] ? { cursor } : {}) } | ||
| ); | ||
| const> | ||
| if (!target || target.nameWithOwner !== repository) throw queueError("projection_target_unavailable", "Issue target is inaccessible or transferred"); | ||
| repositoryId = target.id; | ||
| label = target.label; | ||
| if (!config["status-field"]) return { repositoryId, labelId: await ensureTrackingLabel(github, repositoryId, label, config.label) }; | ||
| const fields = target.issueFields; | ||
| if (!Array.isArray(fields?.nodes)) throw queueError("projection_field_pending", "native organization Issue fields are inaccessible"); | ||
| for (const node of fields.nodes) { | ||
| if (node.name !== config["status-field"]) continue; | ||
| if (field || node.__typename !== "IssueFieldSingleSelect" || !Array.isArray(node.options)) throw queueError("projection_field_pending", "status field is ambiguous or not single-select"); | ||
| const options = new Map(); | ||
| for (const option of node.options) { | ||
| if (options.has(option.name) || typeof option.id !== "string") throw queueError("projection_field_pending", "status options are ambiguous"); | ||
| options.set(option.name, option.id); | ||
| } | ||
| if (STATUSES.some(status => !options.has(status))) throw queueError("projection_field_pending", "status field is missing required options"); | ||
| field = { id: node.id, options }; | ||
| } | ||
| if (fields.pageInfo?.hasNextPage === false) { | ||
| if (!field) throw queueError("projection_field_pending", "configured native status field is missing or inaccessible"); | ||
| return { repositoryId, labelId: await ensureTrackingLabel(github, repositoryId, label, config.label), field }; | ||
| } | ||
| const next = fields.pageInfo?.endCursor; | ||
| if (typeof next !== "string" || !next || next === cursor) throw queueError("projection_field_pending", "status field discovery is incomplete"); | ||
| cursor = next; | ||
| } | ||
| throw queueError("projection_field_pending", "status field discovery pagination exhausted"); | ||
| } catch (error) { | ||
| if (error.code !== "projection_field_pending") throw error; | ||
| return { repositoryId, labelId: await ensureTrackingLabel(github, repositoryId, label, config.label), fieldPending: error.message }; | ||
| } | ||
| const result = firstPage | ||
| ? { repository: firstPage } | ||
| : await github.graphql("query WorkQueueIssueTarget($owner:String!,$name:String!,$label:String!) { repository(owner:$owner,name:$name) { id nameWithOwner label(name:$label) { id name color } } }", { owner, name, label: config.label }); | ||
| const> | ||
| if (!target || target.nameWithOwner !== repository) throw queueError("projection_target_unavailable", "Issue target is inaccessible or transferred"); | ||
| return { repositoryId: target.id, labelId: await ensureLabel(github, target.id, target.label, config.label) }; | ||
| } | ||
|
|
||
| function issuePreflightQuery(targets, fields) { | ||
| function issuePreflightQuery(targets) { | ||
| if (targets.length > MAX_PROJECTION_TARGETS) throw queueError("projection_limit", `at most ${MAX_PROJECTION_TARGETS} projection targets`); | ||
| const variables = {}; | ||
| const declarations = []; | ||
|
|
@@ -94,7 +68,7 @@ function issuePreflightQuery(targets, fields) { | |
| if (!Number.isSafeInteger(number) || number < 1 || number > 2147483647) throw queueError("projection_target_unavailable", "Issue number cannot be represented by GraphQL"); | ||
| declarations.push(`$o${index}:String!,$r${index}:String!,$n${index}:Int!`); | ||
| Object.assign(variables, { [`o${index}`]: owner, [`r${index}`]: name, [`n${index}`]: number }); | ||
| selections.push(`i${index}: repository(owner:$o${index},name:$r${index}) { issue(number:$n${index}) { ${fields ? ISSUE_SELECTION : ISSUE_SELECTION.replace(/issueFieldValues[\s\S]*$/, "")} } }`); | ||
| selections.push(`i${index}: repository(owner:$o${index},name:$r${index}) { issue(number:$n${index}) { ${ISSUE_SELECTION} } }`); | ||
| } | ||
| for (const [handle, id] of Object.entries(target.comments || {})) { | ||
| const alias = `c${index}_${selections.length}`; | ||
|
|
@@ -109,7 +83,7 @@ function issuePreflightQuery(targets, fields) { | |
| } | ||
|
|
||
| function issueReadQuery(targets, config, repository) { | ||
| const read = issuePreflightQuery(targets, !!config["status-field"]); | ||
| const read = issuePreflightQuery(targets); | ||
| const repositories = [...new Set(targets.map(target => target.resource?.repository || repository))]; | ||
| const repositoryAliases = new Map(); | ||
| read.declarations.push("$trackingLabel:String!"); | ||
|
|
@@ -120,16 +94,15 @@ function issueReadQuery(targets, config, repository) { | |
| repositoryAliases.set(repository, alias); | ||
| read.declarations.push(`$${alias}Owner:String!,$${alias}Name:String!`); | ||
| Object.assign(read.variables, { [`${alias}Owner`]: owner, [`${alias}Name`]: name }); | ||
| read.selections.push(`${alias}: repository(owner:$${alias}Owner,name:$${alias}Name) { id nameWithOwner label(name:$trackingLabel) { id name } | ||
| ${config["status-field"] ? `issueFields(first:${MAX_PROJECTION_TARGETS}) { nodes { ${FIELD_SELECTION} } pageInfo { hasNextPage endCursor } }` : ""} }`); | ||
| read.selections.push(`${alias}: repository(owner:$${alias}Owner,name:$${alias}Name) { id nameWithOwner label(name:$trackingLabel) { id name color } }`); | ||
| } | ||
| return { ...read, repositoryAliases }; | ||
| } | ||
|
|
||
| /** @param {{fields?: boolean, response?: Record<string, any>}} [options] */ | ||
| /** @param {{response?: Record<string, any>}} [options] */ | ||
| async function preflightIssues(github, targets, options = {}) { | ||
| const { fields = true, response } = options; | ||
| const read = issuePreflightQuery(targets, fields); | ||
| const { response } = options; | ||
| const read = issuePreflightQuery(targets); | ||
| if (!read.selections.length) return new Map(); | ||
| const result = response || (await github.graphql(`query WorkQueueIssuePreflight(${read.declarations.join(",")}) { ${read.selections.join("\n")} }`, read.variables)); | ||
| const issues = new Map(); | ||
|
|
@@ -146,7 +119,6 @@ async function preflightIssues(github, targets, options = {}) { | |
| ) | ||
| throw queueError("projection_target_unavailable", "backing Issue was deleted, transferred, or changed identity"); | ||
| if (!Array.isArray(issue.labels?.nodes)) throw queueError("projection_target_unavailable", "Issue labels are inaccessible"); | ||
| if (fields && !Array.isArray(issue.issueFieldValues?.nodes)) throw queueError("projection_field_pending", "Issue field values are inaccessible"); | ||
| issue.comments = new Map(); | ||
| for (const [handle, alias] of Object.entries(target.commentAliases || {})) { | ||
| const comment = result?.[alias]; | ||
|
|
@@ -156,19 +128,19 @@ async function preflightIssues(github, targets, options = {}) { | |
| } | ||
| issues.set(target.work_id, issue); | ||
| } | ||
| await completeIssueConnections(github, [...issues.values()], fields); | ||
| await completeIssueConnections(github, [...issues.values()]); | ||
| return issues; | ||
| } | ||
|
|
||
| async function completeIssueConnections(github, issues, fields) { | ||
| async function completeIssueConnections(github, issues) { | ||
| const seen = new Map(); | ||
| for (let page = 0; page < 16; page++) { | ||
| const declarations = []; | ||
| const selections = []; | ||
| const variables = {}; | ||
| const pending = []; | ||
| for (const [index, issue] of issues.entries()) { | ||
| for (const connection of fields ? ["labels", "issueFieldValues"] : ["labels"]) { | ||
| for (const connection of ["labels"]) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. L143: yagni: generic connection loop and dynamic property bookkeeping now serve only
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replaced the generic connection loop with inline labels-only pagination, keeping just issue/alias pending state. Fixed in |
||
| const values = issue[connection]; | ||
| if (values?.pageInfo?.hasNextPage === false) continue; | ||
| const cursor = values?.pageInfo?.endCursor; | ||
|
|
@@ -180,9 +152,7 @@ async function completeIssueConnections(github, issues, fields) { | |
| const alias = `p${pending.length}`; | ||
| declarations.push(`$${alias}:ID!,$${alias}Cursor:String!`); | ||
| Object.assign(variables, { [alias]: issue.id, [`${alias}Cursor`]: cursor }); | ||
| selections.push( | ||
| `${alias}: node(id:$${alias}) { ... on Issue { id repository { id } ${connection}(first:${connection === "labels" ? LABELS_PAGE_SIZE : MAX_PROJECTION_TARGETS},after:$${alias}Cursor) { nodes { ${connection === "labels" ? "id name" : VALUE_SELECTION} } pageInfo { hasNextPage endCursor } } } }` | ||
| ); | ||
| selections.push(`${alias}: node(id:$${alias}) { ... on Issue { id repository { id } ${connection}(first:${LABELS_PAGE_SIZE},after:$${alias}Cursor) { nodes { id name } pageInfo { hasNextPage endCursor } } } }`); | ||
| pending.push({ issue, connection, alias }); | ||
| } | ||
| } | ||
|
|
@@ -269,6 +239,8 @@ module.exports = { | |
| LABELS_PAGE_SIZE, | ||
| STATUSES, | ||
| ISSUE_SELECTION, | ||
| statusLabelName, | ||
| ensureStatusLabel, | ||
| discoverTarget, | ||
| preflightIssues, | ||
| mutateIssues, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot no spaces in label names
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generated status labels now use lowercase hyphenated names without spaces, including whitespace-normalized prefixes. Added test coverage and updated the reference docs in
c02d3f9.