From 6f45534357b656222a7f20ffea07183407db43d1 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 13 Oct 2026 15:40:34 +0200 Subject: [PATCH] fix: recover verified Project items omitted from board listings --- build/cli/index.js | 59 +++++++++- build/github_action/index.js | 59 +++++++++- docs/issues/assignees-and-projects.mdx | 9 ++ specs/CATALOG.md | 8 +- specs/catalog.json | 9 +- .../pull-request-lifecycle-and-enrichment.md | 52 +++++++++ .../project_board_command_repository.test.ts | 5 +- .../project_board_query_repository.test.ts | 3 + .../project_board_visibility_recovery.test.ts | 103 ++++++++++++++++++ .../project/project_board_field_update.ts | 26 ++++- .../project/project_board_item_query.ts | 44 ++++++++ 11 files changed, 359 insertions(+), 18 deletions(-) create mode 100644 src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts diff --git a/build/cli/index.js b/build/cli/index.js index c285c41fb..db56711e1 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -80272,9 +80272,27 @@ async function findProjectItem(client, project, itemId, fieldName) { if (item) return item; } - const message = `Project item ${itemId} was not found while updating field '${fieldName}'.`; - (0, logger_1.logError)(message); - throw new Error(message); + const result = await client.graphql(`query($itemId: ID!, $fieldName: String!) { + node(id: $itemId) { + id __typename + ... on ProjectV2Item { + project { id } + fieldValueByName(name: $fieldName) { + ... on ProjectV2ItemFieldSingleSelectValue { optionId } + } + } + } + }`, { itemId, fieldName }); + const item = result.node; + if (item?.id !== itemId || item.__typename !== 'ProjectV2Item' || item.project?.id !== project.id + || !Object.prototype.hasOwnProperty.call(item, 'fieldValueByName')) { + throw new Error(`Project item ${itemId} has no verified field evidence in project ${project.id}.`); + } + const value = item.fieldValueByName; + if (value !== null && (typeof value?.optionId !== 'string' || !value.optionId.trim())) { + throw new Error('The direct Project item single-select value is malformed.'); + } + return { id: itemId, fieldValues: { nodes: value === null ? [] : [{ field: { name: fieldName }, optionId: value.optionId }] } }; } @@ -80354,6 +80372,41 @@ async function findProjectItemId(client, project, contentId) { if (item) return item.id; } + return findContentSideProjectItemId(client, project, contentId); +} +/** Recover a missing board-list entry from the exact content's own memberships. */ +async function findContentSideProjectItemId(client, project, contentId) { + for await (const page of (0, github_pagination_adapter_1.paginateCursor)(async (after) => { + const result = await client.graphql(`query($contentId: ID!, $after: String) { + node(id: $contentId) { + id __typename + ... on Issue { projectItems(first: 100, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { id project { id } content { ... on Issue { id } ... on PullRequest { id } } } + } } + ... on PullRequest { projectItems(first: 100, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { id project { id } content { ... on Issue { id } ... on PullRequest { id } } } + } } + } + }`, { contentId, after }); + const node = result.node; + const membership = node?.projectItems; + if (node?.id !== contentId || !['Issue', 'PullRequest'].includes(node.__typename ?? '') + || !membership || !Array.isArray(membership.nodes) + || typeof membership.pageInfo?.hasNextPage !== 'boolean' + || !(membership.pageInfo.endCursor === null || typeof membership.pageInfo.endCursor === 'string')) { + throw new Error('The content-side Project membership evidence is unavailable or malformed.'); + } + return membership; + }, { description: 'content-side project membership', maxPages: 2 })) { + const item = page.nodes.find(candidate => candidate.project?.id === project.id && candidate.content?.id === contentId); + if (item) { + if (typeof item.id !== 'string' || !item.id.trim()) + throw new Error('The content-side Project item identity is malformed.'); + return item.id; + } + } return undefined; } diff --git a/build/github_action/index.js b/build/github_action/index.js index d012d3c85..cbf48e958 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -73892,9 +73892,27 @@ async function findProjectItem(client, project, itemId, fieldName) { if (item) return item; } - const message = `Project item ${itemId} was not found while updating field '${fieldName}'.`; - (0, logger_1.logError)(message); - throw new Error(message); + const result = await client.graphql(`query($itemId: ID!, $fieldName: String!) { + node(id: $itemId) { + id __typename + ... on ProjectV2Item { + project { id } + fieldValueByName(name: $fieldName) { + ... on ProjectV2ItemFieldSingleSelectValue { optionId } + } + } + } + }`, { itemId, fieldName }); + const item = result.node; + if (item?.id !== itemId || item.__typename !== 'ProjectV2Item' || item.project?.id !== project.id + || !Object.prototype.hasOwnProperty.call(item, 'fieldValueByName')) { + throw new Error(`Project item ${itemId} has no verified field evidence in project ${project.id}.`); + } + const value = item.fieldValueByName; + if (value !== null && (typeof value?.optionId !== 'string' || !value.optionId.trim())) { + throw new Error('The direct Project item single-select value is malformed.'); + } + return { id: itemId, fieldValues: { nodes: value === null ? [] : [{ field: { name: fieldName }, optionId: value.optionId }] } }; } @@ -73974,6 +73992,41 @@ async function findProjectItemId(client, project, contentId) { if (item) return item.id; } + return findContentSideProjectItemId(client, project, contentId); +} +/** Recover a missing board-list entry from the exact content's own memberships. */ +async function findContentSideProjectItemId(client, project, contentId) { + for await (const page of (0, github_pagination_adapter_1.paginateCursor)(async (after) => { + const result = await client.graphql(`query($contentId: ID!, $after: String) { + node(id: $contentId) { + id __typename + ... on Issue { projectItems(first: 100, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { id project { id } content { ... on Issue { id } ... on PullRequest { id } } } + } } + ... on PullRequest { projectItems(first: 100, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { id project { id } content { ... on Issue { id } ... on PullRequest { id } } } + } } + } + }`, { contentId, after }); + const node = result.node; + const membership = node?.projectItems; + if (node?.id !== contentId || !['Issue', 'PullRequest'].includes(node.__typename ?? '') + || !membership || !Array.isArray(membership.nodes) + || typeof membership.pageInfo?.hasNextPage !== 'boolean' + || !(membership.pageInfo.endCursor === null || typeof membership.pageInfo.endCursor === 'string')) { + throw new Error('The content-side Project membership evidence is unavailable or malformed.'); + } + return membership; + }, { description: 'content-side project membership', maxPages: 2 })) { + const item = page.nodes.find(candidate => candidate.project?.id === project.id && candidate.content?.id === contentId); + if (item) { + if (typeof item.id !== 'string' || !item.id.trim()) + throw new Error('The content-side Project item identity is malformed.'); + return item.id; + } + } return undefined; } diff --git a/docs/issues/assignees-and-projects.mdx b/docs/issues/assignees-and-projects.mdx index d8d4001df..f00dfe40b 100644 --- a/docs/issues/assignees-and-projects.mdx +++ b/docs/issues/assignees-and-projects.mdx @@ -80,6 +80,15 @@ the normal lifecycle transition selects the configured in-progress Status. If initialization fails, retrying reads the same item and can fill its unset Status without adding another item. +If a subsequent priority or size update cannot find a newly linked item in the +board listing, Copilot checks the exact issue or PR's own Project memberships +before reporting a failure. This recovery reads at most 200 memberships. If a +known item's field is absent from the listing, one direct item read verifies its +Project and current field value. Matching values stay unchanged on replay. +Permission errors, incomplete evidence and persistent absence remain visible +failures; rerun after checking access and membership. Successful linking and +other completed updates are retained. + ### How many boards? You can link each issue to **multiple** boards by listing several IDs: diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 8b366c8a4..e7431a085 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -25,7 +25,7 @@ debt or convert unknown historic intent into a design decision. | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 79 paths · 2026-10-11 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze its bounded diff partitions, publish stable findings atomically, and apply authorized verified fixes | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 2 companion | 108 paths · 2026-10-13 | | `branch-synchronization` | Implemented | Observe parent drift with one localized status card and transition-only notifications, then safely merge a parent branch into a linked working branch | [Branch synchronization and conflict recovery](./branch-synchronization-and-conflict-recovery.md) | 68 paths · 2026-10-13 | -| `pull-request-lifecycle` | Implemented | Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 72 paths · 2026-10-12 | +| `pull-request-lifecycle` | Implemented | Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 75 paths · 2026-10-13 | | `agent-runtime` | Implemented | Resolve, provision, authenticate, authorize, and execute only the agent roles reachable by a run | [Agent runtime, provider, model, and role routing](./agent-runtime-provider-and-model-routing.md) + 1 companion | 96 paths · 2026-10-12 | | `cli-and-single-actions` | As-built baseline | Expose bounded local commands and workflow-dispatched operations through the shared application core | [CLI and single-action execution](./cli-and-single-action-execution.md) | 33 paths · 2026-09-16 | | `configurable-issue-workflows` | Implemented | Select one canonical set of issue workflows and enforce its forms, dependencies, branch policy, runtime admission, migration, and diagnosis | [Configurable issue workflows and fail-closed admission](./configurable-issue-workflows-and-admission.md) | 89 paths · 2026-10-13 | @@ -202,12 +202,12 @@ debt or convert unknown historic intent into a design decision. ### `pull-request-lifecycle` — Pull request lifecycle and enrichment - Owner: Copilot maintainers -- Last verified: 2026-10-12 +- Last verified: 2026-10-13 - Specifications: [`specs/pull-request-lifecycle-and-enrichment.md`](./pull-request-lifecycle-and-enrichment.md) - Workflows: [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_pull_request_merge_queue.yml`](../.github/workflows/copilot_pull_request_merge_queue.yml) · [`setup/workflows/copilot_pull_request.yml`](../setup/workflows/copilot_pull_request.yml) · [`setup/workflows/copilot_pull_request_merge_queue.yml`](../setup/workflows/copilot_pull_request_merge_queue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`setup/workflows/copilot_issue_comment.yml`](../setup/workflows/copilot_issue_comment.yml) · [`setup/workflows/copilot_pull_request_comment.yml`](../setup/workflows/copilot_pull_request_comment.yml) - Entrypoints: [`src/application/usecases/pull_request_use_case.ts`](../src/application/usecases/pull_request_use_case.ts) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) -- Core code: [`src/application/usecases/execution/execution_issue_number_policy.ts`](../src/application/usecases/execution/execution_issue_number_policy.ts) · [`src/application/usecases/execution/setup_execution_workflow.ts`](../src/application/usecases/execution/setup_execution_workflow.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/application/usecases/pull_request_workflow.ts`](../src/application/usecases/pull_request_workflow.ts) · [`src/application/usecases/pull_request_workflow_steps.ts`](../src/application/usecases/pull_request_workflow_steps.ts) · [`src/application/ports/project_board_link_ports.ts`](../src/application/ports/project_board_link_ports.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.ts) · [`src/application/usecases/steps/pull_request/link_pull_request_project_use_case.ts`](../src/application/usecases/steps/pull_request/link_pull_request_project_use_case.ts) · [`src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts`](../src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts) · [`src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts`](../src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts) · [`src/prompts/update_pull_request_description.ts`](../src/prompts/update_pull_request_description.ts) · [`src/application/policies/agent_response_schemas.ts`](../src/application/policies/agent_response_schemas.ts) · [`src/application/policies/pull_request_description_content_policy.ts`](../src/application/policies/pull_request_description_content_policy.ts) · [`src/data/repository/project/project_board_link_repository.ts`](../src/data/repository/project/project_board_link_repository.ts) · [`src/data/repository/project/project_board_command_repository.ts`](../src/data/repository/project/project_board_command_repository.ts) · [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/domain/pull_request_description.ts`](../src/domain/pull_request_description.ts) · [`src/data/repository/pull_request/pull_request_lifecycle_repository.ts`](../src/data/repository/pull_request/pull_request_lifecycle_repository.ts) · [`src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts`](../src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts) · [`src/application/policies/agent_task_activation_policy.ts`](../src/application/policies/agent_task_activation_policy.ts) · [`src/application/policies/agent_activity_policy.ts`](../src/application/policies/agent_activity_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/actions/synchronize_agent_activity_use_case.ts`](../src/application/usecases/actions/synchronize_agent_activity_use_case.ts) · [`src/application/policies/lifecycle_waiting_state_policy.ts`](../src/application/policies/lifecycle_waiting_state_policy.ts) · [`src/application/policies/copilot_evidence_policy.ts`](../src/application/policies/copilot_evidence_policy.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) -- Tests: [`src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts`](../src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts) · [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) · [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/application/usecases/__tests__/pull_request_use_case.test.ts`](../src/application/usecases/__tests__/pull_request_use_case.test.ts) · [`src/application/usecases/steps/pull_request/__tests__/link_pull_request_project_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/link_pull_request_project_use_case.test.ts) · [`src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts) · [`src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts) · [`src/application/policies/__tests__/pull_request_description_content_policy.test.ts`](../src/application/policies/__tests__/pull_request_description_content_policy.test.ts) · [`src/application/policies/__tests__/agent_response_schemas.test.ts`](../src/application/policies/__tests__/agent_response_schemas.test.ts) · [`src/prompts/__tests__/update_pull_request_description.test.ts`](../src/prompts/__tests__/update_pull_request_description.test.ts) · [`src/data/repository/__tests__/project_board_link_repository.test.ts`](../src/data/repository/__tests__/project_board_link_repository.test.ts) · [`src/data/repository/project/__tests__/project_board_command_repository.test.ts`](../src/data/repository/project/__tests__/project_board_command_repository.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts`](../src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts) · [`src/application/policies/__tests__/agent_task_activation_policy.test.ts`](../src/application/policies/__tests__/agent_task_activation_policy.test.ts) · [`src/application/policies/__tests__/agent_activity_policy.test.ts`](../src/application/policies/__tests__/agent_activity_policy.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_agent_activity_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_agent_activity_use_case.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/application/policies/__tests__/copilot_evidence_policy.test.ts`](../src/application/policies/__tests__/copilot_evidence_policy.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) +- Core code: [`src/application/usecases/execution/execution_issue_number_policy.ts`](../src/application/usecases/execution/execution_issue_number_policy.ts) · [`src/application/usecases/execution/setup_execution_workflow.ts`](../src/application/usecases/execution/setup_execution_workflow.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/application/usecases/pull_request_workflow.ts`](../src/application/usecases/pull_request_workflow.ts) · [`src/application/usecases/pull_request_workflow_steps.ts`](../src/application/usecases/pull_request_workflow_steps.ts) · [`src/application/ports/project_board_link_ports.ts`](../src/application/ports/project_board_link_ports.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.ts) · [`src/application/usecases/steps/pull_request/link_pull_request_project_use_case.ts`](../src/application/usecases/steps/pull_request/link_pull_request_project_use_case.ts) · [`src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts`](../src/application/usecases/steps/pull_request/link_pull_request_issue_workflow.ts) · [`src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts`](../src/application/usecases/steps/pull_request/update_pull_request_description_use_case.ts) · [`src/prompts/update_pull_request_description.ts`](../src/prompts/update_pull_request_description.ts) · [`src/application/policies/agent_response_schemas.ts`](../src/application/policies/agent_response_schemas.ts) · [`src/application/policies/pull_request_description_content_policy.ts`](../src/application/policies/pull_request_description_content_policy.ts) · [`src/data/repository/project/project_board_link_repository.ts`](../src/data/repository/project/project_board_link_repository.ts) · [`src/data/repository/project/project_board_command_repository.ts`](../src/data/repository/project/project_board_command_repository.ts) · [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/domain/pull_request_description.ts`](../src/domain/pull_request_description.ts) · [`src/data/repository/pull_request/pull_request_lifecycle_repository.ts`](../src/data/repository/pull_request/pull_request_lifecycle_repository.ts) · [`src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts`](../src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts) · [`src/application/policies/agent_task_activation_policy.ts`](../src/application/policies/agent_task_activation_policy.ts) · [`src/application/policies/agent_activity_policy.ts`](../src/application/policies/agent_activity_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/actions/synchronize_agent_activity_use_case.ts`](../src/application/usecases/actions/synchronize_agent_activity_use_case.ts) · [`src/application/policies/lifecycle_waiting_state_policy.ts`](../src/application/policies/lifecycle_waiting_state_policy.ts) · [`src/application/policies/copilot_evidence_policy.ts`](../src/application/policies/copilot_evidence_policy.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/data/repository/project/project_board_item_query.ts`](../src/data/repository/project/project_board_item_query.ts) · [`src/data/repository/project/project_board_field_update.ts`](../src/data/repository/project/project_board_field_update.ts) +- Tests: [`src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts`](../src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts) · [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) · [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/application/usecases/__tests__/pull_request_use_case.test.ts`](../src/application/usecases/__tests__/pull_request_use_case.test.ts) · [`src/application/usecases/steps/pull_request/__tests__/link_pull_request_project_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/link_pull_request_project_use_case.test.ts) · [`src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/link_pull_request_issue_use_case.test.ts) · [`src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts`](../src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts) · [`src/application/policies/__tests__/pull_request_description_content_policy.test.ts`](../src/application/policies/__tests__/pull_request_description_content_policy.test.ts) · [`src/application/policies/__tests__/agent_response_schemas.test.ts`](../src/application/policies/__tests__/agent_response_schemas.test.ts) · [`src/prompts/__tests__/update_pull_request_description.test.ts`](../src/prompts/__tests__/update_pull_request_description.test.ts) · [`src/data/repository/__tests__/project_board_link_repository.test.ts`](../src/data/repository/__tests__/project_board_link_repository.test.ts) · [`src/data/repository/project/__tests__/project_board_command_repository.test.ts`](../src/data/repository/project/__tests__/project_board_command_repository.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts`](../src/data/repository/__tests__/pull_request_lifecycle_repository.test.ts) · [`src/application/policies/__tests__/agent_task_activation_policy.test.ts`](../src/application/policies/__tests__/agent_task_activation_policy.test.ts) · [`src/application/policies/__tests__/agent_activity_policy.test.ts`](../src/application/policies/__tests__/agent_activity_policy.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_agent_activity_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_agent_activity_use_case.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/application/policies/__tests__/copilot_evidence_policy.test.ts`](../src/application/policies/__tests__/copilot_evidence_policy.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts`](../src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts) - User documentation: [`docs/pull-requests/capabilities.mdx`](../docs/pull-requests/capabilities.mdx) · [`docs/pull-requests/ai-description.mdx`](../docs/pull-requests/ai-description.mdx) · [`docs/pull-requests/configuration.mdx`](../docs/pull-requests/configuration.mdx) · [`docs/features.mdx`](../docs/features.mdx) · [`docs/bugbot/how-it-works.mdx`](../docs/bugbot/how-it-works.mdx) · [`docs/bugbot/configuration.mdx`](../docs/bugbot/configuration.mdx) · [`docs/bugbot/detection.mdx`](../docs/bugbot/detection.mdx) · [`docs/pull-requests/workflow-setup.mdx`](../docs/pull-requests/workflow-setup.mdx) · [`docs/pull-requests/examples.mdx`](../docs/pull-requests/examples.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) ### `agent-runtime` — Agent runtime, provider, model, and role routing diff --git a/specs/catalog.json b/specs/catalog.json index 4b9d938d8..731125d6b 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1742,7 +1742,7 @@ "status": "implemented", "scope": "Enrich linked and unlinked pull requests with safe issue linkage, projects, metadata, reviewers, concise descriptions, and distinct workflow evidence", "owner": "Copilot maintainers", - "lastVerified": "2026-10-12", + "lastVerified": "2026-10-13", "specs": [ "specs/pull-request-lifecycle-and-enrichment.md" ], @@ -1788,7 +1788,9 @@ "src/application/usecases/actions/synchronize_agent_activity_use_case.ts", "src/application/policies/lifecycle_waiting_state_policy.ts", "src/application/policies/copilot_evidence_policy.ts", - "src/actions/github_action_completion.ts" + "src/actions/github_action_completion.ts", + "src/data/repository/project/project_board_item_query.ts", + "src/data/repository/project/project_board_field_update.ts" ], "tests": [ "src/application/usecases/execution/__tests__/execution_issue_number_policy.test.ts", @@ -1814,7 +1816,8 @@ "src/application/usecases/actions/__tests__/synchronize_agent_activity_use_case.test.ts", "src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts", "src/application/policies/__tests__/copilot_evidence_policy.test.ts", - "src/actions/__tests__/github_action_completion.test.ts" + "src/actions/__tests__/github_action_completion.test.ts", + "src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts" ], "documentation": [ "docs/pull-requests/capabilities.mdx", diff --git a/specs/pull-request-lifecycle-and-enrichment.md b/specs/pull-request-lifecycle-and-enrichment.md index c972875ec..f47f9217e 100644 --- a/specs/pull-request-lifecycle-and-enrichment.md +++ b/specs/pull-request-lifecycle-and-enrichment.md @@ -616,3 +616,55 @@ command does not perform implicit recovery writes. This observed limitation does not authorize clearing another active run's label or weakening a sync guard. Native History interruption `38179714921` retained its prior remote head without a success receipt or push. + +## 21. Live Project item visibility recovery (2026-10-13) + +Live PR [#485](https://github.com/vypdev/copilot/pull/485) was added to the +Project and its Status initialized from the returned item, but subsequent +priority and concurrent commit-size reads could not see that same PR in the +board listing. Runs 38402185354 and 38402175824 failed on attempt 1 and succeeded +on exact replay. Membership was retained; these failures did not invalidate the +completed code review or imply a different PR base. + +The adapter MUST preserve the existing bounded board lookup. Only complete +absence triggers an independent read of the exact content node's Project +memberships, at most two pages of 100 items. Identity, content type, Project and +item ID MUST match before a recovered ID is accepted. Exhausted pagination, +malformed evidence and permission errors MUST remain failures rather than +successful absence. A complete empty membership permits the existing missing +item outcome. No retry timer, new permission, persistent cache or public input +is added; these read bounds are fixed. + +```text +board lookup -> exact content memberships (only if absent) -> verified item ID +field list -> direct verified item/field (only if absent) -> unchanged or update +``` + +A field update whose known item is absent from the board listing MUST read that +exact item once, verify its Project and native type, and read the named field. +An explicit null means unset; missing/malformed field evidence blocks writes. +An equal option remains a quiet no-op on replay. Retained project membership and +other completed mutations are never undone to repair visibility. + +The existing semantic error/result UI remains: completed linkage stays visible; +a persistent unavailable item reports failed enrichment and asks the operator to +inspect/retry the run. Recovery adds no conversation comment or model call, and +works identically with either locale. Recommended setup/Projects permissions +remain unchanged. Rollback restores previous lookup behavior; no data migration +or cleanup is required. + +An additional **22 adapter/security cases**, separate from the existing 164 +budget, cover Issue/PR recovery, second-page membership, truthful absence, +malformed/foreign identity, exhausted reads, permission denial, direct field +recovery, matching-value replay and six no-write evidence failures. Thirteen +cases reproduced the old missing-item behavior; nine were negative controls. +Global and changed-module gates remain in force. The tests map each MUST above +to `project_board_visibility_recovery.test.ts`; user/operator documentation is +`docs/issues/assignees-and-projects.mdx`. Live acceptance requires opening the +managed correction PR, confirming Project/priority/size and current checks, +then replaying without duplicate membership or reset Status. Unknown provider +propagation timing is not asserted as a guaranteed consistency SLA. + +Primary schema references: [Issue projectItems](https://docs.github.com/en/graphql/reference/issues), +[PullRequest projectItems](https://docs.github.com/en/graphql/reference/pulls), +and [ProjectV2Item](https://docs.github.com/en/graphql/reference/projects). diff --git a/src/data/repository/project/__tests__/project_board_command_repository.test.ts b/src/data/repository/project/__tests__/project_board_command_repository.test.ts index ade612b4b..5a1e9bf64 100644 --- a/src/data/repository/project/__tests__/project_board_command_repository.test.ts +++ b/src/data/repository/project/__tests__/project_board_command_repository.test.ts @@ -90,6 +90,7 @@ function createHarness(options: HarnessOptions = {}) { const graphql = jest.fn( async (document: string, variables?: Record) => { + if (document.includes('fieldValueByName(name: $fieldName)')) return { node: null }; if (document.includes("fieldValueByName")) { return { node: Object.prototype.hasOwnProperty.call(options, "statusItem") ? options.statusItem : { project: { id: "PVT_project" }, fieldValueByName: null, @@ -429,7 +430,7 @@ describe("ProjectBoardCommandRepository", () => { "token", ), ).rejects.toThrow( - "Project item PVTI_item was not found while updating field 'Status'.", + "Project item PVTI_item has no verified field evidence in project PVT_project.", ); expect( harness.graphql.mock.calls.some(([document]) => @@ -538,7 +539,7 @@ describe("ProjectBoardCommandRepository", () => { "token", ), ).rejects.toThrow( - "Project item PVTI_item was not found while updating field 'Status'.", + "Project item PVTI_item has no verified field evidence in project PVT_project.", ); }); diff --git a/src/data/repository/project/__tests__/project_board_query_repository.test.ts b/src/data/repository/project/__tests__/project_board_query_repository.test.ts index 120fbbcdd..d6453c7e4 100644 --- a/src/data/repository/project/__tests__/project_board_query_repository.test.ts +++ b/src/data/repository/project/__tests__/project_board_query_repository.test.ts @@ -72,6 +72,9 @@ function createHarness(options: HarnessOptions = {}) { ? { user: { projectV2: projectData } } : { organization: { projectV2: projectData } }; } + if (document.includes("projectItems(first:")) { + return { node: { id: options.contentId ?? "CONTENT_1", __typename: 'Issue', projectItems: terminalPage([]) } }; + } if (document.includes("issueOrPullRequest(number:")) { return { repository: options.contentMissing diff --git a/src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts b/src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts new file mode 100644 index 000000000..7eff0804f --- /dev/null +++ b/src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts @@ -0,0 +1,103 @@ +import { getProjectItemId, getProjectItemIdByContentId } from '../project_board_item_query'; +import { setProjectBoardSingleSelectField } from '../project_board_field_update'; +import { ProjectDetail } from '../../../model/project_detail'; +import type { GithubGraphqlTransportClient } from '../../../../infrastructure/github/ports/github_graphql_transport_port'; + +jest.mock('../../../../utils/logger', () => ({ logDebugInfo: jest.fn(), logError: jest.fn() })); +const project = new ProjectDetail({ id: 'PVT_board', title: 'Board', url: 'https://github.com/orgs/owner/projects/1', type: 'organization', owner: 'owner', number: 1 }); +const page = (nodes: unknown[], more = false, cursor: string | null = null) => ({ nodes, pageInfo: { hasNextPage: more, endCursor: cursor } }); +const linked = { id: 'PVTI_exact', project: { id: project.id }, content: { id: 'CONTENT_exact' } }; +const source = (kind = 'PullRequest', items: unknown = page([linked])) => ({ id: 'CONTENT_exact', __typename: kind, projectItems: items }); +function queryHarness(fallback: unknown = source()) { + const graphql = jest.fn(async (document: string) => { + if (document.includes('issueOrPullRequest(number:')) return { repository: { issueOrPullRequest: { id: 'CONTENT_exact' } } }; + if (document.includes('projectItems(first:')) return { node: fallback }; + return { node: { items: page([]) } }; + }); + return { graphql, client: { graphql } as unknown as GithubGraphqlTransportClient }; +} + +describe('bounded content-side project membership recovery', () => { + it.each(['Issue', 'PullRequest'])('recovers %s membership omitted by the board list', async kind => { + const h = queryHarness(source(kind)); + await expect(getProjectItemId(h.client, project, 'owner', 'repo', 42)).resolves.toBe('PVTI_exact'); + expect(h.graphql).toHaveBeenCalledTimes(3); + expect(h.graphql.mock.calls[2][0]).toContain('projectItems(first: 100'); + }); + it('reuses the same recovered item during linkage instead of adding another', async () => { + const h = queryHarness(); + await expect(getProjectItemIdByContentId(h.client, project, 'CONTENT_exact')).resolves.toBe('PVTI_exact'); + expect(h.graphql).toHaveBeenCalledTimes(2); + }); + it('continues once to a second content membership page', async () => { + const h = queryHarness(); let reads = 0; + h.graphql.mockImplementation(async document => document.includes('projectItems(first:') + ? { node: source('Issue', ++reads === 1 ? page([], true, 'next') : page([linked])) } + : { node: { items: page([]) } }); + await expect(getProjectItemIdByContentId(h.client, project, 'CONTENT_exact')).resolves.toBe('PVTI_exact'); + expect(reads).toBe(2); + }); + it('retains truthful absence when neither view contains the item', async () => { + const h = queryHarness(source('Issue', page([]))); + await expect(getProjectItemId(h.client, project, 'owner', 'repo', 42)).rejects.toThrow('is not in project'); + }); + it.each(['wrong-content', 'wrong-kind', 'missing-connection', 'null-node', 'malformed-page', 'bad-item'])('rejects %s evidence', async kind => { + let node: unknown = source(); + if (kind === 'wrong-content') node = { ...source(), id: 'FOREIGN' }; + if (kind === 'wrong-kind') node = source('DraftIssue'); + if (kind === 'missing-connection') node = { id: 'CONTENT_exact', __typename: 'Issue' }; + if (kind === 'null-node') node = null; + if (kind === 'malformed-page') node = source('Issue', { nodes: [], pageInfo: null }); + if (kind === 'bad-item') node = source('Issue', page([{ ...linked, id: '' }])); + const h = queryHarness(node); + await expect(getProjectItemIdByContentId(h.client, project, 'CONTENT_exact')).rejects.toThrow(); + }); + it('rejects continued pagination after two recovery pages', async () => { + const h = queryHarness(source('Issue', page([], true, 'next'))); + await expect(getProjectItemIdByContentId(h.client, project, 'CONTENT_exact')).rejects.toThrow('stopped after 2 pages'); + expect(h.graphql).toHaveBeenCalledTimes(3); + }); + it('does not retry permission errors from the first board view', async () => { + const h = queryHarness(); h.graphql.mockRejectedValue(new Error('403 forbidden')); + await expect(getProjectItemIdByContentId(h.client, project, 'CONTENT_exact')).rejects.toThrow('403 forbidden'); + expect(h.graphql).toHaveBeenCalledTimes(1); + }); + it('does not accept membership in a different Project or for different content', async () => { + const h = queryHarness(source('Issue', page([{ ...linked, project: { id: 'OTHER' } }, { ...linked, content: { id: 'OTHER' } }]))); + await expect(getProjectItemIdByContentId(h.client, project, 'CONTENT_exact')).resolves.toBeUndefined(); + }); +}); + +function fieldHarness(node: unknown) { + const graphql = jest.fn(async (document: string) => { + if (document.includes('mutation(')) return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'PVTI_exact' } } }; + if (document.includes('fieldValueByName(name: $fieldName)')) return { node }; + if (document.includes('fields(first:')) return { node: { fields: page([{ id: 'FIELD_size', name: 'Size', options: [{ id: 'OPTION_m', name: 'M' }] }]) } }; + return { node: { items: page([]) } }; + }); + const client = { graphql } as unknown as GithubGraphqlTransportClient; + const update = () => setProjectBoardSingleSelectField({ getProjectItemId: async () => 'PVTI_exact' }, { getClient: () => client }, project, 'owner', 'repo', 42, 'Size', 'M', 'token'); + return { graphql, update }; +} +const item = (value: unknown = null) => ({ id: 'PVTI_exact', __typename: 'ProjectV2Item', project: { id: project.id }, fieldValueByName: value }); +describe('direct exact-item field recovery', () => { + it('updates an unset field even when the item is absent from the board list', async () => { + const h = fieldHarness(item()); await expect(h.update()).resolves.toBe(true); + const mutations = h.graphql.mock.calls.filter(([q]) => q.includes('mutation(')); expect(mutations).toHaveLength(1); + }); + it('keeps a matching field unchanged on replay', async () => { + const h = fieldHarness(item({ optionId: 'OPTION_m' })); await expect(h.update()).resolves.toBe(false); + expect(h.graphql.mock.calls.some(([q]) => q.includes('mutation('))).toBe(false); + }); + it.each(['null', 'wrong-item', 'wrong-project', 'wrong-kind', 'missing-value', 'bad-option'])('refuses %s direct item before mutation', async kind => { + let node: unknown = item(); + if (kind === 'null') node = null; + if (kind === 'wrong-item') node = { ...item(), id: 'OTHER' }; + if (kind === 'wrong-project') node = { ...item(), project: { id: 'OTHER' } }; + if (kind === 'wrong-kind') node = { ...item(), __typename: 'Issue' }; + if (kind === 'missing-value') node = { id: 'PVTI_exact', __typename: 'ProjectV2Item', project: { id: project.id } }; + if (kind === 'bad-option') node = item({ optionId: '' }); + const h = fieldHarness(node); await expect(h.update()).rejects.toThrow(); + expect(h.graphql.mock.calls.some(([q]) => q.includes('mutation('))).toBe(false); + }); +}); diff --git a/src/data/repository/project/project_board_field_update.ts b/src/data/repository/project/project_board_field_update.ts index c646fabbc..2f3d713e5 100644 --- a/src/data/repository/project/project_board_field_update.ts +++ b/src/data/repository/project/project_board_field_update.ts @@ -251,7 +251,27 @@ async function findProjectItem( const item = page.nodes.find((candidate) => candidate.id === itemId); if (item) return item; } - const message = `Project item ${itemId} was not found while updating field '${fieldName}'.`; - logError(message); - throw new Error(message); + const result = await client.graphql<{ + node?: { id?: string; __typename?: string; project?: { id?: string }; fieldValueByName?: { optionId?: string } | null } | null; + }>(`query($itemId: ID!, $fieldName: String!) { + node(id: $itemId) { + id __typename + ... on ProjectV2Item { + project { id } + fieldValueByName(name: $fieldName) { + ... on ProjectV2ItemFieldSingleSelectValue { optionId } + } + } + } + }`, { itemId, fieldName }); + const item = result.node; + if (item?.id !== itemId || item.__typename !== 'ProjectV2Item' || item.project?.id !== project.id + || !Object.prototype.hasOwnProperty.call(item, 'fieldValueByName')) { + throw new Error(`Project item ${itemId} has no verified field evidence in project ${project.id}.`); + } + const value = item.fieldValueByName; + if (value !== null && (typeof value?.optionId !== 'string' || !value.optionId.trim())) { + throw new Error('The direct Project item single-select value is malformed.'); + } + return { id: itemId, fieldValues: { nodes: value === null ? [] : [{ field: { name: fieldName }, optionId: value.optionId }] } }; } diff --git a/src/data/repository/project/project_board_item_query.ts b/src/data/repository/project/project_board_item_query.ts index 5d7908043..5e7154931 100644 --- a/src/data/repository/project/project_board_item_query.ts +++ b/src/data/repository/project/project_board_item_query.ts @@ -99,5 +99,49 @@ async function findProjectItemId( const item = page.nodes.find((candidate) => candidate.content?.id === contentId); if (item) return item.id; } + return findContentSideProjectItemId(client, project, contentId); +} + +/** Recover a missing board-list entry from the exact content's own memberships. */ +async function findContentSideProjectItemId( + client: GithubGraphqlTransportClient, + project: ProjectDetail, + contentId: string, +): Promise { + for await (const page of paginateCursor( + async after => { + const result = await client.graphql<{ + node?: { id?: string; __typename?: string; projectItems?: CursorPage<{ id?: string; project?: { id?: string }; content?: { id?: string } }> } | null; + }>(`query($contentId: ID!, $after: String) { + node(id: $contentId) { + id __typename + ... on Issue { projectItems(first: 100, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { id project { id } content { ... on Issue { id } ... on PullRequest { id } } } + } } + ... on PullRequest { projectItems(first: 100, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { id project { id } content { ... on Issue { id } ... on PullRequest { id } } } + } } + } + }`, { contentId, after }); + const node = result.node; + const membership = node?.projectItems; + if (node?.id !== contentId || !['Issue', 'PullRequest'].includes(node.__typename ?? '') + || !membership || !Array.isArray(membership.nodes) + || typeof membership.pageInfo?.hasNextPage !== 'boolean' + || !(membership.pageInfo.endCursor === null || typeof membership.pageInfo.endCursor === 'string')) { + throw new Error('The content-side Project membership evidence is unavailable or malformed.'); + } + return membership; + }, + { description: 'content-side project membership', maxPages: 2 }, + )) { + const item = page.nodes.find(candidate => candidate.project?.id === project.id && candidate.content?.id === contentId); + if (item) { + if (typeof item.id !== 'string' || !item.id.trim()) throw new Error('The content-side Project item identity is malformed.'); + return item.id; + } + } return undefined; }