From 5d3fa664ea281294cadd2f82e05fdb681fd50a68 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 23:10:19 +0100 Subject: [PATCH 1/4] bugfix-849-issue-assignment-loses-organization-lookup-diagnostics-behind-opaque-retryable-provider-unavailable: retain assignment diagnostics and safe retries --- build/api/index.js | 51 +- .../api/src/data/model/application_error.d.ts | 4 +- .../domain/issue_assignment_diagnostic.d.ts | 19 + build/cli/index.js | 470 ++++++++++++++---- build/deployment_recovery/index.js | 100 +++- build/github_action/index.js | 470 ++++++++++++++---- docs/issues/assignees-and-projects.mdx | 34 ++ .../operations/error-reference.mdx | 19 + scripts/coverage-budgets.json | 16 + specs/CATALOG.md | 12 +- specs/catalog.json | 36 +- specs/issue-assignment-failure-recovery.md | 287 +++++++++++ specs/managed-issue-and-branch-lifecycle.md | 7 + .../errors/issue_assignment_failure.ts | 16 + ...lication_error_presentation_policy.test.ts | 3 + .../issue_assignment_messages.test.ts | 37 ++ .../application_error_message_catalog.ts | 6 + .../application_error_presentation_policy.ts | 5 +- .../policies/assignee_assignment_policy.ts | 3 +- ...ssue_assignment_diagnostic_presentation.ts | 27 + .../policies/issue_assignment_messages.ts | 32 ++ .../ports/assignment_provider_error.ts | 13 + .../assign_members_to_issue_use_case.test.ts | 2 +- .../steps/issue/assign_members_workflow.ts | 53 +- src/data/model/application_error.ts | 9 + .../assignment_provider_contract.test.ts | 82 +++ .../issue_assignment_repository.test.ts | 2 +- .../organization_members_repository.test.ts | 2 +- .../assignment_provider_failure.test.ts | 65 +++ .../github/assignment_provider_failure.ts | 30 ++ .../issue/issue_assignment_repository.ts | 53 +- .../organization_members_query.ts | 57 ++- .../organization_members_repository.ts | 20 +- .../issue_assignment_diagnostic.test.ts | 56 +++ src/domain/issue_assignment_diagnostic.ts | 44 ++ .../issue_assignment_diagnostics.test.ts | 162 ++++++ 36 files changed, 2001 insertions(+), 303 deletions(-) create mode 100644 build/api/src/domain/issue_assignment_diagnostic.d.ts create mode 100644 specs/issue-assignment-failure-recovery.md create mode 100644 src/application/errors/issue_assignment_failure.ts create mode 100644 src/application/policies/__tests__/issue_assignment_messages.test.ts create mode 100644 src/application/policies/issue_assignment_diagnostic_presentation.ts create mode 100644 src/application/policies/issue_assignment_messages.ts create mode 100644 src/application/ports/assignment_provider_error.ts create mode 100644 src/data/repository/__tests__/assignment_provider_contract.test.ts create mode 100644 src/data/repository/github/__tests__/assignment_provider_failure.test.ts create mode 100644 src/data/repository/github/assignment_provider_failure.ts create mode 100644 src/domain/__tests__/issue_assignment_diagnostic.test.ts create mode 100644 src/domain/issue_assignment_diagnostic.ts create mode 100644 src/tooling/__tests__/issue_assignment_diagnostics.test.ts diff --git a/build/api/index.js b/build/api/index.js index a013e2f26..0e84c22fa 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -7050,6 +7050,7 @@ const progress_partition_length_failure_1 = __nccwpck_require__(9292); const progress_binary_evidence_diagnostic_1 = __nccwpck_require__(364); const issue_workflow_diagnostic_1 = __nccwpck_require__(4347); const change_size_reconciliation_1 = __nccwpck_require__(5304); +const issue_assignment_diagnostic_1 = __nccwpck_require__(2616); const copilot_command_validation_1 = __nccwpck_require__(4237); const closed_issue_reconciliation_1 = __nccwpck_require__(1565); const approval_evidence_diagnostic_1 = __nccwpck_require__(8205); @@ -7088,6 +7089,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -7270,9 +7272,11 @@ class ApplicationError extends Error { this.action = metadata.action; this.retainedState = options.recovery?.id === 'change-size-reconciliation-failed' ? 'Completed effects were retained; the failed and later size operations remain unverified.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'issue-assignment-failed' + ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -7315,6 +7319,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'issue-workflow-admission-blocked': Object.freeze(['kind', 'reason', 'fields']), 'change-size-reconciliation-failed': Object.freeze(['operation', 'issueNumber', 'targetNumber', 'projectIndex', 'verifiedOperations', 'pendingProjectSizes', 'providerStatus', 'providerReference']), + 'issue-assignment-failed': Object.freeze(['targetNumber', 'desiredCount', 'stage', 'operation', 'providerStatus', 'assigneeRead', 'observedAssignees', 'confirmedAssignments', 'remainingAssignments', 'mutation']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -7455,6 +7460,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'change-size-reconciliation-failed' && !(0, change_size_reconciliation_1.isChangeSizeReconciliationFacts)(variables)) { throw new TypeError('Change size reconciliation diagnostic is invalid.'); } + if (recovery.id === 'issue-assignment-failed' && !(0, issue_assignment_diagnostic_1.isIssueAssignmentDiagnostic)(variables)) { + throw new TypeError('Issue assignment diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), @@ -8586,6 +8594,43 @@ function githubUsersMatch(left, right) { } +/***/ }), + +/***/ 2616: +/***/ ((__unused_webpack_module, exports) => { + + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ASSIGNMENT_OPERATIONS = exports.ASSIGNMENT_STAGES = void 0; +exports.isAssignmentProviderStatus = isAssignmentProviderStatus; +exports.isIssueAssignmentDiagnostic = isIssueAssignmentDiagnostic; +exports.ASSIGNMENT_STAGES = Object.freeze(['read-assignees', 'lookup-members', 'select-members', 'assign-creator', 'assign-members']); +exports.ASSIGNMENT_OPERATIONS = Object.freeze(['read-assignees', 'list-teams', 'list-team-members', 'add-assignees', 'initialize-client', 'unavailable']); +function isAssignmentProviderStatus(value) { + return typeof value === 'string' && (value === 'http-unavailable' || /^http-[1-5][0-9]{2}$/u.test(value)); +} +function isIssueAssignmentDiagnostic(value) { + return Number.isSafeInteger(value.targetNumber) && Number(value.targetNumber) > 0 + && Number.isInteger(value.desiredCount) && Number(value.desiredCount) >= 1 && Number(value.desiredCount) <= 10 + && exports.ASSIGNMENT_STAGES.includes(value.stage) + && exports.ASSIGNMENT_OPERATIONS.includes(value.operation) + && isAssignmentProviderStatus(value.providerStatus) + && ['verified', 'unavailable'].includes(value.assigneeRead) + && ['not-attempted', 'unverified', 'verified'].includes(value.mutation) + && validCount(value.confirmedAssignments) && Number(value.confirmedAssignments) <= Number(value.desiredCount) + && (value.mutation !== 'not-attempted' || value.confirmedAssignments === 0) + && (value.mutation !== 'verified' || Number(value.confirmedAssignments) > 0) + && (value.assigneeRead === 'unavailable' + ? value.observedAssignees === 'unavailable' && value.remainingAssignments === 'unavailable' + && value.confirmedAssignments === 0 && value.mutation === 'not-attempted' && value.stage === 'read-assignees' + : validCount(value.observedAssignees) && validCount(value.remainingAssignments) + && value.remainingAssignments === Math.max(0, Number(value.desiredCount) - Number(value.observedAssignees) - Number(value.confirmedAssignments))); +} +function validCount(value) { + return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 10; +} + + /***/ }), /***/ 9376: diff --git a/build/api/src/data/model/application_error.d.ts b/build/api/src/data/model/application_error.d.ts index 3ce47d45c..380d74485 100644 --- a/build/api/src/data/model/application_error.d.ts +++ b/build/api/src/data/model/application_error.d.ts @@ -8,6 +8,7 @@ import { type ProgressPartitionLengthDiagnostic } from '../../domain/progress_pa import { type ProgressBinaryEvidenceFacts } from '../../domain/progress_binary_evidence_diagnostic'; import { type IssueWorkflowDiagnosticFacts } from '../../domain/issue_workflow_diagnostic'; import { type ChangeSizeReconciliationFacts } from '../../domain/change_size_reconciliation'; +import { type IssueAssignmentDiagnostic } from '../../domain/issue_assignment_diagnostic'; import { type CopilotCommandValidationReason } from '../../domain/copilot_command_validation'; import type { CopilotCommandName } from '../../domain/copilot_command'; import { type ClosedIssueReconciliationFacts } from '../../domain/closed_issue_reconciliation'; @@ -18,7 +19,7 @@ import { type BranchSyncCatalogDiagnostic } from '../../domain/branch_sync_catal import { type SddSourceContextDiagnostic } from '../../domain/agent_source_snapshot'; export type ApplicationErrorKind = 'configuration' | 'authorization' | 'provider' | 'agent' | 'validation' | 'workflow' | 'unknown'; export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'validation.evidence-too-large' | 'validation.evidence-unavailable' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'workflow.presentation-pending' | 'timeout' | 'unexpected'; -export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["comment-command-length", "comment-command-name-missing", "comment-command-name-unknown", "comment-command-argument-count", "comment-command-arguments-unexpected", "comment-command-arguments-missing", "comment-command-finding-quoting", "comment-command-review-format", "comment-command-review-effort", "comment-command-review-boolean", "comment-command-review-option", "comment-command-sync-option", "comment-command-sync-parent", "pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "branch-sync-verification-failed", "branch-sync-catalog-incompatible", "branch-sync-workflow-write-required", "branch-sync-publication-failed", "sdd-draft-size-exceeded", "sdd-catalog-unsupported-profile", "sdd-catalog-format-invalid", "sdd-catalog-missing-trusted-source", "sdd-source-context-unavailable", "sdd-draft-format-invalid", "sdd-draft-case-identity-invalid", "sdd-draft-case-budget-invalid", "sdd-draft-requirement-mapping-invalid", "sdd-draft-repository-path-invalid", "deployment-publication-context-missing", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "change-size-reconciliation-failed", "issue-workflow-admission-blocked", "progress-binary-evidence-unavailable", "progress-inventory-incomplete", "bugbot-review-incomplete", "progress-result-contract-invalid", "progress-partition-length-invalid", "progress-assessment-capacity-exceeded", "progress-scope-clause-limit", "progress-completion-recovery-exhausted", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; +export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["comment-command-length", "comment-command-name-missing", "comment-command-name-unknown", "comment-command-argument-count", "comment-command-arguments-unexpected", "comment-command-arguments-missing", "comment-command-finding-quoting", "comment-command-review-format", "comment-command-review-effort", "comment-command-review-boolean", "comment-command-review-option", "comment-command-sync-option", "comment-command-sync-parent", "pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "branch-sync-verification-failed", "branch-sync-catalog-incompatible", "branch-sync-workflow-write-required", "branch-sync-publication-failed", "sdd-draft-size-exceeded", "sdd-catalog-unsupported-profile", "sdd-catalog-format-invalid", "sdd-catalog-missing-trusted-source", "sdd-source-context-unavailable", "sdd-draft-format-invalid", "sdd-draft-case-identity-invalid", "sdd-draft-case-budget-invalid", "sdd-draft-requirement-mapping-invalid", "sdd-draft-repository-path-invalid", "deployment-publication-context-missing", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "change-size-reconciliation-failed", "issue-assignment-failed", "issue-workflow-admission-blocked", "progress-binary-evidence-unavailable", "progress-inventory-incomplete", "bugbot-review-incomplete", "progress-result-contract-invalid", "progress-partition-length-invalid", "progress-assessment-capacity-exceeded", "progress-scope-clause-limit", "progress-completion-recovery-exhausted", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables extends Readonly>): boolean; diff --git a/build/cli/index.js b/build/cli/index.js index e0ea4b9ff..57c29e018 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -38479,6 +38479,30 @@ class DeploymentMergeEffectError extends application_error_1.ApplicationError { exports.DeploymentMergeEffectError = DeploymentMergeEffectError; +/***/ }), + +/***/ 95252: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.issueAssignmentFailure = issueAssignmentFailure; +const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_error_1 = __nccwpck_require__(62823); +/** Add application-owned progress without losing the originating safe category or correlation. */ +function issueAssignmentFailure(cause, facts) { + return new application_error_1.ApplicationError(cause instanceof application_error_1.ApplicationError ? cause.code : 'unexpected', 'Automatic member assignment stopped.', { + ...(cause instanceof application_error_1.ApplicationError ? { retryable: cause.retryable, correlationId: cause.correlationId } : {}), + recovery: { id: 'issue-assignment-failed', variables: { ...facts, + operation: cause instanceof assignment_provider_error_1.AssignmentProviderError ? cause.operation + : facts.stage === 'assign-creator' || facts.stage === 'assign-members' ? 'add-assignees' + : facts.stage === 'read-assignees' ? 'read-assignees' : 'unavailable', + providerStatus: cause instanceof assignment_provider_error_1.AssignmentProviderError ? cause.providerStatus : 'http-unavailable' } }, + }); +} + + /***/ }), /***/ 6519: @@ -39651,6 +39675,7 @@ exports.readEnglishApplicationErrorMessage = exports.APPLICATION_ERROR_CATALOG_D exports.resolveStaticApplicationErrorCatalog = resolveStaticApplicationErrorCatalog; exports.resolveApplicationErrorCatalog = resolveApplicationErrorCatalog; const branch_sync_publication_messages_1 = __nccwpck_require__(3948); +const issue_assignment_messages_1 = __nccwpck_require__(71737); const bugbot_review_diagnostic_labels_1 = __nccwpck_require__(20351); const comment_command_validation_message_catalog_1 = __nccwpck_require__(62002); const application_error_1 = __nccwpck_require__(97790); @@ -39659,6 +39684,7 @@ const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, + ...issue_assignment_messages_1.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); @@ -39672,6 +39698,7 @@ exports.APPLICATION_ERROR_MESSAGE_IDS = Object.freeze([ const ENGLISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.ENGLISH_REVIEW_DIAGNOSTIC_LABELS, ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...issue_assignment_messages_1.ENGLISH_ASSIGNMENT_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -39684,6 +39711,7 @@ const ENGLISH_LABELS = Object.freeze({ const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...issue_assignment_messages_1.SPANISH_ASSIGNMENT_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -39822,6 +39850,7 @@ const STORED_CONFIGURATION_ACTION_ES = 'Un mantenedor debe revisar {recoveryGuid const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored configuration were not changed by this read.'; const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ + ...issue_assignment_messages_1.ENGLISH_ASSIGNMENT_CONTENT, ...comment_command_validation_message_catalog_1.ENGLISH_COMMAND_VALIDATION_CONTENT, ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ @@ -40021,6 +40050,7 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }), }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ + ...issue_assignment_messages_1.SPANISH_ASSIGNMENT_CONTENT, ...comment_command_validation_message_catalog_1.SPANISH_COMMAND_VALIDATION_CONTENT, ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ @@ -40284,43 +40314,46 @@ const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/secu const BRANCH_SYNC_CATALOG_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-catalog-compatibility'; const BRANCH_SYNC_PUBLICATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery'; const agent_source_snapshot_1 = __nccwpck_require__(59205); +const issue_assignment_diagnostic_presentation_1 = __nccwpck_require__(99446); /** Shared semantic view model for terminal, GitHub, and API presentation. */ function buildApplicationErrorPresentation(error, message = application_error_message_catalog_1.readEnglishApplicationErrorMessage) { const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` : `error.${error.code}`; - const variables = error.recovery?.id === 'sdd-source-context-unavailable' - ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } - : error.recovery?.id === 'progress-partition-length-invalid' - ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) - : error.recovery?.id === 'bugbot-review-incomplete' - ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', - resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), - unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } - : error.recovery?.id === 'progress-inventory-incomplete' - ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) - : error.recovery?.id === 'progress-binary-evidence-unavailable' - ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) - : error.recovery?.id === 'issue-workflow-admission-blocked' - ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) - : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) - ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) - : error.recovery?.id === 'branch-sync-verification-failed' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } - : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' - ? { - reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, - candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, - pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), - remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' - : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), - publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', - } - : error.recovery?.id === 'branch-sync-catalog-incompatible' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } - : error.recovery?.id.startsWith('stored-configuration-') - ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } - : error.recovery?.variables; + const variables = error.recovery?.id === 'issue-assignment-failed' + ? (0, issue_assignment_diagnostic_presentation_1.assignmentDiagnosticPresentation)(error.recovery.variables, error.code, message) + : error.recovery?.id === 'sdd-source-context-unavailable' + ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } + : error.recovery?.id === 'progress-partition-length-invalid' + ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) + : error.recovery?.id === 'bugbot-review-incomplete' + ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', + resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), + unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } + : error.recovery?.id === 'progress-inventory-incomplete' + ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) + : error.recovery?.id === 'progress-binary-evidence-unavailable' + ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) + : error.recovery?.id === 'issue-workflow-admission-blocked' + ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) + : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) + ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) + : error.recovery?.id === 'branch-sync-verification-failed' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } + : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' + ? { + reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, + candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, + pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), + remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' + : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), + publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', + } + : error.recovery?.id === 'branch-sync-catalog-incompatible' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } + : error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -40500,7 +40533,8 @@ function calculateRemainingAssignees(desiredCount, currentCount, creatorAssigned } function selectConfirmedAssignees(requestedMembers, assignedMembers) { const requestedIdentities = new Set(requestedMembers.map((member) => member.toLowerCase())); - return assignedMembers.filter((member) => requestedIdentities.has(member.toLowerCase())); + return [...new Map(assignedMembers.filter((member) => requestedIdentities.has(member.toLowerCase())) + .map(member => [member.toLowerCase(), member])).values()]; } @@ -46122,6 +46156,79 @@ function buildInitialLabelProvisioningPlan(labels, existingLabelNames) { } +/***/ }), + +/***/ 99446: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.assignmentDiagnosticPresentation = assignmentDiagnosticPresentation; +const STAGES = { + 'read-assignees': 'assignmentRead', 'lookup-members': 'assignmentLookup', 'select-members': 'assignmentSelect', + 'assign-creator': 'assignmentCreator', 'assign-members': 'assignmentMembers', +}; +const OPERATIONS = { + 'read-assignees': 'assignmentReadOperation', 'list-teams': 'assignmentTeams', 'list-team-members': 'assignmentTeamMembers', + 'add-assignees': 'assignmentAdd', 'initialize-client': 'assignmentClient', unavailable: 'assignmentUnavailable', +}; +function assignmentDiagnosticPresentation(facts, code, message) { + return { ...facts, + stage: message(`error.label.${STAGES[facts.stage]}`), operation: message(`error.label.${OPERATIONS[facts.operation]}`), + providerStatus: facts.providerStatus === 'http-unavailable' ? message('error.label.assignmentUnavailable') : facts.providerStatus, + assigneeRead: message(facts.assigneeRead === 'verified' ? 'error.label.assignmentReadVerified' : 'error.label.assignmentUnavailable'), + remainingAssignments: facts.assigneeRead === 'verified' ? facts.remainingAssignments : message('error.label.assignmentUnavailable'), + mutation: message(facts.mutation === 'not-attempted' ? 'error.label.assignmentWriteNone' + : facts.mutation === 'unverified' ? 'error.label.assignmentWriteUnverified' : 'error.label.assignmentWriteVerified'), + guidance: code.startsWith('authorization.') || code === 'provider.not-found' + ? message('error.label.assignmentAccess') : message(`error.${code}.action`), + }; +} + + +/***/ }), + +/***/ 71737: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_ASSIGNMENT_CONTENT = exports.ENGLISH_ASSIGNMENT_CONTENT = exports.SPANISH_ASSIGNMENT_LABELS = exports.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS = exports.ENGLISH_ASSIGNMENT_LABELS = void 0; +exports.ENGLISH_ASSIGNMENT_LABELS = { + assignmentRead: 'reading current assignees', assignmentLookup: 'checking organization membership', + assignmentSelect: 'selecting additional members', assignmentCreator: 'assigning the creator', assignmentMembers: 'assigning additional members', + assignmentReadOperation: 'issue assignees', assignmentTeams: 'organization teams', assignmentTeamMembers: 'organization team members', + assignmentAdd: 'add issue assignees', assignmentClient: 'provider client initialization', assignmentUnavailable: 'unavailable', + assignmentReadVerified: 'verified', assignmentWriteNone: 'not attempted', assignmentWriteUnverified: 'outcome unverified; it may have applied', + assignmentWriteVerified: 'confirmed', assignmentAccess: 'Check the configured credential, documented access and target resource before retrying. A 404 alone does not establish that a private resource is absent.', +}; +exports.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS = Object.keys(exports.ENGLISH_ASSIGNMENT_LABELS); +exports.SPANISH_ASSIGNMENT_LABELS = { + assignmentRead: 'leer las personas asignadas', assignmentLookup: 'consultar los miembros de la organización', + assignmentSelect: 'seleccionar miembros adicionales', assignmentCreator: 'asignar al autor', assignmentMembers: 'asignar miembros adicionales', + assignmentReadOperation: 'personas asignadas a la issue', assignmentTeams: 'equipos de la organización', assignmentTeamMembers: 'miembros de los equipos', + assignmentAdd: 'añadir personas asignadas', assignmentClient: 'inicialización del cliente', assignmentUnavailable: 'no disponible', + assignmentReadVerified: 'verificada', assignmentWriteNone: 'no intentada', assignmentWriteUnverified: 'resultado sin verificar; pudo aplicarse', + assignmentWriteVerified: 'confirmada', assignmentAccess: 'Comprueba la credencial configurada, el acceso documentado y el recurso antes de reintentar. Un 404 por sí solo no demuestra que un recurso privado no exista.', +}; +exports.ENGLISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'Automatic assignment for #{targetNumber} stopped during {stage}. Provider operation: {operation}; status: {providerStatus}.', + action: '{guidance} Rerun the same issue workflow after addressing the cause; it rereads assignees before adding missing members.', + retainedState: 'Verified new assignments: {confirmedAssignments}. Remaining target count: {remainingAssignments}. Current assignee read: {assigneeRead}. Assignment write: {mutation}. Independent completed lifecycle effects are retained in their own results.', + }, +}; +exports.SPANISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'La asignación automática de #{targetNumber} se detuvo al {stage}. Operación del proveedor: {operation}; estado: {providerStatus}.', + action: '{guidance} Repite el mismo workflow tras resolver la causa; volverá a leer las personas asignadas antes de añadir las pendientes.', + retainedState: 'Asignaciones nuevas verificadas: {confirmedAssignments}. Objetivo pendiente: {remainingAssignments}. Lectura de personas asignadas: {assigneeRead}. Escritura de asignaciones: {mutation}. Los efectos independientes completados se conservan en sus propios resultados.', + }, +}; + + /***/ }), /***/ 61899: @@ -56975,6 +57082,31 @@ exports.AGENT_EXECUTION_OUTPUT_DIAGNOSTICS = [ ]; +/***/ }), + +/***/ 62823: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.AssignmentProviderError = void 0; +const application_error_1 = __nccwpck_require__(75999); +const issue_assignment_diagnostic_1 = __nccwpck_require__(52616); +/** Sanitized adapter evidence. Provider messages, bodies, member lists and credentials are discarded. */ +class AssignmentProviderError extends application_error_1.ApplicationError { + constructor(code, operation, providerStatus, options = {}) { + super(code, 'The assignment provider operation failed.', options); + this.operation = operation; + this.providerStatus = providerStatus; + if (!issue_assignment_diagnostic_1.ASSIGNMENT_OPERATIONS.includes(operation) || !(0, issue_assignment_diagnostic_1.isAssignmentProviderStatus)(providerStatus)) { + throw new TypeError('Assignment provider evidence is invalid.'); + } + } +} +exports.AssignmentProviderError = AssignmentProviderError; + + /***/ }), /***/ 85930: @@ -75630,45 +75762,74 @@ const logging_ports_1 = __nccwpck_require__(6152); const task_emoji_1 = __nccwpck_require__(46103); const assignee_assignment_policy_1 = __nccwpck_require__(85918); const application_error_1 = __nccwpck_require__(75999); +const issue_assignment_failure_1 = __nccwpck_require__(95252); const TASK_ID = 'AssignMemberToIssueUseCase'; /** Assigns the creator and remaining project members according to the pure assignment policy. */ async function runAssignMembersWorkflow(param, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(TASK_ID)} Executing ${TASK_ID}.`); const target = (0, assignee_assignment_policy_1.resolveAssigneeTarget)(param); const results = []; + let stage = 'read-assignees'; + let assigneeRead = 'unavailable'; + let mutation = 'not-attempted'; + let observedAssignees = 0; + const confirmed = []; try { (0, logging_ports_1.logDebugInfo)(`#${target.number} needs ${target.desiredCount} assignees.`); if (target.desiredCount <= 0) return [new result_1.Result({ id: TASK_ID, success: true, executed: false })]; if (target.number <= 0) return [assignmentResult(false, 'Issue or pull request number is not available.')]; - const [currentProjectMembers, currentMembers] = await Promise.all([ - dependencies.projectRepository.getAllMembers(), - dependencies.issueRepository.getCurrentAssignees(target.number), - ]); + const currentMembers = await dependencies.issueRepository.getCurrentAssignees(target.number); + observedAssignees = new Set(currentMembers.map(member => member.toLowerCase())).size; + assigneeRead = 'verified'; + if (observedAssignees >= target.desiredCount) + return [new result_1.Result({ id: TASK_ID, success: true, executed: true })]; + stage = 'lookup-members'; + const currentProjectMembers = await dependencies.projectRepository.getAllMembers(); const creatorAssignment = (0, assignee_assignment_policy_1.resolveCreatorAssignment)(param, currentProjectMembers, currentMembers); if (creatorAssignment) { const { login: creator, source } = creatorAssignment; - await dependencies.issueRepository.assignMembersToIssue(target.number, [creator]); + stage = 'assign-creator'; + mutation = 'unverified'; + const added = await dependencies.issueRepository.assignMembersToIssue(target.number, [creator]); + if ((0, assignee_assignment_policy_1.selectConfirmedAssignees)([creator], added).length === 0) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'The creator assignment was not confirmed.'); + } + confirmed.push(creator); + mutation = 'verified'; (0, logging_ports_1.logDebugInfo)(`Assigned ${source} creator @${creator} to #${target.number}.`); results.push(assignmentResult(true, `The ${source} was assigned to @${creator} (creator).`)); } - const remainingAssignees = (0, assignee_assignment_policy_1.calculateRemainingAssignees)(target.desiredCount, currentMembers.length, creatorAssignment !== undefined); + const remainingAssignees = (0, assignee_assignment_policy_1.calculateRemainingAssignees)(target.desiredCount, observedAssignees, creatorAssignment !== undefined); if (remainingAssignees <= 0) { results.push(new result_1.Result({ id: TASK_ID, success: true, executed: true })); return results; } - const members = await dependencies.projectRepository.getRandomMembers(remainingAssignees, currentMembers); + stage = 'select-members'; + const members = await dependencies.projectRepository.getRandomMembers(remainingAssignees, [...currentMembers, ...confirmed]); if (members.length === 0) { results.push(assignmentResult(false, 'Tried to assign members to issue, but no one was found.')); return results; } + stage = 'assign-members'; + mutation = 'unverified'; const membersAdded = await dependencies.issueRepository.assignMembersToIssue(target.number, members); - results.push(...(0, assignee_assignment_policy_1.selectConfirmedAssignees)(members, membersAdded).map((member) => assignmentResult(true, `The ${param.target} was assigned to @${member}.`))); + const verifiedMembers = (0, assignee_assignment_policy_1.selectConfirmedAssignees)(members, membersAdded); + confirmed.push(...verifiedMembers); + results.push(...verifiedMembers.map((member) => assignmentResult(true, `The ${param.target} was assigned to @${member}.`))); + if (verifiedMembers.length !== members.length) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'Some requested assignments were not confirmed.'); + } + mutation = 'verified'; return results; } catch (error) { - const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to assign members.'); + const semanticError = (0, issue_assignment_failure_1.issueAssignmentFailure)((0, application_error_1.toApplicationError)(error, 'unexpected', 'Automatic member assignment stopped.'), { + targetNumber: target.number, desiredCount: target.desiredCount, stage, assigneeRead, mutation, + observedAssignees: assigneeRead === 'verified' ? observedAssignees : 'unavailable', confirmedAssignments: confirmed.length, + remainingAssignments: assigneeRead === 'verified' ? Math.max(0, target.desiredCount - observedAssignees - confirmed.length) : 'unavailable', + }); (0, logging_ports_1.logError)(semanticError); results.push(new result_1.Result({ id: TASK_ID, @@ -75685,7 +75846,7 @@ function assignmentResult(success, step) { id: TASK_ID, success, executed: true, - steps: step ? [step] : [], + steps: [step], }); } @@ -83379,6 +83540,7 @@ const progress_partition_length_failure_1 = __nccwpck_require__(59292); const progress_binary_evidence_diagnostic_1 = __nccwpck_require__(90364); const issue_workflow_diagnostic_1 = __nccwpck_require__(14347); const change_size_reconciliation_1 = __nccwpck_require__(25304); +const issue_assignment_diagnostic_1 = __nccwpck_require__(52616); const copilot_command_validation_1 = __nccwpck_require__(44237); const closed_issue_reconciliation_1 = __nccwpck_require__(21565); const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); @@ -83417,6 +83579,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -83599,9 +83762,11 @@ class ApplicationError extends Error { this.action = metadata.action; this.retainedState = options.recovery?.id === 'change-size-reconciliation-failed' ? 'Completed effects were retained; the failed and later size operations remain unverified.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'issue-assignment-failed' + ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -83644,6 +83809,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'issue-workflow-admission-blocked': Object.freeze(['kind', 'reason', 'fields']), 'change-size-reconciliation-failed': Object.freeze(['operation', 'issueNumber', 'targetNumber', 'projectIndex', 'verifiedOperations', 'pendingProjectSizes', 'providerStatus', 'providerReference']), + 'issue-assignment-failed': Object.freeze(['targetNumber', 'desiredCount', 'stage', 'operation', 'providerStatus', 'assigneeRead', 'observedAssignees', 'confirmedAssignments', 'remainingAssignments', 'mutation']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -83784,6 +83950,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'change-size-reconciliation-failed' && !(0, change_size_reconciliation_1.isChangeSizeReconciliationFacts)(variables)) { throw new TypeError('Change size reconciliation diagnostic is invalid.'); } + if (recovery.id === 'issue-assignment-failed' && !(0, issue_assignment_diagnostic_1.isIssueAssignmentDiagnostic)(variables)) { + throw new TypeError('Issue assignment diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), @@ -89177,6 +89346,47 @@ class GitCliRepository { exports.GitCliRepository = GitCliRepository; +/***/ }), + +/***/ 86172: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.withAssignmentProviderFailure = withAssignmentProviderFailure; +const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_error_1 = __nccwpck_require__(62823); +const github_error_policy_1 = __nccwpck_require__(58791); +/** Terminate untrusted REST/transport failures at the adapter boundary, without retaining the cause. */ +async function withAssignmentProviderFailure(operation, perform) { + try { + return await perform(); + } + catch (cause) { + if (cause instanceof assignment_provider_error_1.AssignmentProviderError) + throw cause; + const error = record(cause), response = record(error.response); + const status = [error.status, response.status].find(value => typeof value === 'number' + && Number.isInteger(value) && value >= 100 && value <= 599); + const code = cause instanceof application_error_1.ApplicationError ? cause.code + : (0, github_error_policy_1.isGithubRateLimited)({ status, response: { headers: response.headers ?? error.headers }, message: error.message }) ? 'provider.rate-limited' + : status === 401 ? 'authorization.credential-invalid' + : status === 403 ? 'authorization.denied' + : status === 404 ? 'provider.not-found' + : status === 409 || status === 412 ? 'provider.conflict' + : status === 400 || status === 422 ? 'provider.contract-invalid' + : status === 408 || typeof status === 'number' && status >= 500 + || ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN'].includes(error.code) + ? 'provider.unavailable' : 'unexpected'; + throw new assignment_provider_error_1.AssignmentProviderError(code, operation, status === undefined ? 'http-unavailable' : `http-${status}`, cause instanceof application_error_1.ApplicationError ? { retryable: cause.retryable, correlationId: cause.correlationId } : {}); + } +} +function record(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}; +} + + /***/ }), /***/ 5299: @@ -90457,40 +90667,48 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.IssueAssignmentRepository = void 0; const logger_1 = __nccwpck_require__(91151); const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_failure_1 = __nccwpck_require__(86172); class IssueAssignmentRepository { constructor(githubClient) { this.githubClient = githubClient; this.getCurrentAssignees = async (owner, repository, issueNumber, token) => { - const octokit = this.githubClient.getClient(token); - try { - const { data: issue } = await octokit.rest.issues.get({ owner, repo: repository, issue_number: issueNumber }); - return (issue.assignees ?? []).map(assignee => assignee.login); - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to get issue assignees.')); - throw error; - } + const octokit = await (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => this.githubClient.getClient(token)); + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('read-assignees', async () => { + const response = await octokit.rest.issues.get({ owner, repo: repository, issue_number: issueNumber }); + return assigneesFromIssue(response?.data); + }); }; this.assignMembersToIssue = async (owner, repository, issueNumber, members, token) => { - const octokit = this.githubClient.getClient(token); - try { - if (members.length === 0) { - (0, logger_1.logDebugInfo)('No members provided for assignment. Skipping operation.'); - return []; - } - const { data: updatedIssue } = await octokit.rest.issues.addAssignees({ + if (members.length === 0) { + (0, logger_1.logDebugInfo)('No members provided for assignment. Skipping operation.'); + return []; + } + const octokit = await (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => this.githubClient.getClient(token)); + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('add-assignees', async () => { + const response = await octokit.rest.issues.addAssignees({ owner, repo: repository, issue_number: issueNumber, assignees: members, }); - return (updatedIssue.assignees ?? []).map(assignee => assignee.login); - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to assign issue members.')); - throw error; - } + return assigneesFromIssue(response?.data); + }); }; } } exports.IssueAssignmentRepository = IssueAssignmentRepository; +function assigneesFromIssue(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw malformedAssignees(); + const assignees = value.assignees ?? []; + if (!Array.isArray(assignees) || assignees.length > 10) + throw malformedAssignees(); + return assignees.map(assignee => { + if (typeof assignee !== 'object' || assignee === null || typeof assignee.login !== 'string' || assignee.login.trim().length === 0) + throw malformedAssignees(); + return assignee.login; + }); +} +function malformedAssignees() { + return new application_error_1.ApplicationError('provider.contract-invalid', 'Issue assignee response is malformed.'); +} /***/ }), @@ -91770,38 +91988,50 @@ exports.AuthenticatedUserRepository = AuthenticatedUserRepository; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.listOrganizationTeams = listOrganizationTeams; exports.listOrganizationTeamMembers = listOrganizationTeamMembers; -const github_pagination_policy_1 = __nccwpck_require__(44812); +const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_failure_1 = __nccwpck_require__(86172); async function listOrganizationTeams(client, organization) { - const teams = []; - for await (const response of client.paginate.iterator(client.rest.teams.list, { - org: organization, - per_page: 100, - })) { - const page = (0, github_pagination_policy_1.requireArrayPage)(response.data, 'organization teams'); - teams.push(...page.flatMap((team) => isTeam(team) ? [team] : [])); - } - return teams; + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('list-teams', async () => { + const teams = []; + for await (const response of client.paginate.iterator(client.rest.teams.list, { + org: organization, + per_page: 100, + })) { + const page = response?.data; + if (!Array.isArray(page) || !page.every(isTeam)) + throw malformedMembers(); + teams.push(...page); + } + return teams; + }); } async function listOrganizationTeamMembers(client, organization, teamSlug) { - const members = []; - for await (const response of client.paginate.iterator(client.rest.teams.listMembersInOrg, { - org: organization, - team_slug: teamSlug, - per_page: 100, - })) { - const page = (0, github_pagination_policy_1.requireArrayPage)(response.data, 'organization team members'); - members.push(...page.flatMap((member) => isMember(member) ? [member] : [])); - } - return members; + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('list-team-members', async () => { + const members = []; + for await (const response of client.paginate.iterator(client.rest.teams.listMembersInOrg, { + org: organization, + team_slug: teamSlug, + per_page: 100, + })) { + const page = response?.data; + if (!Array.isArray(page) || !page.every(isMember)) + throw malformedMembers(); + members.push(...page); + } + return members; + }); } function isTeam(value) { - return isRecord(value) && typeof value.slug === 'string'; + return isRecord(value) && typeof value.slug === 'string' && value.slug.trim().length > 0; } function isMember(value) { - return isRecord(value) && typeof value.login === 'string'; + return isRecord(value) && typeof value.login === 'string' && value.login.trim().length > 0; } function isRecord(value) { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function malformedMembers() { + return new application_error_1.ApplicationError('provider.contract-invalid', 'Organization membership response is malformed.'); } @@ -91817,14 +92047,14 @@ exports.OrganizationMembersRepository = void 0; const logger_1 = __nccwpck_require__(91151); const project_members_policy_1 = __nccwpck_require__(41370); const organization_members_query_1 = __nccwpck_require__(84916); -const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_failure_1 = __nccwpck_require__(86172); class OrganizationMembersRepository { constructor(githubClient) { this.githubClient = githubClient; this.getRandomMembers = async (organization, membersToAdd, currentMembers, token) => { if (membersToAdd === 0) return []; - try { + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => { const client = this.githubClient.getClient(token); const teams = await (0, organization_members_query_1.listOrganizationTeams)(client, organization); if (teams.length === 0) { @@ -91837,26 +92067,18 @@ class OrganizationMembersRepository { (0, logger_1.logDebugInfo)(`No available members to assign for organization ${organization}.`); } return selectedMembers; - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to select organization members.')); - throw error; - } + }); }; this.getAllMembers = async (organization, token) => { - try { + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => { const client = this.githubClient.getClient(token); const teams = await (0, organization_members_query_1.listOrganizationTeams)(client, organization); if (teams.length === 0) { (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); return []; } - return (0, project_members_policy_1.collectOrganizationMembers)(teams, (teamSlug) => (0, organization_members_query_1.listOrganizationTeamMembers)(client, organization, teamSlug)); - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to list organization members.')); - throw error; - } + return await (0, project_members_policy_1.collectOrganizationMembers)(teams, (teamSlug) => (0, organization_members_query_1.listOrganizationTeamMembers)(client, organization, teamSlug)); + }); }; } } @@ -98261,6 +98483,44 @@ function parseDelimitedValues(value) { } +/***/ }), + +/***/ 52616: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ASSIGNMENT_OPERATIONS = exports.ASSIGNMENT_STAGES = void 0; +exports.isAssignmentProviderStatus = isAssignmentProviderStatus; +exports.isIssueAssignmentDiagnostic = isIssueAssignmentDiagnostic; +exports.ASSIGNMENT_STAGES = Object.freeze(['read-assignees', 'lookup-members', 'select-members', 'assign-creator', 'assign-members']); +exports.ASSIGNMENT_OPERATIONS = Object.freeze(['read-assignees', 'list-teams', 'list-team-members', 'add-assignees', 'initialize-client', 'unavailable']); +function isAssignmentProviderStatus(value) { + return typeof value === 'string' && (value === 'http-unavailable' || /^http-[1-5][0-9]{2}$/u.test(value)); +} +function isIssueAssignmentDiagnostic(value) { + return Number.isSafeInteger(value.targetNumber) && Number(value.targetNumber) > 0 + && Number.isInteger(value.desiredCount) && Number(value.desiredCount) >= 1 && Number(value.desiredCount) <= 10 + && exports.ASSIGNMENT_STAGES.includes(value.stage) + && exports.ASSIGNMENT_OPERATIONS.includes(value.operation) + && isAssignmentProviderStatus(value.providerStatus) + && ['verified', 'unavailable'].includes(value.assigneeRead) + && ['not-attempted', 'unverified', 'verified'].includes(value.mutation) + && validCount(value.confirmedAssignments) && Number(value.confirmedAssignments) <= Number(value.desiredCount) + && (value.mutation !== 'not-attempted' || value.confirmedAssignments === 0) + && (value.mutation !== 'verified' || Number(value.confirmedAssignments) > 0) + && (value.assigneeRead === 'unavailable' + ? value.observedAssignees === 'unavailable' && value.remainingAssignments === 'unavailable' + && value.confirmedAssignments === 0 && value.mutation === 'not-attempted' && value.stage === 'read-assignees' + : validCount(value.observedAssignees) && validCount(value.remainingAssignments) + && value.remainingAssignments === Math.max(0, Number(value.desiredCount) - Number(value.observedAssignees) - Number(value.confirmedAssignments))); +} +function validCount(value) { + return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 10; +} + + /***/ }), /***/ 31147: diff --git a/build/deployment_recovery/index.js b/build/deployment_recovery/index.js index 4deab592c..368d3c4a8 100644 --- a/build/deployment_recovery/index.js +++ b/build/deployment_recovery/index.js @@ -32362,6 +32362,7 @@ exports.readEnglishApplicationErrorMessage = exports.APPLICATION_ERROR_CATALOG_D exports.resolveStaticApplicationErrorCatalog = resolveStaticApplicationErrorCatalog; exports.resolveApplicationErrorCatalog = resolveApplicationErrorCatalog; const branch_sync_publication_messages_1 = __nccwpck_require__(3948); +const issue_assignment_messages_1 = __nccwpck_require__(1737); const bugbot_review_diagnostic_labels_1 = __nccwpck_require__(351); const comment_command_validation_message_catalog_1 = __nccwpck_require__(2002); const application_error_1 = __nccwpck_require__(7790); @@ -32370,6 +32371,7 @@ const resolved_message_catalog_policy_1 = __nccwpck_require__(5069); const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, + ...issue_assignment_messages_1.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); @@ -32383,6 +32385,7 @@ exports.APPLICATION_ERROR_MESSAGE_IDS = Object.freeze([ const ENGLISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.ENGLISH_REVIEW_DIAGNOSTIC_LABELS, ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...issue_assignment_messages_1.ENGLISH_ASSIGNMENT_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -32395,6 +32398,7 @@ const ENGLISH_LABELS = Object.freeze({ const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...issue_assignment_messages_1.SPANISH_ASSIGNMENT_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -32533,6 +32537,7 @@ const STORED_CONFIGURATION_ACTION_ES = 'Un mantenedor debe revisar {recoveryGuid const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored configuration were not changed by this read.'; const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ + ...issue_assignment_messages_1.ENGLISH_ASSIGNMENT_CONTENT, ...comment_command_validation_message_catalog_1.ENGLISH_COMMAND_VALIDATION_CONTENT, ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ @@ -32732,6 +32737,7 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }), }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ + ...issue_assignment_messages_1.SPANISH_ASSIGNMENT_CONTENT, ...comment_command_validation_message_catalog_1.SPANISH_COMMAND_VALIDATION_CONTENT, ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ @@ -33510,6 +33516,48 @@ function deploymentCopy(catalog) { } +/***/ }), + +/***/ 1737: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_ASSIGNMENT_CONTENT = exports.ENGLISH_ASSIGNMENT_CONTENT = exports.SPANISH_ASSIGNMENT_LABELS = exports.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS = exports.ENGLISH_ASSIGNMENT_LABELS = void 0; +exports.ENGLISH_ASSIGNMENT_LABELS = { + assignmentRead: 'reading current assignees', assignmentLookup: 'checking organization membership', + assignmentSelect: 'selecting additional members', assignmentCreator: 'assigning the creator', assignmentMembers: 'assigning additional members', + assignmentReadOperation: 'issue assignees', assignmentTeams: 'organization teams', assignmentTeamMembers: 'organization team members', + assignmentAdd: 'add issue assignees', assignmentClient: 'provider client initialization', assignmentUnavailable: 'unavailable', + assignmentReadVerified: 'verified', assignmentWriteNone: 'not attempted', assignmentWriteUnverified: 'outcome unverified; it may have applied', + assignmentWriteVerified: 'confirmed', assignmentAccess: 'Check the configured credential, documented access and target resource before retrying. A 404 alone does not establish that a private resource is absent.', +}; +exports.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS = Object.keys(exports.ENGLISH_ASSIGNMENT_LABELS); +exports.SPANISH_ASSIGNMENT_LABELS = { + assignmentRead: 'leer las personas asignadas', assignmentLookup: 'consultar los miembros de la organización', + assignmentSelect: 'seleccionar miembros adicionales', assignmentCreator: 'asignar al autor', assignmentMembers: 'asignar miembros adicionales', + assignmentReadOperation: 'personas asignadas a la issue', assignmentTeams: 'equipos de la organización', assignmentTeamMembers: 'miembros de los equipos', + assignmentAdd: 'añadir personas asignadas', assignmentClient: 'inicialización del cliente', assignmentUnavailable: 'no disponible', + assignmentReadVerified: 'verificada', assignmentWriteNone: 'no intentada', assignmentWriteUnverified: 'resultado sin verificar; pudo aplicarse', + assignmentWriteVerified: 'confirmada', assignmentAccess: 'Comprueba la credencial configurada, el acceso documentado y el recurso antes de reintentar. Un 404 por sí solo no demuestra que un recurso privado no exista.', +}; +exports.ENGLISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'Automatic assignment for #{targetNumber} stopped during {stage}. Provider operation: {operation}; status: {providerStatus}.', + action: '{guidance} Rerun the same issue workflow after addressing the cause; it rereads assignees before adding missing members.', + retainedState: 'Verified new assignments: {confirmedAssignments}. Remaining target count: {remainingAssignments}. Current assignee read: {assigneeRead}. Assignment write: {mutation}. Independent completed lifecycle effects are retained in their own results.', + }, +}; +exports.SPANISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'La asignación automática de #{targetNumber} se detuvo al {stage}. Operación del proveedor: {operation}; estado: {providerStatus}.', + action: '{guidance} Repite el mismo workflow tras resolver la causa; volverá a leer las personas asignadas antes de añadir las pendientes.', + retainedState: 'Asignaciones nuevas verificadas: {confirmedAssignments}. Objetivo pendiente: {remainingAssignments}. Lectura de personas asignadas: {assigneeRead}. Escritura de asignaciones: {mutation}. Los efectos independientes completados se conservan en sus propios resultados.', + }, +}; + + /***/ }), /***/ 6033: @@ -33873,6 +33921,7 @@ const progress_partition_length_failure_1 = __nccwpck_require__(9292); const progress_binary_evidence_diagnostic_1 = __nccwpck_require__(364); const issue_workflow_diagnostic_1 = __nccwpck_require__(4347); const change_size_reconciliation_1 = __nccwpck_require__(5304); +const issue_assignment_diagnostic_1 = __nccwpck_require__(2616); const copilot_command_validation_1 = __nccwpck_require__(4237); const closed_issue_reconciliation_1 = __nccwpck_require__(1565); const approval_evidence_diagnostic_1 = __nccwpck_require__(8205); @@ -33911,6 +33960,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -34093,9 +34143,11 @@ class ApplicationError extends Error { this.action = metadata.action; this.retainedState = options.recovery?.id === 'change-size-reconciliation-failed' ? 'Completed effects were retained; the failed and later size operations remain unverified.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'issue-assignment-failed' + ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -34138,6 +34190,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'issue-workflow-admission-blocked': Object.freeze(['kind', 'reason', 'fields']), 'change-size-reconciliation-failed': Object.freeze(['operation', 'issueNumber', 'targetNumber', 'projectIndex', 'verifiedOperations', 'pendingProjectSizes', 'providerStatus', 'providerReference']), + 'issue-assignment-failed': Object.freeze(['targetNumber', 'desiredCount', 'stage', 'operation', 'providerStatus', 'assigneeRead', 'observedAssignees', 'confirmedAssignments', 'remainingAssignments', 'mutation']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -34278,6 +34331,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'change-size-reconciliation-failed' && !(0, change_size_reconciliation_1.isChangeSizeReconciliationFacts)(variables)) { throw new TypeError('Change size reconciliation diagnostic is invalid.'); } + if (recovery.id === 'issue-assignment-failed' && !(0, issue_assignment_diagnostic_1.isIssueAssignmentDiagnostic)(variables)) { + throw new TypeError('Issue assignment diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), @@ -36172,6 +36228,44 @@ function hasOnlyKeys(value, allowed) { } +/***/ }), + +/***/ 2616: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ASSIGNMENT_OPERATIONS = exports.ASSIGNMENT_STAGES = void 0; +exports.isAssignmentProviderStatus = isAssignmentProviderStatus; +exports.isIssueAssignmentDiagnostic = isIssueAssignmentDiagnostic; +exports.ASSIGNMENT_STAGES = Object.freeze(['read-assignees', 'lookup-members', 'select-members', 'assign-creator', 'assign-members']); +exports.ASSIGNMENT_OPERATIONS = Object.freeze(['read-assignees', 'list-teams', 'list-team-members', 'add-assignees', 'initialize-client', 'unavailable']); +function isAssignmentProviderStatus(value) { + return typeof value === 'string' && (value === 'http-unavailable' || /^http-[1-5][0-9]{2}$/u.test(value)); +} +function isIssueAssignmentDiagnostic(value) { + return Number.isSafeInteger(value.targetNumber) && Number(value.targetNumber) > 0 + && Number.isInteger(value.desiredCount) && Number(value.desiredCount) >= 1 && Number(value.desiredCount) <= 10 + && exports.ASSIGNMENT_STAGES.includes(value.stage) + && exports.ASSIGNMENT_OPERATIONS.includes(value.operation) + && isAssignmentProviderStatus(value.providerStatus) + && ['verified', 'unavailable'].includes(value.assigneeRead) + && ['not-attempted', 'unverified', 'verified'].includes(value.mutation) + && validCount(value.confirmedAssignments) && Number(value.confirmedAssignments) <= Number(value.desiredCount) + && (value.mutation !== 'not-attempted' || value.confirmedAssignments === 0) + && (value.mutation !== 'verified' || Number(value.confirmedAssignments) > 0) + && (value.assigneeRead === 'unavailable' + ? value.observedAssignees === 'unavailable' && value.remainingAssignments === 'unavailable' + && value.confirmedAssignments === 0 && value.mutation === 'not-attempted' && value.stage === 'read-assignees' + : validCount(value.observedAssignees) && validCount(value.remainingAssignments) + && value.remainingAssignments === Math.max(0, Number(value.desiredCount) - Number(value.observedAssignees) - Number(value.confirmedAssignments))); +} +function validCount(value) { + return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 10; +} + + /***/ }), /***/ 9376: diff --git a/build/github_action/index.js b/build/github_action/index.js index bef00873b..edf800cae 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -39390,6 +39390,30 @@ class DeploymentMergeEffectError extends application_error_1.ApplicationError { exports.DeploymentMergeEffectError = DeploymentMergeEffectError; +/***/ }), + +/***/ 95252: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.issueAssignmentFailure = issueAssignmentFailure; +const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_error_1 = __nccwpck_require__(62823); +/** Add application-owned progress without losing the originating safe category or correlation. */ +function issueAssignmentFailure(cause, facts) { + return new application_error_1.ApplicationError(cause instanceof application_error_1.ApplicationError ? cause.code : 'unexpected', 'Automatic member assignment stopped.', { + ...(cause instanceof application_error_1.ApplicationError ? { retryable: cause.retryable, correlationId: cause.correlationId } : {}), + recovery: { id: 'issue-assignment-failed', variables: { ...facts, + operation: cause instanceof assignment_provider_error_1.AssignmentProviderError ? cause.operation + : facts.stage === 'assign-creator' || facts.stage === 'assign-members' ? 'add-assignees' + : facts.stage === 'read-assignees' ? 'read-assignees' : 'unavailable', + providerStatus: cause instanceof assignment_provider_error_1.AssignmentProviderError ? cause.providerStatus : 'http-unavailable' } }, + }); +} + + /***/ }), /***/ 6519: @@ -41228,6 +41252,7 @@ exports.readEnglishApplicationErrorMessage = exports.APPLICATION_ERROR_CATALOG_D exports.resolveStaticApplicationErrorCatalog = resolveStaticApplicationErrorCatalog; exports.resolveApplicationErrorCatalog = resolveApplicationErrorCatalog; const branch_sync_publication_messages_1 = __nccwpck_require__(3948); +const issue_assignment_messages_1 = __nccwpck_require__(71737); const bugbot_review_diagnostic_labels_1 = __nccwpck_require__(20351); const comment_command_validation_message_catalog_1 = __nccwpck_require__(62002); const application_error_1 = __nccwpck_require__(97790); @@ -41236,6 +41261,7 @@ const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, + ...issue_assignment_messages_1.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); @@ -41249,6 +41275,7 @@ exports.APPLICATION_ERROR_MESSAGE_IDS = Object.freeze([ const ENGLISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.ENGLISH_REVIEW_DIAGNOSTIC_LABELS, ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...issue_assignment_messages_1.ENGLISH_ASSIGNMENT_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -41261,6 +41288,7 @@ const ENGLISH_LABELS = Object.freeze({ const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...issue_assignment_messages_1.SPANISH_ASSIGNMENT_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -41399,6 +41427,7 @@ const STORED_CONFIGURATION_ACTION_ES = 'Un mantenedor debe revisar {recoveryGuid const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored configuration were not changed by this read.'; const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ + ...issue_assignment_messages_1.ENGLISH_ASSIGNMENT_CONTENT, ...comment_command_validation_message_catalog_1.ENGLISH_COMMAND_VALIDATION_CONTENT, ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ @@ -41598,6 +41627,7 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }), }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ + ...issue_assignment_messages_1.SPANISH_ASSIGNMENT_CONTENT, ...comment_command_validation_message_catalog_1.SPANISH_COMMAND_VALIDATION_CONTENT, ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ @@ -41861,43 +41891,46 @@ const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/secu const BRANCH_SYNC_CATALOG_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-catalog-compatibility'; const BRANCH_SYNC_PUBLICATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery'; const agent_source_snapshot_1 = __nccwpck_require__(59205); +const issue_assignment_diagnostic_presentation_1 = __nccwpck_require__(99446); /** Shared semantic view model for terminal, GitHub, and API presentation. */ function buildApplicationErrorPresentation(error, message = application_error_message_catalog_1.readEnglishApplicationErrorMessage) { const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` : `error.${error.code}`; - const variables = error.recovery?.id === 'sdd-source-context-unavailable' - ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } - : error.recovery?.id === 'progress-partition-length-invalid' - ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) - : error.recovery?.id === 'bugbot-review-incomplete' - ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', - resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), - unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } - : error.recovery?.id === 'progress-inventory-incomplete' - ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) - : error.recovery?.id === 'progress-binary-evidence-unavailable' - ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) - : error.recovery?.id === 'issue-workflow-admission-blocked' - ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) - : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) - ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) - : error.recovery?.id === 'branch-sync-verification-failed' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } - : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' - ? { - reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, - candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, - pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), - remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' - : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), - publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', - } - : error.recovery?.id === 'branch-sync-catalog-incompatible' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } - : error.recovery?.id.startsWith('stored-configuration-') - ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } - : error.recovery?.variables; + const variables = error.recovery?.id === 'issue-assignment-failed' + ? (0, issue_assignment_diagnostic_presentation_1.assignmentDiagnosticPresentation)(error.recovery.variables, error.code, message) + : error.recovery?.id === 'sdd-source-context-unavailable' + ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } + : error.recovery?.id === 'progress-partition-length-invalid' + ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) + : error.recovery?.id === 'bugbot-review-incomplete' + ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', + resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), + unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } + : error.recovery?.id === 'progress-inventory-incomplete' + ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) + : error.recovery?.id === 'progress-binary-evidence-unavailable' + ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) + : error.recovery?.id === 'issue-workflow-admission-blocked' + ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) + : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) + ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) + : error.recovery?.id === 'branch-sync-verification-failed' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } + : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' + ? { + reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, + candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, + pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), + remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' + : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), + publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', + } + : error.recovery?.id === 'branch-sync-catalog-incompatible' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } + : error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -41970,7 +42003,8 @@ function calculateRemainingAssignees(desiredCount, currentCount, creatorAssigned } function selectConfirmedAssignees(requestedMembers, assignedMembers) { const requestedIdentities = new Set(requestedMembers.map((member) => member.toLowerCase())); - return assignedMembers.filter((member) => requestedIdentities.has(member.toLowerCase())); + return [...new Map(assignedMembers.filter((member) => requestedIdentities.has(member.toLowerCase())) + .map(member => [member.toLowerCase(), member])).values()]; } @@ -47884,6 +47918,79 @@ function buildInitialLabelProvisioningPlan(labels, existingLabelNames) { } +/***/ }), + +/***/ 99446: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.assignmentDiagnosticPresentation = assignmentDiagnosticPresentation; +const STAGES = { + 'read-assignees': 'assignmentRead', 'lookup-members': 'assignmentLookup', 'select-members': 'assignmentSelect', + 'assign-creator': 'assignmentCreator', 'assign-members': 'assignmentMembers', +}; +const OPERATIONS = { + 'read-assignees': 'assignmentReadOperation', 'list-teams': 'assignmentTeams', 'list-team-members': 'assignmentTeamMembers', + 'add-assignees': 'assignmentAdd', 'initialize-client': 'assignmentClient', unavailable: 'assignmentUnavailable', +}; +function assignmentDiagnosticPresentation(facts, code, message) { + return { ...facts, + stage: message(`error.label.${STAGES[facts.stage]}`), operation: message(`error.label.${OPERATIONS[facts.operation]}`), + providerStatus: facts.providerStatus === 'http-unavailable' ? message('error.label.assignmentUnavailable') : facts.providerStatus, + assigneeRead: message(facts.assigneeRead === 'verified' ? 'error.label.assignmentReadVerified' : 'error.label.assignmentUnavailable'), + remainingAssignments: facts.assigneeRead === 'verified' ? facts.remainingAssignments : message('error.label.assignmentUnavailable'), + mutation: message(facts.mutation === 'not-attempted' ? 'error.label.assignmentWriteNone' + : facts.mutation === 'unverified' ? 'error.label.assignmentWriteUnverified' : 'error.label.assignmentWriteVerified'), + guidance: code.startsWith('authorization.') || code === 'provider.not-found' + ? message('error.label.assignmentAccess') : message(`error.${code}.action`), + }; +} + + +/***/ }), + +/***/ 71737: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_ASSIGNMENT_CONTENT = exports.ENGLISH_ASSIGNMENT_CONTENT = exports.SPANISH_ASSIGNMENT_LABELS = exports.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS = exports.ENGLISH_ASSIGNMENT_LABELS = void 0; +exports.ENGLISH_ASSIGNMENT_LABELS = { + assignmentRead: 'reading current assignees', assignmentLookup: 'checking organization membership', + assignmentSelect: 'selecting additional members', assignmentCreator: 'assigning the creator', assignmentMembers: 'assigning additional members', + assignmentReadOperation: 'issue assignees', assignmentTeams: 'organization teams', assignmentTeamMembers: 'organization team members', + assignmentAdd: 'add issue assignees', assignmentClient: 'provider client initialization', assignmentUnavailable: 'unavailable', + assignmentReadVerified: 'verified', assignmentWriteNone: 'not attempted', assignmentWriteUnverified: 'outcome unverified; it may have applied', + assignmentWriteVerified: 'confirmed', assignmentAccess: 'Check the configured credential, documented access and target resource before retrying. A 404 alone does not establish that a private resource is absent.', +}; +exports.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS = Object.keys(exports.ENGLISH_ASSIGNMENT_LABELS); +exports.SPANISH_ASSIGNMENT_LABELS = { + assignmentRead: 'leer las personas asignadas', assignmentLookup: 'consultar los miembros de la organización', + assignmentSelect: 'seleccionar miembros adicionales', assignmentCreator: 'asignar al autor', assignmentMembers: 'asignar miembros adicionales', + assignmentReadOperation: 'personas asignadas a la issue', assignmentTeams: 'equipos de la organización', assignmentTeamMembers: 'miembros de los equipos', + assignmentAdd: 'añadir personas asignadas', assignmentClient: 'inicialización del cliente', assignmentUnavailable: 'no disponible', + assignmentReadVerified: 'verificada', assignmentWriteNone: 'no intentada', assignmentWriteUnverified: 'resultado sin verificar; pudo aplicarse', + assignmentWriteVerified: 'confirmada', assignmentAccess: 'Comprueba la credencial configurada, el acceso documentado y el recurso antes de reintentar. Un 404 por sí solo no demuestra que un recurso privado no exista.', +}; +exports.ENGLISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'Automatic assignment for #{targetNumber} stopped during {stage}. Provider operation: {operation}; status: {providerStatus}.', + action: '{guidance} Rerun the same issue workflow after addressing the cause; it rereads assignees before adding missing members.', + retainedState: 'Verified new assignments: {confirmedAssignments}. Remaining target count: {remainingAssignments}. Current assignee read: {assigneeRead}. Assignment write: {mutation}. Independent completed lifecycle effects are retained in their own results.', + }, +}; +exports.SPANISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'La asignación automática de #{targetNumber} se detuvo al {stage}. Operación del proveedor: {operation}; estado: {providerStatus}.', + action: '{guidance} Repite el mismo workflow tras resolver la causa; volverá a leer las personas asignadas antes de añadir las pendientes.', + retainedState: 'Asignaciones nuevas verificadas: {confirmedAssignments}. Objetivo pendiente: {remainingAssignments}. Lectura de personas asignadas: {assigneeRead}. Escritura de asignaciones: {mutation}. Los efectos independientes completados se conservan en sus propios resultados.', + }, +}; + + /***/ }), /***/ 61899: @@ -54996,6 +55103,31 @@ exports.AGENT_EXECUTION_OUTPUT_DIAGNOSTICS = [ ]; +/***/ }), + +/***/ 62823: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.AssignmentProviderError = void 0; +const application_error_1 = __nccwpck_require__(75999); +const issue_assignment_diagnostic_1 = __nccwpck_require__(52616); +/** Sanitized adapter evidence. Provider messages, bodies, member lists and credentials are discarded. */ +class AssignmentProviderError extends application_error_1.ApplicationError { + constructor(code, operation, providerStatus, options = {}) { + super(code, 'The assignment provider operation failed.', options); + this.operation = operation; + this.providerStatus = providerStatus; + if (!issue_assignment_diagnostic_1.ASSIGNMENT_OPERATIONS.includes(operation) || !(0, issue_assignment_diagnostic_1.isAssignmentProviderStatus)(providerStatus)) { + throw new TypeError('Assignment provider evidence is invalid.'); + } + } +} +exports.AssignmentProviderError = AssignmentProviderError; + + /***/ }), /***/ 85930: @@ -71761,45 +71893,74 @@ const logging_ports_1 = __nccwpck_require__(6152); const task_emoji_1 = __nccwpck_require__(46103); const assignee_assignment_policy_1 = __nccwpck_require__(85918); const application_error_1 = __nccwpck_require__(75999); +const issue_assignment_failure_1 = __nccwpck_require__(95252); const TASK_ID = 'AssignMemberToIssueUseCase'; /** Assigns the creator and remaining project members according to the pure assignment policy. */ async function runAssignMembersWorkflow(param, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(TASK_ID)} Executing ${TASK_ID}.`); const target = (0, assignee_assignment_policy_1.resolveAssigneeTarget)(param); const results = []; + let stage = 'read-assignees'; + let assigneeRead = 'unavailable'; + let mutation = 'not-attempted'; + let observedAssignees = 0; + const confirmed = []; try { (0, logging_ports_1.logDebugInfo)(`#${target.number} needs ${target.desiredCount} assignees.`); if (target.desiredCount <= 0) return [new result_1.Result({ id: TASK_ID, success: true, executed: false })]; if (target.number <= 0) return [assignmentResult(false, 'Issue or pull request number is not available.')]; - const [currentProjectMembers, currentMembers] = await Promise.all([ - dependencies.projectRepository.getAllMembers(), - dependencies.issueRepository.getCurrentAssignees(target.number), - ]); + const currentMembers = await dependencies.issueRepository.getCurrentAssignees(target.number); + observedAssignees = new Set(currentMembers.map(member => member.toLowerCase())).size; + assigneeRead = 'verified'; + if (observedAssignees >= target.desiredCount) + return [new result_1.Result({ id: TASK_ID, success: true, executed: true })]; + stage = 'lookup-members'; + const currentProjectMembers = await dependencies.projectRepository.getAllMembers(); const creatorAssignment = (0, assignee_assignment_policy_1.resolveCreatorAssignment)(param, currentProjectMembers, currentMembers); if (creatorAssignment) { const { login: creator, source } = creatorAssignment; - await dependencies.issueRepository.assignMembersToIssue(target.number, [creator]); + stage = 'assign-creator'; + mutation = 'unverified'; + const added = await dependencies.issueRepository.assignMembersToIssue(target.number, [creator]); + if ((0, assignee_assignment_policy_1.selectConfirmedAssignees)([creator], added).length === 0) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'The creator assignment was not confirmed.'); + } + confirmed.push(creator); + mutation = 'verified'; (0, logging_ports_1.logDebugInfo)(`Assigned ${source} creator @${creator} to #${target.number}.`); results.push(assignmentResult(true, `The ${source} was assigned to @${creator} (creator).`)); } - const remainingAssignees = (0, assignee_assignment_policy_1.calculateRemainingAssignees)(target.desiredCount, currentMembers.length, creatorAssignment !== undefined); + const remainingAssignees = (0, assignee_assignment_policy_1.calculateRemainingAssignees)(target.desiredCount, observedAssignees, creatorAssignment !== undefined); if (remainingAssignees <= 0) { results.push(new result_1.Result({ id: TASK_ID, success: true, executed: true })); return results; } - const members = await dependencies.projectRepository.getRandomMembers(remainingAssignees, currentMembers); + stage = 'select-members'; + const members = await dependencies.projectRepository.getRandomMembers(remainingAssignees, [...currentMembers, ...confirmed]); if (members.length === 0) { results.push(assignmentResult(false, 'Tried to assign members to issue, but no one was found.')); return results; } + stage = 'assign-members'; + mutation = 'unverified'; const membersAdded = await dependencies.issueRepository.assignMembersToIssue(target.number, members); - results.push(...(0, assignee_assignment_policy_1.selectConfirmedAssignees)(members, membersAdded).map((member) => assignmentResult(true, `The ${param.target} was assigned to @${member}.`))); + const verifiedMembers = (0, assignee_assignment_policy_1.selectConfirmedAssignees)(members, membersAdded); + confirmed.push(...verifiedMembers); + results.push(...verifiedMembers.map((member) => assignmentResult(true, `The ${param.target} was assigned to @${member}.`))); + if (verifiedMembers.length !== members.length) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'Some requested assignments were not confirmed.'); + } + mutation = 'verified'; return results; } catch (error) { - const semanticError = (0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to assign members.'); + const semanticError = (0, issue_assignment_failure_1.issueAssignmentFailure)((0, application_error_1.toApplicationError)(error, 'unexpected', 'Automatic member assignment stopped.'), { + targetNumber: target.number, desiredCount: target.desiredCount, stage, assigneeRead, mutation, + observedAssignees: assigneeRead === 'verified' ? observedAssignees : 'unavailable', confirmedAssignments: confirmed.length, + remainingAssignments: assigneeRead === 'verified' ? Math.max(0, target.desiredCount - observedAssignees - confirmed.length) : 'unavailable', + }); (0, logging_ports_1.logError)(semanticError); results.push(new result_1.Result({ id: TASK_ID, @@ -71816,7 +71977,7 @@ function assignmentResult(success, step) { id: TASK_ID, success, executed: true, - steps: step ? [step] : [], + steps: [step], }); } @@ -74160,6 +74321,7 @@ const progress_partition_length_failure_1 = __nccwpck_require__(59292); const progress_binary_evidence_diagnostic_1 = __nccwpck_require__(90364); const issue_workflow_diagnostic_1 = __nccwpck_require__(14347); const change_size_reconciliation_1 = __nccwpck_require__(25304); +const issue_assignment_diagnostic_1 = __nccwpck_require__(52616); const copilot_command_validation_1 = __nccwpck_require__(44237); const closed_issue_reconciliation_1 = __nccwpck_require__(21565); const approval_evidence_diagnostic_1 = __nccwpck_require__(98205); @@ -74198,6 +74360,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -74380,9 +74543,11 @@ class ApplicationError extends Error { this.action = metadata.action; this.retainedState = options.recovery?.id === 'change-size-reconciliation-failed' ? 'Completed effects were retained; the failed and later size operations remain unverified.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'issue-assignment-failed' + ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -74425,6 +74590,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'issue-workflow-admission-blocked': Object.freeze(['kind', 'reason', 'fields']), 'change-size-reconciliation-failed': Object.freeze(['operation', 'issueNumber', 'targetNumber', 'projectIndex', 'verifiedOperations', 'pendingProjectSizes', 'providerStatus', 'providerReference']), + 'issue-assignment-failed': Object.freeze(['targetNumber', 'desiredCount', 'stage', 'operation', 'providerStatus', 'assigneeRead', 'observedAssignees', 'confirmedAssignments', 'remainingAssignments', 'mutation']), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -74565,6 +74731,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'change-size-reconciliation-failed' && !(0, change_size_reconciliation_1.isChangeSizeReconciliationFacts)(variables)) { throw new TypeError('Change size reconciliation diagnostic is invalid.'); } + if (recovery.id === 'issue-assignment-failed' && !(0, issue_assignment_diagnostic_1.isIssueAssignmentDiagnostic)(variables)) { + throw new TypeError('Issue assignment diagnostic is invalid.'); + } return Object.freeze({ id: recovery.id, variables: Object.freeze({ ...variables }), @@ -80102,6 +80271,47 @@ class GitCliRepository { exports.GitCliRepository = GitCliRepository; +/***/ }), + +/***/ 86172: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.withAssignmentProviderFailure = withAssignmentProviderFailure; +const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_error_1 = __nccwpck_require__(62823); +const github_error_policy_1 = __nccwpck_require__(58791); +/** Terminate untrusted REST/transport failures at the adapter boundary, without retaining the cause. */ +async function withAssignmentProviderFailure(operation, perform) { + try { + return await perform(); + } + catch (cause) { + if (cause instanceof assignment_provider_error_1.AssignmentProviderError) + throw cause; + const error = record(cause), response = record(error.response); + const status = [error.status, response.status].find(value => typeof value === 'number' + && Number.isInteger(value) && value >= 100 && value <= 599); + const code = cause instanceof application_error_1.ApplicationError ? cause.code + : (0, github_error_policy_1.isGithubRateLimited)({ status, response: { headers: response.headers ?? error.headers }, message: error.message }) ? 'provider.rate-limited' + : status === 401 ? 'authorization.credential-invalid' + : status === 403 ? 'authorization.denied' + : status === 404 ? 'provider.not-found' + : status === 409 || status === 412 ? 'provider.conflict' + : status === 400 || status === 422 ? 'provider.contract-invalid' + : status === 408 || typeof status === 'number' && status >= 500 + || ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN'].includes(error.code) + ? 'provider.unavailable' : 'unexpected'; + throw new assignment_provider_error_1.AssignmentProviderError(code, operation, status === undefined ? 'http-unavailable' : `http-${status}`, cause instanceof application_error_1.ApplicationError ? { retryable: cause.retryable, correlationId: cause.correlationId } : {}); + } +} +function record(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}; +} + + /***/ }), /***/ 5299: @@ -81418,40 +81628,48 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.IssueAssignmentRepository = void 0; const logger_1 = __nccwpck_require__(91151); const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_failure_1 = __nccwpck_require__(86172); class IssueAssignmentRepository { constructor(githubClient) { this.githubClient = githubClient; this.getCurrentAssignees = async (owner, repository, issueNumber, token) => { - const octokit = this.githubClient.getClient(token); - try { - const { data: issue } = await octokit.rest.issues.get({ owner, repo: repository, issue_number: issueNumber }); - return (issue.assignees ?? []).map(assignee => assignee.login); - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to get issue assignees.')); - throw error; - } + const octokit = await (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => this.githubClient.getClient(token)); + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('read-assignees', async () => { + const response = await octokit.rest.issues.get({ owner, repo: repository, issue_number: issueNumber }); + return assigneesFromIssue(response?.data); + }); }; this.assignMembersToIssue = async (owner, repository, issueNumber, members, token) => { - const octokit = this.githubClient.getClient(token); - try { - if (members.length === 0) { - (0, logger_1.logDebugInfo)('No members provided for assignment. Skipping operation.'); - return []; - } - const { data: updatedIssue } = await octokit.rest.issues.addAssignees({ + if (members.length === 0) { + (0, logger_1.logDebugInfo)('No members provided for assignment. Skipping operation.'); + return []; + } + const octokit = await (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => this.githubClient.getClient(token)); + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('add-assignees', async () => { + const response = await octokit.rest.issues.addAssignees({ owner, repo: repository, issue_number: issueNumber, assignees: members, }); - return (updatedIssue.assignees ?? []).map(assignee => assignee.login); - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to assign issue members.')); - throw error; - } + return assigneesFromIssue(response?.data); + }); }; } } exports.IssueAssignmentRepository = IssueAssignmentRepository; +function assigneesFromIssue(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw malformedAssignees(); + const assignees = value.assignees ?? []; + if (!Array.isArray(assignees) || assignees.length > 10) + throw malformedAssignees(); + return assignees.map(assignee => { + if (typeof assignee !== 'object' || assignee === null || typeof assignee.login !== 'string' || assignee.login.trim().length === 0) + throw malformedAssignees(); + return assignee.login; + }); +} +function malformedAssignees() { + return new application_error_1.ApplicationError('provider.contract-invalid', 'Issue assignee response is malformed.'); +} /***/ }), @@ -82731,38 +82949,50 @@ exports.AuthenticatedUserRepository = AuthenticatedUserRepository; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.listOrganizationTeams = listOrganizationTeams; exports.listOrganizationTeamMembers = listOrganizationTeamMembers; -const github_pagination_policy_1 = __nccwpck_require__(44812); +const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_failure_1 = __nccwpck_require__(86172); async function listOrganizationTeams(client, organization) { - const teams = []; - for await (const response of client.paginate.iterator(client.rest.teams.list, { - org: organization, - per_page: 100, - })) { - const page = (0, github_pagination_policy_1.requireArrayPage)(response.data, 'organization teams'); - teams.push(...page.flatMap((team) => isTeam(team) ? [team] : [])); - } - return teams; + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('list-teams', async () => { + const teams = []; + for await (const response of client.paginate.iterator(client.rest.teams.list, { + org: organization, + per_page: 100, + })) { + const page = response?.data; + if (!Array.isArray(page) || !page.every(isTeam)) + throw malformedMembers(); + teams.push(...page); + } + return teams; + }); } async function listOrganizationTeamMembers(client, organization, teamSlug) { - const members = []; - for await (const response of client.paginate.iterator(client.rest.teams.listMembersInOrg, { - org: organization, - team_slug: teamSlug, - per_page: 100, - })) { - const page = (0, github_pagination_policy_1.requireArrayPage)(response.data, 'organization team members'); - members.push(...page.flatMap((member) => isMember(member) ? [member] : [])); - } - return members; + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('list-team-members', async () => { + const members = []; + for await (const response of client.paginate.iterator(client.rest.teams.listMembersInOrg, { + org: organization, + team_slug: teamSlug, + per_page: 100, + })) { + const page = response?.data; + if (!Array.isArray(page) || !page.every(isMember)) + throw malformedMembers(); + members.push(...page); + } + return members; + }); } function isTeam(value) { - return isRecord(value) && typeof value.slug === 'string'; + return isRecord(value) && typeof value.slug === 'string' && value.slug.trim().length > 0; } function isMember(value) { - return isRecord(value) && typeof value.login === 'string'; + return isRecord(value) && typeof value.login === 'string' && value.login.trim().length > 0; } function isRecord(value) { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function malformedMembers() { + return new application_error_1.ApplicationError('provider.contract-invalid', 'Organization membership response is malformed.'); } @@ -82778,14 +83008,14 @@ exports.OrganizationMembersRepository = void 0; const logger_1 = __nccwpck_require__(91151); const project_members_policy_1 = __nccwpck_require__(41370); const organization_members_query_1 = __nccwpck_require__(84916); -const application_error_1 = __nccwpck_require__(75999); +const assignment_provider_failure_1 = __nccwpck_require__(86172); class OrganizationMembersRepository { constructor(githubClient) { this.githubClient = githubClient; this.getRandomMembers = async (organization, membersToAdd, currentMembers, token) => { if (membersToAdd === 0) return []; - try { + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => { const client = this.githubClient.getClient(token); const teams = await (0, organization_members_query_1.listOrganizationTeams)(client, organization); if (teams.length === 0) { @@ -82798,26 +83028,18 @@ class OrganizationMembersRepository { (0, logger_1.logDebugInfo)(`No available members to assign for organization ${organization}.`); } return selectedMembers; - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to select organization members.')); - throw error; - } + }); }; this.getAllMembers = async (organization, token) => { - try { + return (0, assignment_provider_failure_1.withAssignmentProviderFailure)('initialize-client', async () => { const client = this.githubClient.getClient(token); const teams = await (0, organization_members_query_1.listOrganizationTeams)(client, organization); if (teams.length === 0) { (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); return []; } - return (0, project_members_policy_1.collectOrganizationMembers)(teams, (teamSlug) => (0, organization_members_query_1.listOrganizationTeamMembers)(client, organization, teamSlug)); - } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', 'Unable to list organization members.')); - throw error; - } + return await (0, project_members_policy_1.collectOrganizationMembers)(teams, (teamSlug) => (0, organization_members_query_1.listOrganizationTeamMembers)(client, organization, teamSlug)); + }); }; } } @@ -89261,6 +89483,44 @@ function parseDelimitedValues(value) { } +/***/ }), + +/***/ 52616: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ASSIGNMENT_OPERATIONS = exports.ASSIGNMENT_STAGES = void 0; +exports.isAssignmentProviderStatus = isAssignmentProviderStatus; +exports.isIssueAssignmentDiagnostic = isIssueAssignmentDiagnostic; +exports.ASSIGNMENT_STAGES = Object.freeze(['read-assignees', 'lookup-members', 'select-members', 'assign-creator', 'assign-members']); +exports.ASSIGNMENT_OPERATIONS = Object.freeze(['read-assignees', 'list-teams', 'list-team-members', 'add-assignees', 'initialize-client', 'unavailable']); +function isAssignmentProviderStatus(value) { + return typeof value === 'string' && (value === 'http-unavailable' || /^http-[1-5][0-9]{2}$/u.test(value)); +} +function isIssueAssignmentDiagnostic(value) { + return Number.isSafeInteger(value.targetNumber) && Number(value.targetNumber) > 0 + && Number.isInteger(value.desiredCount) && Number(value.desiredCount) >= 1 && Number(value.desiredCount) <= 10 + && exports.ASSIGNMENT_STAGES.includes(value.stage) + && exports.ASSIGNMENT_OPERATIONS.includes(value.operation) + && isAssignmentProviderStatus(value.providerStatus) + && ['verified', 'unavailable'].includes(value.assigneeRead) + && ['not-attempted', 'unverified', 'verified'].includes(value.mutation) + && validCount(value.confirmedAssignments) && Number(value.confirmedAssignments) <= Number(value.desiredCount) + && (value.mutation !== 'not-attempted' || value.confirmedAssignments === 0) + && (value.mutation !== 'verified' || Number(value.confirmedAssignments) > 0) + && (value.assigneeRead === 'unavailable' + ? value.observedAssignees === 'unavailable' && value.remainingAssignments === 'unavailable' + && value.confirmedAssignments === 0 && value.mutation === 'not-attempted' && value.stage === 'read-assignees' + : validCount(value.observedAssignees) && validCount(value.remainingAssignments) + && value.remainingAssignments === Math.max(0, Number(value.desiredCount) - Number(value.observedAssignees) - Number(value.confirmedAssignments))); +} +function validCount(value) { + return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 10; +} + + /***/ }), /***/ 31147: diff --git a/docs/issues/assignees-and-projects.mdx b/docs/issues/assignees-and-projects.mdx index b37a9f08b..fd44550f4 100644 --- a/docs/issues/assignees-and-projects.mdx +++ b/docs/issues/assignees-and-projects.mdx @@ -35,6 +35,40 @@ Set the number of assignees in the workflow: Use a higher value to assign more people (e.g. `2` or `3`), up to the configured maximum. +### Recover an assignment failure + +Copilot reads current assignees first. If the target is already satisfied, it +retains them without querying organization membership or adding the creator. +Otherwise, it confirms requested identities in GitHub's assignment response. +A confirmed creator is excluded when selecting additional members. The count +is an upper target; the organization may have fewer eligible available members. + +```text +Read current assignees -> retain satisfied target + -> lookup -> add and confirm missing members +Failure -> inspect stage and operation -> address cause -> rerun same workflow +``` + +An assignment failure names the stage, safe provider operation and HTTP status +when known. `issue-assignment-failed` also records the current-read certainty, +verified new assignments, remaining target count and whether the write was +attempted or confirmed. An unread remaining count is shown as unavailable. +For example, `lookup-members` / `list-teams` / `http-403` reports a failed +organization-team lookup; it does not prove that the organization is empty. + +Check configured access for denied/authentication failures; verify both access +and resource for 404, which can conceal a private resource. Wait for recovery +when the provider is unavailable or rate limited. A malformed response or an +unclassified `unexpected` error requires investigation using the correlation +reference, rather than blind retries or automatically expanding permissions. +No membership list, response body or credential is included in the diagnostic. + +If a write's outcome is unverified, it may have applied remotely. A retry reads +current assignees again and avoids repeating a satisfied assignment. Confirmed +assignments remain, as do independently completed type, Project and configuration +effects reported by their own lifecycle results. The assignment error does not +certify those other steps. See the [application error reference](/security-operations/operations/error-reference). + ## Project (board) linking Linking issues to **GitHub Project** boards requires a **Personal Access Token (PAT)** with access to the repository and to the project(s). The action uses **`project-ids`** to know which boards to link to. diff --git a/docs/security-operations/operations/error-reference.mdx b/docs/security-operations/operations/error-reference.mdx index 339a39c28..831971f97 100644 --- a/docs/security-operations/operations/error-reference.mdx +++ b/docs/security-operations/operations/error-reference.mdx @@ -284,3 +284,22 @@ scope and classifier result; do not remove acceptance or raise a bound to pass. Generated persisted state is operational context, not another requirement. Malformed state fails through the existing configuration-error family before model queries. Raw rejected output is never part of these diagnostics. + +## Assignment failures + +`issue-assignment-failed` identifies the assignment stage and safe provider +operation, target number, bounded HTTP status (or unavailable), current-read +certainty, observed/confirmed/remaining counts, and assignment-write certainty. +An unverified write may already have applied; do not treat zero confirmed new +assignments as proof of zero external changes. The same workflow rereads current +assignees before retrying missing work. Independent completed lifecycle effects +are retained in their own results. + +Known denied/authentication failures are not retryable outages. A 403 with +rate-limit evidence or a 429 uses `provider.rate-limited`; recognized transport, +408 and 5xx failures use `provider.unavailable`. Unknown exceptions are +`unexpected`, with no invented transient cause. Malformed lists/rows use +`provider.contract-invalid`, rather than becoming an empty membership list. +For 404, check access and target; private-resource absence cannot be inferred. +Raw assignment-provider exceptions are discarded after their safe classification. +See [assignment recovery](/issues/assignees-and-projects#recover-an-assignment-failure). diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index 3c15996bf..f55d56b03 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -1466,6 +1466,22 @@ } ], "successMessage": "Bugbot asynchronous statement guidance: PASS (100% lines/statements/functions/branches)" + }, + { + "name": "Issue assignment diagnostics", + "missingEntryLabel": "issue assignment diagnostics", + "rules": [ + { + "files": [ + "src/domain/issue_assignment_diagnostic.ts", + "src/application/policies/issue_assignment_messages.ts", + "src/application/policies/issue_assignment_diagnostic_presentation.ts" + ], + "mode": "each", + "thresholdProfile": "exhaustive" + } + ], + "successMessage": "Issue assignment diagnostics: PASS (pure validation and presentation 100% lines/statements/functions/branches)" } ] } diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 00d8c36c2..a62cc3ae2 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -21,7 +21,7 @@ debt or convert unknown historic intent into a design decision. | `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT, verify bot identity and grants, recover explicitly from wrong-account input within the session, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 39 paths · 2026-10-07 | | `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 36 paths · 2026-10-18 | | `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 6 companion | 164 paths · 2026-11-03 | -| `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 75 paths · 2026-10-30 | +| `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) + 1 companion | 100 paths · 2026-11-03 | | `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) | 88 paths · 2026-10-26 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze bounded diff partitions with version-grounded dependency, policy-to-request transformation and source-bound assertion and generated-source coordinates with independent candidate verification, configuration-selected initial-session credential ownership, delegated test-path and external Action input-contract, host-aware self-repository references and Kotlin/Wasm lexical-binding and renderer-grounded Markdown path evidence, publish stable findings atomically, and apply authorized verified fixes with regression coverage for cwd/pattern-grounded indirect unittest discovery and source-bound module execution evidence, with active-instance Kotlin Json nullable-input policy grounding; verify complete large diffs through a source-bound closed framed profile with immutable local Git proof, live-base freshness and atomic global publication; ground Python exception syntax in declared interpreter versions and PEP 758 while preserving older-target and independent runtime defects; ground filesystem recovery findings in retention/no-overwrite, ownership and publication contracts while preserving required disposable cleanup and data-loss defects; independently adjudicate candidates for supplied Python source, retaining existing bounded source, identity and history controls; trace concrete helper defaults/overrides, database-file parent containment and stored query values before judging assertions, preserving exact fragment coordinates and genuine assertion failures; co-deliver complete long discussion to every source-bound partition within closed native prompt and invocation traffic limits, preserving provenance and truthful provider partial coverage; verify actual resource content, source binding, human authority and intended entry availability before reporting an authentication defect; ground asynchronous findings in complete statements, actual receiver/cycle/caller and reached lifecycle effects, preserving genuine unfinished updates and omitting disproved claims | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 9 companion | 211 paths · 2026-11-03 | | `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) + 3 companion | 159 paths · 2026-11-03 | @@ -159,13 +159,13 @@ debt or convert unknown historic intent into a design decision. ### `managed-issue-lifecycle` — Managed issue and branch lifecycle - Owner: Copilot maintainers -- Last verified: 2026-10-30 -- Specifications: [`specs/managed-issue-and-branch-lifecycle.md`](./managed-issue-and-branch-lifecycle.md) +- Last verified: 2026-11-03 +- Specifications: [`specs/managed-issue-and-branch-lifecycle.md`](./managed-issue-and-branch-lifecycle.md) · [`specs/issue-assignment-failure-recovery.md`](./issue-assignment-failure-recovery.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) - Entrypoints: [`src/application/usecases/issue_use_case.ts`](../src/application/usecases/issue_use_case.ts) · [`src/application/usecases/commit_use_case.ts`](../src/application/usecases/commit_use_case.ts) -- Core code: [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.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/issue/link_issue_project_use_case.ts`](../src/application/usecases/steps/issue/link_issue_project_use_case.ts) · [`src/application/usecases/steps/issue/branch_preparation_strategy.ts`](../src/application/usecases/steps/issue/branch_preparation_strategy.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.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/data/repository/branch_lifecycle_repository.ts`](../src/data/repository/branch_lifecycle_repository.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/application/policies/implementation_result_policy.ts`](../src/application/policies/implementation_result_policy.ts) · [`src/domain/issue_completion.ts`](../src/domain/issue_completion.ts) · [`src/application/ports/issue_completion_ports.ts`](../src/application/ports/issue_completion_ports.ts) · [`src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts`](../src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts) · [`src/data/repository/issue/issue_completion_repository.ts`](../src/data/repository/issue/issue_completion_repository.ts) · [`src/infrastructure/composition/issue_completion_composition_root.ts`](../src/infrastructure/composition/issue_completion_composition_root.ts) · [`src/infrastructure/github/ports/github_issue_completion_client.ts`](../src/infrastructure/github/ports/github_issue_completion_client.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/application/usecases/steps/issue/resume_issue_project_use_case.ts`](../src/application/usecases/steps/issue/resume_issue_project_use_case.ts) · [`src/data/repository/project/project_board_field_update.ts`](../src/data/repository/project/project_board_field_update.ts) · [`src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts`](../src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts) · [`src/application/usecases/actions/reconcile_closed_issue_use_case.ts`](../src/application/usecases/actions/reconcile_closed_issue_use_case.ts) · [`src/domain/closed_issue_reconciliation.ts`](../src/domain/closed_issue_reconciliation.ts) · [`src/application/errors/closed_issue_reconciliation_error.ts`](../src/application/errors/closed_issue_reconciliation_error.ts) · [`src/application/usecases/actions/reconcile_issue_completion_metadata.ts`](../src/application/usecases/actions/reconcile_issue_completion_metadata.ts) · [`src/application/usecases/actions/verify_closed_issue_card_retirement.ts`](../src/application/usecases/actions/verify_closed_issue_card_retirement.ts) · [`src/application/policies/closed_issue_reconciliation_outcome_policy.ts`](../src/application/policies/closed_issue_reconciliation_outcome_policy.ts) · [`src/domain/deployment_issue_completion.ts`](../src/domain/deployment_issue_completion.ts) · [`src/application/usecases/actions/deferred_deployment_completion.ts`](../src/application/usecases/actions/deferred_deployment_completion.ts) -- Tests: [`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__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.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/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/data/repository/__tests__/branch_lifecycle_repository.test.ts`](../src/data/repository/__tests__/branch_lifecycle_repository.test.ts) · [`src/domain/__tests__/copilot_lifecycle.test.ts`](../src/domain/__tests__/copilot_lifecycle.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__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.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/domain/__tests__/issue_completion.test.ts`](../src/domain/__tests__/issue_completion.test.ts) · [`src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts) · [`src/data/repository/issue/__tests__/issue_completion_repository.test.ts`](../src/data/repository/issue/__tests__/issue_completion_repository.test.ts) · [`src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts) · [`src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts`](../src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts) · [`src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts`](../src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts) · [`src/domain/__tests__/deployment_issue_completion.test.ts`](../src/domain/__tests__/deployment_issue_completion.test.ts) · [`src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts`](../src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts) -- User documentation: [`docs/issues/branch-management.mdx`](../docs/issues/branch-management.mdx) · [`docs/issues/labels-and-branch-types.mdx`](../docs/issues/labels-and-branch-types.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/issues/notifications-and-auto-close.mdx`](../docs/issues/notifications-and-auto-close.mdx) · [`docs/issues/index.mdx`](../docs/issues/index.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) +- Core code: [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.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/issue/link_issue_project_use_case.ts`](../src/application/usecases/steps/issue/link_issue_project_use_case.ts) · [`src/application/usecases/steps/issue/branch_preparation_strategy.ts`](../src/application/usecases/steps/issue/branch_preparation_strategy.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.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/data/repository/branch_lifecycle_repository.ts`](../src/data/repository/branch_lifecycle_repository.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/application/policies/implementation_result_policy.ts`](../src/application/policies/implementation_result_policy.ts) · [`src/domain/issue_completion.ts`](../src/domain/issue_completion.ts) · [`src/application/ports/issue_completion_ports.ts`](../src/application/ports/issue_completion_ports.ts) · [`src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts`](../src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts) · [`src/data/repository/issue/issue_completion_repository.ts`](../src/data/repository/issue/issue_completion_repository.ts) · [`src/infrastructure/composition/issue_completion_composition_root.ts`](../src/infrastructure/composition/issue_completion_composition_root.ts) · [`src/infrastructure/github/ports/github_issue_completion_client.ts`](../src/infrastructure/github/ports/github_issue_completion_client.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/application/usecases/steps/issue/resume_issue_project_use_case.ts`](../src/application/usecases/steps/issue/resume_issue_project_use_case.ts) · [`src/data/repository/project/project_board_field_update.ts`](../src/data/repository/project/project_board_field_update.ts) · [`src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts`](../src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts) · [`src/application/usecases/actions/reconcile_closed_issue_use_case.ts`](../src/application/usecases/actions/reconcile_closed_issue_use_case.ts) · [`src/domain/closed_issue_reconciliation.ts`](../src/domain/closed_issue_reconciliation.ts) · [`src/application/errors/closed_issue_reconciliation_error.ts`](../src/application/errors/closed_issue_reconciliation_error.ts) · [`src/application/usecases/actions/reconcile_issue_completion_metadata.ts`](../src/application/usecases/actions/reconcile_issue_completion_metadata.ts) · [`src/application/usecases/actions/verify_closed_issue_card_retirement.ts`](../src/application/usecases/actions/verify_closed_issue_card_retirement.ts) · [`src/application/policies/closed_issue_reconciliation_outcome_policy.ts`](../src/application/policies/closed_issue_reconciliation_outcome_policy.ts) · [`src/domain/deployment_issue_completion.ts`](../src/domain/deployment_issue_completion.ts) · [`src/application/usecases/actions/deferred_deployment_completion.ts`](../src/application/usecases/actions/deferred_deployment_completion.ts) · [`src/domain/issue_assignment_diagnostic.ts`](../src/domain/issue_assignment_diagnostic.ts) · [`src/application/ports/assignment_provider_error.ts`](../src/application/ports/assignment_provider_error.ts) · [`src/application/errors/issue_assignment_failure.ts`](../src/application/errors/issue_assignment_failure.ts) · [`src/application/usecases/steps/issue/assign_members_workflow.ts`](../src/application/usecases/steps/issue/assign_members_workflow.ts) · [`src/application/policies/assignee_assignment_policy.ts`](../src/application/policies/assignee_assignment_policy.ts) · [`src/application/policies/issue_assignment_messages.ts`](../src/application/policies/issue_assignment_messages.ts) · [`src/data/repository/github/assignment_provider_failure.ts`](../src/data/repository/github/assignment_provider_failure.ts) · [`src/data/repository/organization/organization_members_query.ts`](../src/data/repository/organization/organization_members_query.ts) · [`src/data/repository/organization/organization_members_repository.ts`](../src/data/repository/organization/organization_members_repository.ts) · [`src/data/repository/issue/issue_assignment_repository.ts`](../src/data/repository/issue/issue_assignment_repository.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/issue_assignment_diagnostic_presentation.ts`](../src/application/policies/issue_assignment_diagnostic_presentation.ts) +- Tests: [`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__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.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/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/data/repository/__tests__/branch_lifecycle_repository.test.ts`](../src/data/repository/__tests__/branch_lifecycle_repository.test.ts) · [`src/domain/__tests__/copilot_lifecycle.test.ts`](../src/domain/__tests__/copilot_lifecycle.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__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.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/domain/__tests__/issue_completion.test.ts`](../src/domain/__tests__/issue_completion.test.ts) · [`src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts) · [`src/data/repository/issue/__tests__/issue_completion_repository.test.ts`](../src/data/repository/issue/__tests__/issue_completion_repository.test.ts) · [`src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts) · [`src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts`](../src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts) · [`src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts`](../src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts) · [`src/domain/__tests__/deployment_issue_completion.test.ts`](../src/domain/__tests__/deployment_issue_completion.test.ts) · [`src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts`](../src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts) · [`src/domain/__tests__/issue_assignment_diagnostic.test.ts`](../src/domain/__tests__/issue_assignment_diagnostic.test.ts) · [`src/data/repository/github/__tests__/assignment_provider_failure.test.ts`](../src/data/repository/github/__tests__/assignment_provider_failure.test.ts) · [`src/data/repository/__tests__/assignment_provider_contract.test.ts`](../src/data/repository/__tests__/assignment_provider_contract.test.ts) · [`src/application/policies/__tests__/issue_assignment_messages.test.ts`](../src/application/policies/__tests__/issue_assignment_messages.test.ts) · [`src/tooling/__tests__/issue_assignment_diagnostics.test.ts`](../src/tooling/__tests__/issue_assignment_diagnostics.test.ts) · [`src/application/policies/__tests__/assignee_assignment_policy.test.ts`](../src/application/policies/__tests__/assignee_assignment_policy.test.ts) · [`src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts) · [`src/data/repository/__tests__/organization_members_repository.test.ts`](../src/data/repository/__tests__/organization_members_repository.test.ts) · [`src/data/repository/__tests__/issue_assignment_repository.test.ts`](../src/data/repository/__tests__/issue_assignment_repository.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) +- User documentation: [`docs/issues/branch-management.mdx`](../docs/issues/branch-management.mdx) · [`docs/issues/labels-and-branch-types.mdx`](../docs/issues/labels-and-branch-types.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/issues/notifications-and-auto-close.mdx`](../docs/issues/notifications-and-auto-close.mdx) · [`docs/issues/index.mdx`](../docs/issues/index.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) ### `comment-automation` — Comment automation and authorization diff --git a/specs/catalog.json b/specs/catalog.json index c5e5ef885..70d1d35f0 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1930,9 +1930,10 @@ "status": "as-built-baseline", "scope": "Convert typed issues into traceable work branches, project state, and lifecycle state", "owner": "Copilot maintainers", - "lastVerified": "2026-10-30", + "lastVerified": "2026-11-03", "specs": [ - "specs/managed-issue-and-branch-lifecycle.md" + "specs/managed-issue-and-branch-lifecycle.md", + "specs/issue-assignment-failure-recovery.md" ], "workflows": [ ".github/workflows/copilot_issue.yml", @@ -1980,7 +1981,21 @@ "src/application/usecases/actions/verify_closed_issue_card_retirement.ts", "src/application/policies/closed_issue_reconciliation_outcome_policy.ts", "src/domain/deployment_issue_completion.ts", - "src/application/usecases/actions/deferred_deployment_completion.ts" + "src/application/usecases/actions/deferred_deployment_completion.ts", + "src/domain/issue_assignment_diagnostic.ts", + "src/application/ports/assignment_provider_error.ts", + "src/application/errors/issue_assignment_failure.ts", + "src/application/usecases/steps/issue/assign_members_workflow.ts", + "src/application/policies/assignee_assignment_policy.ts", + "src/application/policies/issue_assignment_messages.ts", + "src/data/repository/github/assignment_provider_failure.ts", + "src/data/repository/organization/organization_members_query.ts", + "src/data/repository/organization/organization_members_repository.ts", + "src/data/repository/issue/issue_assignment_repository.ts", + "src/data/model/application_error.ts", + "src/application/policies/application_error_message_catalog.ts", + "src/application/policies/application_error_presentation_policy.ts", + "src/application/policies/issue_assignment_diagnostic_presentation.ts" ], "tests": [ "src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts", @@ -2009,7 +2024,17 @@ "src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts", "src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts", "src/domain/__tests__/deployment_issue_completion.test.ts", - "src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts" + "src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts", + "src/domain/__tests__/issue_assignment_diagnostic.test.ts", + "src/data/repository/github/__tests__/assignment_provider_failure.test.ts", + "src/data/repository/__tests__/assignment_provider_contract.test.ts", + "src/application/policies/__tests__/issue_assignment_messages.test.ts", + "src/tooling/__tests__/issue_assignment_diagnostics.test.ts", + "src/application/policies/__tests__/assignee_assignment_policy.test.ts", + "src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts", + "src/data/repository/__tests__/organization_members_repository.test.ts", + "src/data/repository/__tests__/issue_assignment_repository.test.ts", + "src/application/policies/__tests__/application_error_presentation_policy.test.ts" ], "documentation": [ "docs/issues/branch-management.mdx", @@ -2017,7 +2042,8 @@ "docs/issues/assignees-and-projects.mdx", "docs/issues/notifications-and-auto-close.mdx", "docs/issues/index.mdx", - "docs/issues/comment-commands.mdx" + "docs/issues/comment-commands.mdx", + "docs/security-operations/operations/error-reference.mdx" ] }, { diff --git a/specs/issue-assignment-failure-recovery.md b/specs/issue-assignment-failure-recovery.md new file mode 100644 index 000000000..8db56e2fc --- /dev/null +++ b/specs/issue-assignment-failure-recovery.md @@ -0,0 +1,287 @@ +# Actionable issue assignment failures and safe retries + +- Status: Implemented; local acceptance complete, delivery follows normal PR gates +- Date: 2026-11-03 +- Catalog capability ID: `managed-issue-lifecycle` +- Owners: Copilot maintainers +- Scope: bounded assignment diagnostics, confirmed partial effects and replay +- Related issue: [#849](https://github.com/vypdev/copilot/issues/849) +- Required review: architecture, product content, security, tests, documentation, + generated bundles, full current-head review and official diff coverage +- Open decisions blocking readiness: none + +## 1. Executive summary + +An assignment failure must name the failed stage and safe provider operation, +retain its known classification, and distinguish confirmed changes from an +unverified write. A retry reads current assignees before adding missing members. +Other lifecycle steps keep their own results and completed effects. + +```text +read current assignees -> already sufficient: retain without membership lookup + -> lookup eligible members -> assign/verify creator + -> select/assign/verify additional members +failure -> safe stage, operation, HTTP category, confirmed/remaining counts + -> continue independent lifecycle steps -> inspect results -> safe retry +``` + +## 2. Problem, observed behavior and evidence + +The original immutable released SDK `3e7ceea08e6131360e026937d1c0cc1b120472e0` +failed in [Domus run 41337490802](https://github.com/vypdev/domus/actions/runs/41337490802) +on source `8ee7e1a15e91c91b6204ca97e8808c3207dee008`. Member lookup and assignment +failed, while type, Project/priority and configuration effects completed. The +published error only said `provider.unavailable`, retryable Yes. Its actual HTTP +status and cause are unknown. A later assigned issue does not prove this attempt +succeeded. No consumer rerun, credential change or source correction is required. + +Current development evidence at `7d1e8d3b` shows adapters logging a generic error +then rethrowing raw exceptions, application wrapping them again as unavailable, +membership queried before counting existing assignees, and creator success +reported without checking the assignment response. Five SDK-owned functional +regressions fail before the fix. These observations do not reconstruct the +unavailable original exception. Historic motivation is unknown. + +## 3. Actors, surfaces and terminology + +The maintainer reads the existing workflow result/Job Summary and retries the same +issue workflow after addressing the stated cause. API and CLI users receive the +same semantic descriptor and localized view. A **confirmed assignment** is a +requested identity present in the returned native assignment list; a write that +throws or omits requested identities has an **unverified outcome**. It may have +applied remotely. Independent results are not inferred from assignment counts. + +## 4. Goals, limits and fixed requirements + +| ID | Requirement | +|---|---| +| R1 | Adapter failures MUST preserve a closed operation, bounded HTTP status when known and truthful semantic category; unknown failures MUST NOT be invented as transient. | +| R2 | Raw messages, response bodies, credentials and membership lists MUST NOT cross the failure boundary. Existing semantic correlation and narrowed retryability MUST survive wrapping. | +| R3 | The application MUST read current assignees first, skip satisfied targets, confirm requested assignments and retain confirmed partial results without claiming failed writes had no effect. | +| R4 | Retry MUST reread assignees, exclude the confirmed creator from additional selection and avoid repeating already-satisfied writes. Independent lifecycle results MUST remain intact. | +| R5 | Validated public diagnostics and English/Spanish presentation MUST name impact, stage/operation, recovery and retained/remaining facts; an unread count MUST be shown unavailable. | +| R6 | Documentation, catalog, architecture checks, generated bundles and current-head technical/review/coverage gates MUST accompany the correction. | + +No model calls, new credentials, automatic retry loop, arbitrary diagnostic +strings, feature flag, new endpoint or persisted assignment journal are added. +The configured count remains an upper target, not a guarantee that enough +eligible members exist. A valid empty selection retains its existing explicit +unsuccessful result; it is distinct from a failed or malformed provider read. + +## 5. Current versus proposed journey + +| Stage | Current risk | Proposed behavior | +|---|---|---| +| Read | unnecessary membership failure on satisfied target | read current native assignments before membership | +| Failure | opaque transient label | adapter-owned operation/status/category plus application-owned stage | +| Write | unconfirmed creator claimed successful | verify returned identities, retain successful partial identities | +| Retry | creator can reenter additional selection | fresh read and current-plus-confirmed exclusion | +| Publication | generic unchanged-state text can hide partial effects | assignment-specific retained facts, other results kept separately | + +## 6. State and functional behavior + +Stages are `read-assignees`, `lookup-members`, `assign-creator`, `select-members`, +and `assign-members`. The application advances them immediately before the +corresponding semantic port operation. The adapter separately owns `list-teams`, +`list-team-members`, `read-assignees`, `add-assignees`, `initialize-client`, or +`unavailable` when no provider operation was established. + +The creator has first preference only while the target still needs assignments. +Current/confirmed identities compare without case; duplicated response aliases +cannot multiply success counts. A returned assignment list that omits requested +identities produces `provider.contract-invalid`, retaining any identities that +were confirmed. No rollback removes a legitimate assignee. + +Writes are `not-attempted`, `verified`, or `unverified`. Confirmed counts describe +this attempt only. An unread current list is unavailable, not a verified empty +list. Later selection failure after creator confirmation retains that success. + +## 7. Configuration + +`desired-assignees-count` stays a validated integer 0–10, default 1, from the +existing execution configuration snapshot. Zero remains a no-op before target +validation or provider/client access. Recommended: `1`; alternative: `2` for two +owners or `0` to disable. No new precedence, storage or migration is introduced. +Diagnostic allowlists, redaction, confirmation and retry classification are not +configurable. No raw provider text can become a setting or recovery template. + +## 8. Clean Architecture + +```mermaid +flowchart LR + SDK[REST provider adapter] --> E[Sanitized semantic provider failure] + E --> W[Assignment workflow] + D[Pure diagnostic validation and assignment policy] --> W + W --> R[Semantic Result and recovery descriptor] + R --> P[Localized terminal/GitHub/API presentation] +``` + +Text equivalent: adapters translate provider failures; application orchestration +adds stage and confirmed progress; pure validation bounds the descriptor; +presentation localizes the resulting facts without mutation authority. + +`AssignmentProviderError` is an application-port error with a closed operation +and safe status, extending the existing semantic error. Raw causes terminate +at the adapter. `issueAssignmentFailure` preserves category/correlation and adds +application-owned progress. Existing bound port signatures/composition stay +unchanged. No application code parses Octokit DTOs. Architecture/import-boundary +checks enforce dependency direction; validators and type checks enforce exact +descriptor keys and bounded values. Existing assignment policy remains provider +free. Other lifecycle ownership and configuration persistence are unchanged. + +## 9. Product content, accessibility and localization + +Existing result surfaces are reused; there is no new comment/card or browser UI. + +```text +Pending: Read current assignees, then add only missing eligible members. +Complete: The creator assignment was confirmed; no assignment remains required. +Action required: Assignment stopped while checking organization membership. + Provider operation: organization teams; status: http-403. + Check the configured credential, documented access and target resource. + Verified new assignments: 0. Remaining target count: 1. Write: not attempted. +Partial: Additional-member selection failed after one confirmed creator. + Verified new assignments: 1. Remaining target count: 1. Write: confirmed. +Failed write: Response unavailable. Write outcome unverified; it may have applied. + Rerun the same workflow after recovery; current assignees are read again. +``` + +The existing error code, Retryable and correlation Reference remain visible. +Known 403 denial differs from 403/429 rate limiting. A 404 does not establish +absence of a private resource. English and Spanish use catalog IDs; other locales +retain existing catalog resolution/fallback. Text is readable without color, +emoji or tables, on narrow/light/dark surfaces. Only allowlisted identifiers and +counts enter templates; supplied Markdown, URLs or member names cannot become +diagnostic content. Existing workflow/run links and result ordering are retained. + +## 10. Failure, recovery, idempotency and cleanup + +| Failure | Retry guidance | Retained facts | +|---|---|---| +| 401/403 denial | inspect configured authentication/access before retry | verified assignees and independent completed effects | +| 404 | verify resource and access; do not infer private absence | same | +| 403/429 limited | wait for provider limit recovery | same; no in-process retry storm | +| 408/5xx/recognized transport failure | retry after provider recovery | confirmed writes; failed write may have applied | +| malformed response | investigate provider contract; no fake empty list | last verified facts only | +| unknown failure | investigate by correlation; no blind transient retry | bounded stage; unavailable status when unknown | + +Retries read native state and skip a satisfied target. The additive native API +does not require deleting existing assignments. External concurrent writers can +race a read/add; this correction makes no transaction or conditional-write claim. +No durable assignment cache is introduced, so cancellation leaves native effects +for the next ordinary event to reread. Other steps keep their existing read/no-op +contracts. No new scratch runtime or production cleanup is needed. + +## 11. Security and privacy + +Existing credential-bound ports and permissions remain. No failed diagnostic +authorizes expanding scopes. Safe metadata is extracted once and provider +exceptions are discarded. Structured recovery accepts exact keys, positive safe +target identity, known stages/operations, HTTP 100–599 or unavailable, and bounded +counts or explicit `unavailable` markers for unread current/remaining values. No token, organization/team/member list, raw cause or response is retained +in the error. SDK fault tests use fixed fakes and no service credentials. + +## 12. Observability and operational UX + +One assignment failure reaches its existing Result and log, with the same +correlation across adapter/application wrapping. Stage, operation, HTTP status, +category, retryability, observed/confirmed/remaining counts and write certainty +are available without raw exceptions. Independent type/Project/configuration +outcomes remain in their own lifecycle results; the assignment error does not +predict future success. No new metrics service, repeated comments or notification +budget is added. Logs and public rendering share the validated descriptor. + +## 13. Compatibility, rollout and rollback + +This is an additive recovery descriptor and internal error refinement with no +port/config schema migration. Legacy null/missing `assignees` still maps to an +empty native list; malformed non-list/row shapes fail closed. Unknown failures +now correctly require investigation instead of an invented retryable outage. +Creator success now requires confirmation. Existing successes and partial +results remain visible. Ship through normal SDK bundles/package and managed patch +after the bug backlog; rollback to the previous reviewed SDK does not undo native +effects and reinstates the diagnosed limitations. No consumer history is edited. + +## 14. Numeric test budget and evidence + +Minimum **60 new distinct cases**: 18 adapter classification/redaction/runtime +shape cases; 14 pure diagnostic validation/security cases; 12 application +confirmation/replay/partial-state cases; 8 localized presentation cases; 4 issue +route integration cases; and 4 semantic identity/serialization/architecture +compatibility cases. Cases count once; repeated runs do not count as new cases. + +Local acceptance on 2026-11-03 passes **886 suites / 17,317 Jest tests** +(35 pre-existing skips) and **267 extension tests** at 100% in all four metrics. +There are **133 new distinct cases**: 29 pure validation, 67 provider/adapter, +14 application, 14 localized presentation, four issue-route integration and five +semantic/serialization cases (four allocated from the 33 domain cases plus the +new exhaustive recovery-inventory case). No case is counted twice. All eleven +affected assignment modules reach 100% lines/statements/functions/branches. +Full-suite LCOV hits **150/150 modified coverable lines**, with zero misses or +partials against `7d1e8d3b`. This is local evidence; official Codecov and remote +current-head checks remain PR delivery gates. + +All changed coverable lines must be covered in full-suite LCOV and official +Codecov against the live base, with zero modified misses/partials. New pure +validation and presentation modules target 100% statements/branches/functions/ +lines. Existing repository, extension, architecture and coverage budgets remain. +Use real SDK adapters with deterministic provider fakes, no sleeps or live fault +injection. Rendered content assertions cover safe English/Spanish output and +unknown/partial states; no VoiceOver or browser product UI changes are involved. + +## 15. Documentation + +Update assignment recovery in `docs/issues/assignees-and-projects.mdx` and the +error reference for operators. Link this companion from the managed lifecycle +SDD and catalog; regenerate `specs/CATALOG.md`. Existing authentication/setup and +configuration docs remain authoritative because permissions and inputs do not +change. Validate documentation/navigation and the SDD/catalog together. + +## 16. Acceptance + +Unavailable, denied, limited, malformed and unknown lookups produce distinct safe +diagnostics. An already assigned target performs no membership lookup/write. +Unconfirmed creator response is never success. Confirmed creator/additional +identities survive a later failure; a fresh retry adds only missing members. +A write that applied before throwing is not repeated after the fresh read. +Independent issue steps continue and retain their results after assignment +failure. API JSON and English/Spanish text preserve safe recovery/correlation. + +## 17. Traceability + +| Requirements | Verification | Documentation | +|---|---|---| +| R1–R2 | `src/data/repository/github/__tests__/assignment_provider_failure.test.ts` and `src/data/repository/__tests__/assignment_provider_contract.test.ts` | error reference | +| R3–R4 | `src/tooling/__tests__/issue_assignment_diagnostics.test.ts`, including four real issue-route cases, and `src/application/policies/__tests__/assignee_assignment_policy.test.ts` | assignees and projects | +| R5 | `src/domain/__tests__/issue_assignment_diagnostic.test.ts`, `src/application/policies/__tests__/issue_assignment_messages.test.ts`, exhaustive `application_error_presentation_policy.test.ts` recovery inventory | both pages | +| R6 | architecture/types/docs/catalog/package/bundles and current-head CI/review/Codecov | this SDD/catalog | + +## 18. Implementation sequence + +Preserve original evidence; execute baseline failures; define bounded semantic +contracts; map adapters; retain application progress/confirmations; localize; +execute regression/architecture/full coverage; align docs/catalog and bundles; +publish normal commits; verify complete current-head review/Codecov/platforms. + +## 19. Definition of Done + +All six requirements, numeric budget and changed-line coverage pass; architecture +and generated artifacts remain valid; no secrets or unconfirmed-success claims +escape; documentation/catalog agree. Merge only with complete current-head review, +zero open findings/threads, all applicable checks and official diff coverage 100% +with zero misses/partials against the correct base. Then verify native issue +closure and retire owned disposable outputs while retaining evidence/rollback. + +## 20. References and decisions + +- [GitHub API troubleshooting](https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api) +- [List teams](https://docs.github.com/en/rest/teams/teams#list-teams) +- [List team members](https://docs.github.com/en/rest/teams/members#list-team-members) +- [Managed lifecycle](./managed-issue-and-branch-lifecycle.md) +- [Semantic errors](./execution-error-and-context-hardening.md) + +Primary provider documentation was checked on 2026-11-03. Preserve safe facts, +not raw provider messages. Read current assignment first to make satisfied +replays independent of membership availability. Keep the existing coordinator and +independent step contracts; no new transaction, persistence framework, model +qualification or consumer permission change is needed. diff --git a/specs/managed-issue-and-branch-lifecycle.md b/specs/managed-issue-and-branch-lifecycle.md index 6a9148d48..1c9e08a64 100644 --- a/specs/managed-issue-and-branch-lifecycle.md +++ b/specs/managed-issue-and-branch-lifecycle.md @@ -133,6 +133,13 @@ add mutation returns the exact ProjectV2 item ID. A direct read of that node ver ### 6.2 Alternative paths +Assignment diagnosis and replay are specified by the focused +[assignment failure and recovery contract](./issue-assignment-failure-recovery.md) +for #849. Read native assignees before membership, retain a satisfied target, +confirm requested assignments, preserve safe provider/application context and +confirmed partial effects, then reread native state on retry. Other lifecycle +steps retain their independent results; assignment failure does not certify them. + - With `issue-managed-branches=false`, eligible regular work proceeds without an Action-managed branch; help is always branchless. - Existing correct branch becomes a no-op; a rename decision may create the new safe name. - Admitted help issues receive answers instead of branch recommendations. A diff --git a/src/application/errors/issue_assignment_failure.ts b/src/application/errors/issue_assignment_failure.ts new file mode 100644 index 000000000..f9997e290 --- /dev/null +++ b/src/application/errors/issue_assignment_failure.ts @@ -0,0 +1,16 @@ +import { ApplicationError } from './application_error'; +import { AssignmentProviderError } from '../ports/assignment_provider_error'; +import type { IssueAssignmentDiagnostic } from '../../domain/issue_assignment_diagnostic'; + +/** Add application-owned progress without losing the originating safe category or correlation. */ +export function issueAssignmentFailure(cause: unknown, + facts: Omit): ApplicationError { + return new ApplicationError(cause instanceof ApplicationError ? cause.code : 'unexpected', 'Automatic member assignment stopped.', { + ...(cause instanceof ApplicationError ? { retryable: cause.retryable, correlationId: cause.correlationId } : {}), + recovery: { id: 'issue-assignment-failed', variables: { ...facts, + operation: cause instanceof AssignmentProviderError ? cause.operation + : facts.stage === 'assign-creator' || facts.stage === 'assign-members' ? 'add-assignees' + : facts.stage === 'read-assignees' ? 'read-assignees' : 'unavailable', + providerStatus: cause instanceof AssignmentProviderError ? cause.providerStatus : 'http-unavailable' } }, + }); +} diff --git a/src/application/policies/__tests__/application_error_presentation_policy.test.ts b/src/application/policies/__tests__/application_error_presentation_policy.test.ts index ff1a254ba..38867c67f 100644 --- a/src/application/policies/__tests__/application_error_presentation_policy.test.ts +++ b/src/application/policies/__tests__/application_error_presentation_policy.test.ts @@ -20,6 +20,9 @@ const RECOVERY_CASES: readonly Readonly<{ english: string; spanish: string; }>[] = [ + { recovery: { id: 'issue-assignment-failed', variables: { targetNumber: 17, desiredCount: 2, stage: 'lookup-members', operation: 'list-teams', providerStatus: 'http-503', assigneeRead: 'verified', observedAssignees: 0, confirmedAssignments: 0, remainingAssignments: 2, mutation: 'not-attempted' } }, + english: 'Verified new assignments: 0. Remaining target count: 2. Current assignee read: verified. Assignment write: not attempted. Independent completed lifecycle effects are retained in their own results.', + spanish: 'Asignaciones nuevas verificadas: 0. Objetivo pendiente: 2. Lectura de personas asignadas: verificada. Escritura de asignaciones: no intentada. Los efectos independientes completados se conservan en sus propios resultados.' }, { recovery: { id: 'branch-sync-workflow-write-required', variables: { reason: 'workflow-write-required', remoteState: 'unchanged', originalSha: 'a'.repeat(40), candidateSha: 'b'.repeat(40), observedSha: 'a'.repeat(40), pushAttempted: true } }, english: `The local merge commit step finished; candidate identity: ${'b'.repeat(40)}. A normal push was attempted. The observed remote still points to the original head; the candidate was not observed as published. Original head: ${'a'.repeat(40)}; observed head: ${'a'.repeat(40)}. Recovery did not reset or abort the committed workspace; runner-local storage is temporary.`, spanish: `Terminó el paso de commit del merge local; identidad del candidato: ${'b'.repeat(40)}. Se intentó un push normal. El remoto observado conserva el head original; no se observó la publicación del candidato. Head original: ${'a'.repeat(40)}; head observado: ${'a'.repeat(40)}. La recuperación no restableció ni abortó el espacio de trabajo confirmado; el almacenamiento del runner es temporal.` }, diff --git a/src/application/policies/__tests__/issue_assignment_messages.test.ts b/src/application/policies/__tests__/issue_assignment_messages.test.ts new file mode 100644 index 000000000..4fa29f110 --- /dev/null +++ b/src/application/policies/__tests__/issue_assignment_messages.test.ts @@ -0,0 +1,37 @@ +import { ApplicationError } from '../../errors/application_error'; +import { buildApplicationErrorPresentation, renderApplicationErrorMarkdown, renderApplicationErrorText } from '../application_error_presentation_policy'; +import { resolveStaticApplicationErrorCatalog } from '../application_error_message_catalog'; +import type { ApplicationErrorCode } from '../../../data/model/application_error'; +import type { IssueAssignmentDiagnostic } from '../../../domain/issue_assignment_diagnostic'; + +const facts: IssueAssignmentDiagnostic = { targetNumber: 17, desiredCount: 2, stage: 'lookup-members', operation: 'list-teams', providerStatus: 'http-403', + assigneeRead: 'verified', observedAssignees: 0, confirmedAssignments: 0, remainingAssignments: 2, mutation: 'not-attempted' }; + +describe.each(['en', 'es'])('assignment diagnostic presentation (%s)', locale => { + const message = resolveStaticApplicationErrorCatalog(locale).message; + it.each([ + { code: 'authorization.denied', override: {} }, + { code: 'provider.not-found', override: { providerStatus: 'http-404' } }, + { code: 'provider.unavailable', override: { stage: 'read-assignees', operation: 'read-assignees', providerStatus: 'http-unavailable', assigneeRead: 'unavailable', observedAssignees: 'unavailable', remainingAssignments: 'unavailable' } }, + { code: 'authorization.credential-invalid', override: { operation: 'initialize-client', providerStatus: 'http-401' } }, + { code: 'provider.unavailable', override: { stage: 'select-members', operation: 'list-team-members', providerStatus: 'http-503', confirmedAssignments: 1, remainingAssignments: 1, mutation: 'verified' } }, + { code: 'provider.contract-invalid', override: { stage: 'assign-creator', operation: 'add-assignees', mutation: 'unverified' } }, + { code: 'unexpected', override: { stage: 'assign-members', operation: 'unavailable', providerStatus: 'http-unavailable', mutation: 'unverified' } }, + ])('renders complete safe progress and recovery for $code/$override', ({ code, override }) => { + const error = new ApplicationError(code as ApplicationErrorCode, 'Safe message.', { recovery: { id: 'issue-assignment-failed', variables: { ...facts, ...override } as IssueAssignmentDiagnostic } }); + const view = buildApplicationErrorPresentation(error, message); + const text = renderApplicationErrorText(error, message), markdown = renderApplicationErrorMarkdown(error, message); + expect(view.impact).toContain('#17'); + expect(view.action.length).toBeGreaterThan(50); + expect(view.retainedState).toContain(String(override.confirmedAssignments ?? 0)); + expect(view.reference).toBe(error.correlationId); + expect(view.code).toBe(code); + expect(view.retryable).toBe(message(error.retryable ? 'error.label.yes' : 'error.label.no')); + expect(text).toContain(view.retainedState); + expect(markdown).toContain(view.impact); + expect(text + markdown).not.toMatch(/\{\w+\}|private-token|private-org|private-member/); + if (override.assigneeRead === 'unavailable') expect(view.retainedState).toContain(message('error.label.assignmentUnavailable')); + if (code === 'provider.not-found') expect(view.action).toContain('404'); + expect(error.toJSON().retainedState).toContain('independently completed effects'); + }); +}); diff --git a/src/application/policies/application_error_message_catalog.ts b/src/application/policies/application_error_message_catalog.ts index 9241532ad..66e0caf00 100644 --- a/src/application/policies/application_error_message_catalog.ts +++ b/src/application/policies/application_error_message_catalog.ts @@ -1,4 +1,5 @@ import { BRANCH_SYNC_PUBLICATION_LABEL_KEYS, ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT } from './branch_sync_publication_messages'; +import { ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, ENGLISH_ASSIGNMENT_LABELS, SPANISH_ASSIGNMENT_LABELS, ENGLISH_ASSIGNMENT_CONTENT, SPANISH_ASSIGNMENT_CONTENT } from './issue_assignment_messages'; import { BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ENGLISH_REVIEW_DIAGNOSTIC_LABELS, SPANISH_REVIEW_DIAGNOSTIC_LABELS } from './bugbot_review_diagnostic_labels'; import { ENGLISH_COMMAND_VALIDATION_CONTENT, SPANISH_COMMAND_VALIDATION_CONTENT } from './comment_command_validation_message_catalog'; import type { @@ -26,6 +27,7 @@ import { const LABEL_KEYS = Object.freeze([ ...BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...BRANCH_SYNC_PUBLICATION_LABEL_KEYS, + ...ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ] as const); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState'] as const); @@ -57,6 +59,7 @@ export const APPLICATION_ERROR_MESSAGE_IDS: readonly ApplicationErrorMessageId[] const ENGLISH_LABELS: Readonly> = Object.freeze({ ...ENGLISH_REVIEW_DIAGNOSTIC_LABELS, ...ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...ENGLISH_ASSIGNMENT_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -70,6 +73,7 @@ const ENGLISH_LABELS: Readonly> = Object.freeze({ const SPANISH_LABELS: Readonly> = Object.freeze({ ...SPANISH_REVIEW_DIAGNOSTIC_LABELS, ...SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, + ...SPANISH_ASSIGNMENT_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -220,6 +224,7 @@ const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored co const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ + ...ENGLISH_ASSIGNMENT_CONTENT, ...ENGLISH_COMMAND_VALIDATION_CONTENT, ...ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ @@ -421,6 +426,7 @@ const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ }); const SPANISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ + ...SPANISH_ASSIGNMENT_CONTENT, ...SPANISH_COMMAND_VALIDATION_CONTENT, ...SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ diff --git a/src/application/policies/application_error_presentation_policy.ts b/src/application/policies/application_error_presentation_policy.ts index 762cafa2d..ded492e95 100644 --- a/src/application/policies/application_error_presentation_policy.ts +++ b/src/application/policies/application_error_presentation_policy.ts @@ -20,6 +20,7 @@ const BRANCH_SYNC_CATALOG_RECOVERY_URL = 'https://docs.page/vypdev/copilot/secur const BRANCH_SYNC_PUBLICATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery'; import { sourceContextDiagnostic } from '../../domain/agent_source_snapshot'; +import { assignmentDiagnosticPresentation } from './issue_assignment_diagnostic_presentation'; export interface ApplicationErrorPresentation { readonly impact: string; @@ -45,7 +46,9 @@ export function buildApplicationErrorPresentation( const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` as const : `error.${error.code}` as const; - const variables = error.recovery?.id === 'sdd-source-context-unavailable' + const variables = error.recovery?.id === 'issue-assignment-failed' + ? assignmentDiagnosticPresentation(error.recovery.variables, error.code, message) + : error.recovery?.id === 'sdd-source-context-unavailable' ? { ...sourceContextDiagnostic(error.recovery.variables) } : error.recovery?.id === 'progress-partition-length-invalid' ? progressPartitionLengthPresentation(error.recovery.variables) diff --git a/src/application/policies/assignee_assignment_policy.ts b/src/application/policies/assignee_assignment_policy.ts index 6bddca49a..0097a991f 100644 --- a/src/application/policies/assignee_assignment_policy.ts +++ b/src/application/policies/assignee_assignment_policy.ts @@ -47,5 +47,6 @@ export function calculateRemainingAssignees( export function selectConfirmedAssignees(requestedMembers: readonly string[], assignedMembers: readonly string[]): string[] { const requestedIdentities = new Set(requestedMembers.map((member) => member.toLowerCase())); - return assignedMembers.filter((member) => requestedIdentities.has(member.toLowerCase())); + return [...new Map(assignedMembers.filter((member) => requestedIdentities.has(member.toLowerCase())) + .map(member => [member.toLowerCase(), member])).values()]; } diff --git a/src/application/policies/issue_assignment_diagnostic_presentation.ts b/src/application/policies/issue_assignment_diagnostic_presentation.ts new file mode 100644 index 000000000..4ccb860a3 --- /dev/null +++ b/src/application/policies/issue_assignment_diagnostic_presentation.ts @@ -0,0 +1,27 @@ +import type { ApplicationErrorCode } from '../../data/model/application_error'; +import type { IssueAssignmentDiagnostic, AssignmentOperation, AssignmentStage } from '../../domain/issue_assignment_diagnostic'; +import type { ApplicationErrorMessageReader } from './application_error_message_catalog'; + +import type { ENGLISH_ASSIGNMENT_LABELS } from './issue_assignment_messages'; + +const STAGES: Record = { + 'read-assignees': 'assignmentRead', 'lookup-members': 'assignmentLookup', 'select-members': 'assignmentSelect', + 'assign-creator': 'assignmentCreator', 'assign-members': 'assignmentMembers', +}; +const OPERATIONS: Record = { + 'read-assignees': 'assignmentReadOperation', 'list-teams': 'assignmentTeams', 'list-team-members': 'assignmentTeamMembers', + 'add-assignees': 'assignmentAdd', 'initialize-client': 'assignmentClient', unavailable: 'assignmentUnavailable', +}; + +export function assignmentDiagnosticPresentation(facts: IssueAssignmentDiagnostic, code: ApplicationErrorCode, message: ApplicationErrorMessageReader) { + return { ...facts, + stage: message(`error.label.${STAGES[facts.stage]}`), operation: message(`error.label.${OPERATIONS[facts.operation]}`), + providerStatus: facts.providerStatus === 'http-unavailable' ? message('error.label.assignmentUnavailable') : facts.providerStatus, + assigneeRead: message(facts.assigneeRead === 'verified' ? 'error.label.assignmentReadVerified' : 'error.label.assignmentUnavailable'), + remainingAssignments: facts.assigneeRead === 'verified' ? facts.remainingAssignments : message('error.label.assignmentUnavailable'), + mutation: message(facts.mutation === 'not-attempted' ? 'error.label.assignmentWriteNone' + : facts.mutation === 'unverified' ? 'error.label.assignmentWriteUnverified' : 'error.label.assignmentWriteVerified'), + guidance: code.startsWith('authorization.') || code === 'provider.not-found' + ? message('error.label.assignmentAccess') : message(`error.${code}.action`), + }; +} diff --git a/src/application/policies/issue_assignment_messages.ts b/src/application/policies/issue_assignment_messages.ts new file mode 100644 index 000000000..6a2211d41 --- /dev/null +++ b/src/application/policies/issue_assignment_messages.ts @@ -0,0 +1,32 @@ +export const ENGLISH_ASSIGNMENT_LABELS = { + assignmentRead: 'reading current assignees', assignmentLookup: 'checking organization membership', + assignmentSelect: 'selecting additional members', assignmentCreator: 'assigning the creator', assignmentMembers: 'assigning additional members', + assignmentReadOperation: 'issue assignees', assignmentTeams: 'organization teams', assignmentTeamMembers: 'organization team members', + assignmentAdd: 'add issue assignees', assignmentClient: 'provider client initialization', assignmentUnavailable: 'unavailable', + assignmentReadVerified: 'verified', assignmentWriteNone: 'not attempted', assignmentWriteUnverified: 'outcome unverified; it may have applied', + assignmentWriteVerified: 'confirmed', assignmentAccess: 'Check the configured credential, documented access and target resource before retrying. A 404 alone does not establish that a private resource is absent.', +} as const; +export const ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS = Object.keys(ENGLISH_ASSIGNMENT_LABELS) as Array; +export const SPANISH_ASSIGNMENT_LABELS: Record = { + assignmentRead: 'leer las personas asignadas', assignmentLookup: 'consultar los miembros de la organización', + assignmentSelect: 'seleccionar miembros adicionales', assignmentCreator: 'asignar al autor', assignmentMembers: 'asignar miembros adicionales', + assignmentReadOperation: 'personas asignadas a la issue', assignmentTeams: 'equipos de la organización', assignmentTeamMembers: 'miembros de los equipos', + assignmentAdd: 'añadir personas asignadas', assignmentClient: 'inicialización del cliente', assignmentUnavailable: 'no disponible', + assignmentReadVerified: 'verificada', assignmentWriteNone: 'no intentada', assignmentWriteUnverified: 'resultado sin verificar; pudo aplicarse', + assignmentWriteVerified: 'confirmada', assignmentAccess: 'Comprueba la credencial configurada, el acceso documentado y el recurso antes de reintentar. Un 404 por sí solo no demuestra que un recurso privado no exista.', +}; + +export const ENGLISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'Automatic assignment for #{targetNumber} stopped during {stage}. Provider operation: {operation}; status: {providerStatus}.', + action: '{guidance} Rerun the same issue workflow after addressing the cause; it rereads assignees before adding missing members.', + retainedState: 'Verified new assignments: {confirmedAssignments}. Remaining target count: {remainingAssignments}. Current assignee read: {assigneeRead}. Assignment write: {mutation}. Independent completed lifecycle effects are retained in their own results.', + }, +}; +export const SPANISH_ASSIGNMENT_CONTENT = { + 'issue-assignment-failed': { + impact: 'La asignación automática de #{targetNumber} se detuvo al {stage}. Operación del proveedor: {operation}; estado: {providerStatus}.', + action: '{guidance} Repite el mismo workflow tras resolver la causa; volverá a leer las personas asignadas antes de añadir las pendientes.', + retainedState: 'Asignaciones nuevas verificadas: {confirmedAssignments}. Objetivo pendiente: {remainingAssignments}. Lectura de personas asignadas: {assigneeRead}. Escritura de asignaciones: {mutation}. Los efectos independientes completados se conservan en sus propios resultados.', + }, +}; diff --git a/src/application/ports/assignment_provider_error.ts b/src/application/ports/assignment_provider_error.ts new file mode 100644 index 000000000..245bf15eb --- /dev/null +++ b/src/application/ports/assignment_provider_error.ts @@ -0,0 +1,13 @@ +import { ApplicationError, type ApplicationErrorCode } from '../errors/application_error'; +import { ASSIGNMENT_OPERATIONS, isAssignmentProviderStatus, type AssignmentOperation } from '../../domain/issue_assignment_diagnostic'; + +/** Sanitized adapter evidence. Provider messages, bodies, member lists and credentials are discarded. */ +export class AssignmentProviderError extends ApplicationError { + constructor(code: ApplicationErrorCode, readonly operation: AssignmentOperation, readonly providerStatus: string, + options: { readonly retryable?: boolean; readonly correlationId?: string } = {}) { + super(code, 'The assignment provider operation failed.', options); + if (!ASSIGNMENT_OPERATIONS.includes(operation) || !isAssignmentProviderStatus(providerStatus)) { + throw new TypeError('Assignment provider evidence is invalid.'); + } + } +} diff --git a/src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts b/src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts index 071481d27..0a201dfa7 100644 --- a/src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts +++ b/src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts @@ -37,7 +37,7 @@ describe('AssignMemberToIssueUseCase', () => { mockGetAllMembers.mockResolvedValue(['alice', 'bob']); mockGetCurrentAssignees.mockResolvedValue([]); mockGetRandomMembers.mockResolvedValue(['bob']); - mockAssignMembersToIssue.mockResolvedValue(['bob']); + mockAssignMembersToIssue.mockImplementation(async (_number: number, members: string[]) => members); }); it('assigns issue creator when creator is team member and not yet assigned', async () => { diff --git a/src/application/usecases/steps/issue/assign_members_workflow.ts b/src/application/usecases/steps/issue/assign_members_workflow.ts index 6b05d1558..82b3daf06 100644 --- a/src/application/usecases/steps/issue/assign_members_workflow.ts +++ b/src/application/usecases/steps/issue/assign_members_workflow.ts @@ -9,7 +9,9 @@ import { resolveCreatorAssignment, selectConfirmedAssignees, } from '../../../policies/assignee_assignment_policy'; -import { toApplicationError } from '../../../errors/application_error'; +import { ApplicationError, toApplicationError } from '../../../errors/application_error'; +import { issueAssignmentFailure } from '../../../errors/issue_assignment_failure'; +import type { IssueAssignmentDiagnostic } from '../../../../domain/issue_assignment_diagnostic'; import type { AssignmentContext } from '../../issue_workflow_context'; export interface AssignMembersWorkflowDependencies { @@ -27,27 +29,41 @@ export async function runAssignMembersWorkflow( logInfo(`${getTaskEmoji(TASK_ID)} Executing ${TASK_ID}.`); const target = resolveAssigneeTarget(param); const results: Result[] = []; + let stage: IssueAssignmentDiagnostic['stage'] = 'read-assignees'; + let assigneeRead: IssueAssignmentDiagnostic['assigneeRead'] = 'unavailable'; + let mutation: IssueAssignmentDiagnostic['mutation'] = 'not-attempted'; + let observedAssignees = 0; + const confirmed: string[] = []; try { logDebugInfo(`#${target.number} needs ${target.desiredCount} assignees.`); if (target.desiredCount <= 0) return [new Result({ id: TASK_ID, success: true, executed: false })]; if (target.number <= 0) return [assignmentResult(false, 'Issue or pull request number is not available.')]; - const [currentProjectMembers, currentMembers] = await Promise.all([ - dependencies.projectRepository.getAllMembers(), - dependencies.issueRepository.getCurrentAssignees(target.number), - ]); + const currentMembers = await dependencies.issueRepository.getCurrentAssignees(target.number); + observedAssignees = new Set(currentMembers.map(member => member.toLowerCase())).size; + assigneeRead = 'verified'; + if (observedAssignees >= target.desiredCount) return [new Result({ id: TASK_ID, success: true, executed: true })]; + stage = 'lookup-members'; + const currentProjectMembers = await dependencies.projectRepository.getAllMembers(); const creatorAssignment = resolveCreatorAssignment(param, currentProjectMembers, currentMembers); if (creatorAssignment) { const { login: creator, source } = creatorAssignment; - await dependencies.issueRepository.assignMembersToIssue(target.number, [creator]); + stage = 'assign-creator'; + mutation = 'unverified'; + const added = await dependencies.issueRepository.assignMembersToIssue(target.number, [creator]); + if (selectConfirmedAssignees([creator], added).length === 0) { + throw new ApplicationError('provider.contract-invalid', 'The creator assignment was not confirmed.'); + } + confirmed.push(creator); + mutation = 'verified'; logDebugInfo(`Assigned ${source} creator @${creator} to #${target.number}.`); results.push(assignmentResult(true, `The ${source} was assigned to @${creator} (creator).`)); } const remainingAssignees = calculateRemainingAssignees( target.desiredCount, - currentMembers.length, + observedAssignees, creatorAssignment !== undefined, ); if (remainingAssignees <= 0) { @@ -55,22 +71,35 @@ export async function runAssignMembersWorkflow( return results; } - const members = await dependencies.projectRepository.getRandomMembers(remainingAssignees, currentMembers); + stage = 'select-members'; + const members = await dependencies.projectRepository.getRandomMembers(remainingAssignees, [...currentMembers, ...confirmed]); if (members.length === 0) { results.push(assignmentResult(false, 'Tried to assign members to issue, but no one was found.')); return results; } + stage = 'assign-members'; + mutation = 'unverified'; const membersAdded = await dependencies.issueRepository.assignMembersToIssue(target.number, members); + const verifiedMembers = selectConfirmedAssignees(members, membersAdded); + confirmed.push(...verifiedMembers); results.push( - ...selectConfirmedAssignees(members, membersAdded).map((member) => assignmentResult( + ...verifiedMembers.map((member) => assignmentResult( true, `The ${param.target} was assigned to @${member}.`, )), ); + if (verifiedMembers.length !== members.length) { + throw new ApplicationError('provider.contract-invalid', 'Some requested assignments were not confirmed.'); + } + mutation = 'verified'; return results; } catch (error) { - const semanticError = toApplicationError(error, 'provider.unavailable', 'Unable to assign members.'); + const semanticError = issueAssignmentFailure(toApplicationError(error, 'unexpected', 'Automatic member assignment stopped.'), { + targetNumber: target.number, desiredCount: target.desiredCount, stage, assigneeRead, mutation, + observedAssignees: assigneeRead === 'verified' ? observedAssignees : 'unavailable', confirmedAssignments: confirmed.length, + remainingAssignments: assigneeRead === 'verified' ? Math.max(0, target.desiredCount - observedAssignees - confirmed.length) : 'unavailable', + }); logError(semanticError); results.push( new Result({ @@ -85,11 +114,11 @@ export async function runAssignMembersWorkflow( } } -function assignmentResult(success: boolean, step?: string): Result { +function assignmentResult(success: boolean, step: string): Result { return new Result({ id: TASK_ID, success, executed: true, - steps: step ? [step] : [], + steps: [step], }); } diff --git a/src/data/model/application_error.ts b/src/data/model/application_error.ts index 39ca66a3f..995d20687 100644 --- a/src/data/model/application_error.ts +++ b/src/data/model/application_error.ts @@ -8,6 +8,7 @@ import { isProgressPartitionLengthDiagnostic, type ProgressPartitionLengthDiagno import { isProgressBinaryEvidenceFacts, type ProgressBinaryEvidenceFacts } from '../../domain/progress_binary_evidence_diagnostic'; import { isIssueWorkflowDiagnostic, type IssueWorkflowDiagnosticFacts } from '../../domain/issue_workflow_diagnostic'; import { isChangeSizeReconciliationFacts, type ChangeSizeReconciliationFacts } from '../../domain/change_size_reconciliation'; +import { isIssueAssignmentDiagnostic, type IssueAssignmentDiagnostic } from '../../domain/issue_assignment_diagnostic'; import { COPILOT_COMMAND_VALIDATION_REASONS, isCopilotCommandValidationReason, isValidCopilotCommandValidation, type CopilotCommandValidationReason } from '../../domain/copilot_command_validation'; import type { CopilotCommandName } from '../../domain/copilot_command'; import { CLOSED_ISSUE_RESULTS, CLOSED_ISSUE_PHASES, CLOSED_ISSUE_REASONS, type ClosedIssueReconciliationFacts } from '../../domain/closed_issue_reconciliation'; @@ -84,6 +85,7 @@ export const APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -108,6 +110,7 @@ interface ApplicationErrorRecoveryVariables extends Readonly ({ logDebugInfo: jest.fn() })); + +function membership(data: unknown, memberPage = false, fail = false) { + const iterator = async function* (_method: unknown, params: { team_slug?: string }) { + if (!memberPage || params.team_slug) { + if (fail) throw { status: 403, message: 'private-token' }; + yield { data }; + } else yield { data: [{ slug: 'core' }] }; + }; + const client = { paginate: { iterator }, rest: { teams: { list: jest.fn(), listMembersInOrg: jest.fn() } } } as unknown as GithubOrganizationMembersClient; + return new OrganizationMembersRepository({ getClient: () => client }); +} + +describe('assignment native provider contracts', () => { + it.each([null, {}, 'private-token', [null], [[]], [{ slug: 17 }], [{ slug: ' ' }]])('rejects malformed teams instead of filtering them into an empty result (%j)', async data => { + await expect(membership(data).getAllMembers('private-org', 'private-token')).rejects.toMatchObject({ code: 'provider.contract-invalid', operation: 'list-teams' }); + }); + + it.each([null, {}, [null], [[]], [{ login: 17 }], [{ login: ' ' }]])('rejects malformed member pages after a valid team lookup (%j)', async data => { + await expect(membership(data, true).getAllMembers('private-org', 'private-token')).rejects.toMatchObject({ code: 'provider.contract-invalid', operation: 'list-team-members' }); + }); + + it('keeps the team-member operation when the awaited collection rejects after the initial read', async () => { + await expect(membership([], true, true).getAllMembers('private-org', 'private-token')).rejects.toMatchObject({ code: 'authorization.denied', operation: 'list-team-members', providerStatus: 'http-403' }); + }); + + it('keeps the same typed lookup diagnostic during random-member selection', async () => { + await expect(membership([], true, true).getRandomMembers('private-org', 1, [], 'private-token')).rejects.toMatchObject({ code: 'authorization.denied', operation: 'list-team-members' }); + }); + + it('sanitizes client initialization before membership queries', async () => { + const repository = new OrganizationMembersRepository({ getClient: () => { throw { status: 401, message: 'private-token' }; } }); + await expect(repository.getAllMembers('private-org', 'private-token')).rejects.toMatchObject({ code: 'authorization.credential-invalid', operation: 'initialize-client' }); + }); + + it.each([null, [], {}, { assignees: 'private-token' }, { assignees: [null] }, { assignees: ['private-token'] }, { assignees: [{ login: 4 }] }, { assignees: [{ login: ' ' }] }, { assignees: Array(11).fill({ login: 'alice' }) }])('validates assignment read/write responses (%j)', async data => { + const client = { rest: { issues: { get: async () => ({ data }), addAssignees: async () => ({ data }) } } } as unknown as GithubIssueAssignmentClient; + const repository = new IssueAssignmentRepository({ getClient: () => client }); + if (data && !Array.isArray(data) && Object.keys(data).length === 0) { + await expect(repository.getCurrentAssignees('o', 'r', 1, 't')).resolves.toEqual([]); + return; + } + await expect(repository.getCurrentAssignees('o', 'r', 1, 't')).rejects.toMatchObject({ code: 'provider.contract-invalid', operation: 'read-assignees' }); + await expect(repository.assignMembersToIssue('o', 'r', 1, ['alice'], 't')).rejects.toMatchObject({ code: 'provider.contract-invalid', operation: 'add-assignees' }); + }); + + it('skips empty writes before acquiring a client and maps client failure for actual work', async () => { + const getClient = jest.fn(() => { throw { status: 401 }; }); + const repository = new IssueAssignmentRepository({ getClient }); + await expect(repository.assignMembersToIssue('o', 'r', 1, [], 't')).resolves.toEqual([]); + expect(getClient).not.toHaveBeenCalled(); + await expect(repository.getCurrentAssignees('o', 'r', 1, 't')).rejects.toMatchObject({ operation: 'initialize-client', code: 'authorization.credential-invalid' }); + await expect(repository.assignMembersToIssue('o', 'r', 1, ['alice'], 't')).rejects.toMatchObject({ operation: 'initialize-client', code: 'authorization.credential-invalid' }); + }); + + it.each([null, undefined, 'private-token'])('rejects malformed response envelopes with a contract diagnostic (%j)', async envelope => { + const client = { rest: { issues: { get: async () => envelope, addAssignees: async () => envelope } } } as unknown as GithubIssueAssignmentClient; + const repository = new IssueAssignmentRepository({ getClient: () => client }); + await expect(repository.getCurrentAssignees('o', 'r', 1, 't')).rejects.toMatchObject({ code: 'provider.contract-invalid', operation: 'read-assignees' }); + await expect(repository.assignMembersToIssue('o', 'r', 1, ['alice'], 't')).rejects.toMatchObject({ code: 'provider.contract-invalid', operation: 'add-assignees' }); + }); + + it.each([false, true])('rejects a null paginated response envelope (member lookup %s)', async members => { + const iterator = async function* (_method: unknown, params: { team_slug?: string }) { + if (members && !params.team_slug) yield { data: [{ slug: 'core' }] }; + else yield null; + }; + const client = { paginate: { iterator }, rest: { teams: { list: jest.fn(), listMembersInOrg: jest.fn() } } } as unknown as GithubOrganizationMembersClient; + await expect(new OrganizationMembersRepository({ getClient: () => client }).getAllMembers('o', 't')).rejects.toMatchObject({ + code: 'provider.contract-invalid', operation: members ? 'list-team-members' : 'list-teams' }); + }); + + it('distinguishes valid empty teams and no available members from a failed read', async () => { + await expect(membership([]).getRandomMembers('o', 1, [], 't')).resolves.toEqual([]); + await expect(membership([{ login: 'alice' }], true).getRandomMembers('o', 1, ['alice'], 't')).resolves.toEqual([]); + }); +}); diff --git a/src/data/repository/__tests__/issue_assignment_repository.test.ts b/src/data/repository/__tests__/issue_assignment_repository.test.ts index a0e264f25..50bc0105b 100644 --- a/src/data/repository/__tests__/issue_assignment_repository.test.ts +++ b/src/data/repository/__tests__/issue_assignment_repository.test.ts @@ -23,7 +23,7 @@ describe('IssueAssignmentRepository', () => { mockGet.mockResolvedValueOnce({ data: { assignees: null } }).mockRejectedValueOnce(new Error('API error')); const repository = new IssueAssignmentRepository(new OctokitIssueAssignmentClientAdapter()); await expect(repository.getCurrentAssignees('o', 'r', 1, 't')).resolves.toEqual([]); - await expect(repository.getCurrentAssignees('o', 'r', 1, 't')).rejects.toThrow('API error'); + await expect(repository.getCurrentAssignees('o', 'r', 1, 't')).rejects.toMatchObject({ code: 'unexpected', operation: 'read-assignees', providerStatus: 'http-unavailable' }); }); it('skips empty assignments and maps updated assignees', async () => { diff --git a/src/data/repository/__tests__/organization_members_repository.test.ts b/src/data/repository/__tests__/organization_members_repository.test.ts index e362b5237..f7a1935d9 100644 --- a/src/data/repository/__tests__/organization_members_repository.test.ts +++ b/src/data/repository/__tests__/organization_members_repository.test.ts @@ -54,7 +54,7 @@ describe('OrganizationMembersRepository', () => { const provider = { getClient: jest.fn(() => client) } as unknown as GithubClientPort; const repository = new OrganizationMembersRepository(provider); - await expect(repository.getAllMembers('acme', 'token')).rejects.toThrow('organization access denied'); + await expect(repository.getAllMembers('acme', 'token')).rejects.toMatchObject({ code: 'unexpected', operation: 'list-teams', providerStatus: 'http-unavailable' }); }); it('does not query GitHub when no members are requested', async () => { diff --git a/src/data/repository/github/__tests__/assignment_provider_failure.test.ts b/src/data/repository/github/__tests__/assignment_provider_failure.test.ts new file mode 100644 index 000000000..4f06d4ab4 --- /dev/null +++ b/src/data/repository/github/__tests__/assignment_provider_failure.test.ts @@ -0,0 +1,65 @@ +import { ApplicationError } from '../../../../application/errors/application_error'; +import { AssignmentProviderError } from '../../../../application/ports/assignment_provider_error'; +import { withAssignmentProviderFailure } from '../assignment_provider_failure'; + +describe('assignment provider failure boundary', () => { + it.each([ + [401, 'authorization.credential-invalid', false], [403, 'authorization.denied', false], + [404, 'provider.not-found', false], [409, 'provider.conflict', true], [412, 'provider.conflict', true], + [400, 'provider.contract-invalid', false], [422, 'provider.contract-invalid', false], + [429, 'provider.rate-limited', true], [408, 'provider.unavailable', true], + [500, 'provider.unavailable', true], [503, 'provider.unavailable', true], [599, 'provider.unavailable', true], + ])('maps HTTP %s without provider text', async (status, code, retryable) => { + const cause = { status, message: 'private-token', response: { data: { members: ['private-member'] } } }; + const error = await withAssignmentProviderFailure('list-teams', async () => { throw cause; }).catch(value => value); + expect(error).toMatchObject({ code, retryable, operation: 'list-teams', providerStatus: `http-${status}` }); + expect(JSON.stringify(error)).not.toMatch(/private-token|private-member|"response":|"cause":/); + }); + + it.each(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN'])('recognizes transport %s', async code => { + await expect(withAssignmentProviderFailure('list-team-members', async () => { throw { code }; })) + .rejects.toMatchObject({ code: 'provider.unavailable', retryable: true, providerStatus: 'http-unavailable' }); + }); + + it.each([ + { status: 403, response: { headers: { 'Retry-After': '60' } } }, + { status: 403, response: { headers: { 'X-RateLimit-Remaining': '0' } } }, + { status: 403, message: 'secondary rate limit reached' }, + { response: { status: 403 }, headers: { 'retry-after': '60' } }, + ])('distinguishes a limited 403 from denied access (%j)', async cause => { + await expect(withAssignmentProviderFailure('read-assignees', async () => { throw cause; })) + .rejects.toMatchObject({ code: 'provider.rate-limited', retryable: true, providerStatus: 'http-403' }); + }); + + it.each([null, [], 'private-token', new Error('private-token'), { status: '503' }, { status: 500.5 }, { status: 600 }, { status: 0 }])('does not invent transient availability for an unknown failure (%j)', async cause => { + const error = await withAssignmentProviderFailure('add-assignees', async () => { throw cause; }).catch(value => value); + expect(error).toMatchObject({ code: 'unexpected', retryable: false, providerStatus: 'http-unavailable' }); + expect(JSON.stringify(error)).not.toContain('private-token'); + }); + + it('uses a bounded response HTTP status when top-level status is invalid', async () => { + await expect(withAssignmentProviderFailure('list-teams', async () => { throw { status: 'bad', response: { status: 504 } }; })) + .rejects.toMatchObject({ code: 'provider.unavailable', providerStatus: 'http-504' }); + }); + + it('preserves an existing semantic category, narrowed retryability and correlation', async () => { + const original = new ApplicationError('provider.unavailable', 'Safe diagnostic.', { retryable: false, cause: 'private-token' }); + const error = await withAssignmentProviderFailure('initialize-client', async () => { throw original; }).catch(value => value); + expect(error).toMatchObject({ code: original.code, retryable: false, correlationId: original.correlationId, operation: 'initialize-client' }); + expect(JSON.stringify(error)).not.toContain('private-token'); + }); + + it('preserves the specific inner operation when membership collection rejects asynchronously', async () => { + const inner = new AssignmentProviderError('authorization.denied', 'list-team-members', 'http-403'); + await expect(withAssignmentProviderFailure('initialize-client', async () => { throw inner; })).rejects.toBe(inner); + }); + + it('returns successful values unchanged', async () => { + const value = ['alice']; + await expect(withAssignmentProviderFailure('list-teams', async () => value)).resolves.toBe(value); + }); + + it.each([['not-an-operation', 'http-403'], ['list-teams', 'private-token']])('rejects invalid provider metadata (%s)', (operation, status) => { + expect(() => new AssignmentProviderError('unexpected', operation as never, status)).toThrow('Assignment provider evidence is invalid.'); + }); +}); diff --git a/src/data/repository/github/assignment_provider_failure.ts b/src/data/repository/github/assignment_provider_failure.ts new file mode 100644 index 000000000..69bc1057c --- /dev/null +++ b/src/data/repository/github/assignment_provider_failure.ts @@ -0,0 +1,30 @@ +import { ApplicationError, type ApplicationErrorCode } from '../../../application/errors/application_error'; +import { AssignmentProviderError } from '../../../application/ports/assignment_provider_error'; +import type { AssignmentOperation } from '../../../domain/issue_assignment_diagnostic'; +import { isGithubRateLimited } from './github_error_policy'; + +/** Terminate untrusted REST/transport failures at the adapter boundary, without retaining the cause. */ +export async function withAssignmentProviderFailure(operation: AssignmentOperation, perform: () => Promise): Promise { + try { return await perform(); } catch (cause) { + if (cause instanceof AssignmentProviderError) throw cause; + const error = record(cause), response = record(error.response); + const status = [error.status, response.status].find(value => typeof value === 'number' + && Number.isInteger(value) && value >= 100 && value <= 599) as number | undefined; + const code: ApplicationErrorCode = cause instanceof ApplicationError ? cause.code + : isGithubRateLimited({ status, response: { headers: response.headers ?? error.headers }, message: error.message }) ? 'provider.rate-limited' + : status === 401 ? 'authorization.credential-invalid' + : status === 403 ? 'authorization.denied' + : status === 404 ? 'provider.not-found' + : status === 409 || status === 412 ? 'provider.conflict' + : status === 400 || status === 422 ? 'provider.contract-invalid' + : status === 408 || typeof status === 'number' && status >= 500 + || ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN'].includes(error.code as string) + ? 'provider.unavailable' : 'unexpected'; + throw new AssignmentProviderError(code, operation, status === undefined ? 'http-unavailable' : `http-${status}`, + cause instanceof ApplicationError ? { retryable: cause.retryable, correlationId: cause.correlationId } : {}); + } +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : {}; +} diff --git a/src/data/repository/issue/issue_assignment_repository.ts b/src/data/repository/issue/issue_assignment_repository.ts index f2a2207d2..6a3bf4b87 100644 --- a/src/data/repository/issue/issue_assignment_repository.ts +++ b/src/data/repository/issue/issue_assignment_repository.ts @@ -1,19 +1,17 @@ -import { logDebugInfo, logError } from "../../../utils/logger"; +import { logDebugInfo } from "../../../utils/logger"; import type { GithubClientPort } from "../../../infrastructure/github/ports/github_client_provider_port"; import type { GithubIssueAssignmentClient } from "../../../infrastructure/github/ports/github_issue_provider_ports"; -import { toApplicationError } from '../../../application/errors/application_error'; +import { ApplicationError } from '../../../application/errors/application_error'; +import { withAssignmentProviderFailure } from '../github/assignment_provider_failure'; export class IssueAssignmentRepository { constructor(private readonly githubClient: GithubClientPort) {} getCurrentAssignees = async (owner: string, repository: string, issueNumber: number, token: string): Promise => { - const octokit = this.githubClient.getClient(token); - try { - const { data: issue } = await octokit.rest.issues.get({ owner, repo: repository, issue_number: issueNumber }); - return (issue.assignees ?? []).map(assignee => assignee.login); - } catch (error) { - logError(toApplicationError(error, 'provider.unavailable', 'Unable to get issue assignees.')); - throw error; - } + const octokit = await withAssignmentProviderFailure('initialize-client', async () => this.githubClient.getClient(token)); + return withAssignmentProviderFailure('read-assignees', async () => { + const response = await octokit.rest.issues.get({ owner, repo: repository, issue_number: issueNumber }); + return assigneesFromIssue(response?.data); + }); }; assignMembersToIssue = async ( @@ -23,19 +21,30 @@ export class IssueAssignmentRepository { members: string[], token: string, ): Promise => { - const octokit = this.githubClient.getClient(token); - try { - if (members.length === 0) { - logDebugInfo('No members provided for assignment. Skipping operation.'); - return []; - } - const { data: updatedIssue } = await octokit.rest.issues.addAssignees({ + if (members.length === 0) { + logDebugInfo('No members provided for assignment. Skipping operation.'); + return []; + } + const octokit = await withAssignmentProviderFailure('initialize-client', async () => this.githubClient.getClient(token)); + return withAssignmentProviderFailure('add-assignees', async () => { + const response = await octokit.rest.issues.addAssignees({ owner, repo: repository, issue_number: issueNumber, assignees: members, }); - return (updatedIssue.assignees ?? []).map(assignee => assignee.login); - } catch (error) { - logError(toApplicationError(error, 'provider.unavailable', 'Unable to assign issue members.')); - throw error; - } + return assigneesFromIssue(response?.data); + }); }; } + +function assigneesFromIssue(value: unknown): string[] { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw malformedAssignees(); + const assignees = (value as Record).assignees ?? []; + if (!Array.isArray(assignees) || assignees.length > 10) throw malformedAssignees(); + return assignees.map(assignee => { + if (typeof assignee !== 'object' || assignee === null || typeof assignee.login !== 'string' || assignee.login.trim().length === 0) throw malformedAssignees(); + return assignee.login; + }); +} + +function malformedAssignees(): ApplicationError { + return new ApplicationError('provider.contract-invalid', 'Issue assignee response is malformed.'); +} diff --git a/src/data/repository/organization/organization_members_query.ts b/src/data/repository/organization/organization_members_query.ts index ad248ae9a..cb7a0cba7 100644 --- a/src/data/repository/organization/organization_members_query.ts +++ b/src/data/repository/organization/organization_members_query.ts @@ -1,19 +1,23 @@ import type { GithubOrganizationMembersClient } from "../../../infrastructure/github/ports/github_identity_provider_ports"; -import { requireArrayPage } from "../github/github_pagination_policy"; +import { ApplicationError } from '../../../application/errors/application_error'; +import { withAssignmentProviderFailure } from '../github/assignment_provider_failure'; export async function listOrganizationTeams( client: GithubOrganizationMembersClient, organization: string, ): Promise> { - const teams: Array<{ slug: string }> = []; - for await (const response of client.paginate.iterator(client.rest.teams.list, { - org: organization, - per_page: 100, - })) { - const page = requireArrayPage(response.data, 'organization teams'); - teams.push(...page.flatMap((team) => isTeam(team) ? [team] : [])); - } - return teams; + return withAssignmentProviderFailure('list-teams', async () => { + const teams: Array<{ slug: string }> = []; + for await (const response of client.paginate.iterator(client.rest.teams.list, { + org: organization, + per_page: 100, + })) { + const page = response?.data; + if (!Array.isArray(page) || !page.every(isTeam)) throw malformedMembers(); + teams.push(...page); + } + return teams; + }); } export async function listOrganizationTeamMembers( @@ -21,26 +25,33 @@ export async function listOrganizationTeamMembers( organization: string, teamSlug: string, ): Promise> { - const members: Array<{ login: string }> = []; - for await (const response of client.paginate.iterator(client.rest.teams.listMembersInOrg, { - org: organization, - team_slug: teamSlug, - per_page: 100, - })) { - const page = requireArrayPage(response.data, 'organization team members'); - members.push(...page.flatMap((member) => isMember(member) ? [member] : [])); - } - return members; + return withAssignmentProviderFailure('list-team-members', async () => { + const members: Array<{ login: string }> = []; + for await (const response of client.paginate.iterator(client.rest.teams.listMembersInOrg, { + org: organization, + team_slug: teamSlug, + per_page: 100, + })) { + const page = response?.data; + if (!Array.isArray(page) || !page.every(isMember)) throw malformedMembers(); + members.push(...page); + } + return members; + }); } function isTeam(value: unknown): value is { slug: string } { - return isRecord(value) && typeof value.slug === 'string'; + return isRecord(value) && typeof value.slug === 'string' && value.slug.trim().length > 0; } function isMember(value: unknown): value is { login: string } { - return isRecord(value) && typeof value.login === 'string'; + return isRecord(value) && typeof value.login === 'string' && value.login.trim().length > 0; } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function malformedMembers(): ApplicationError { + return new ApplicationError('provider.contract-invalid', 'Organization membership response is malformed.'); } diff --git a/src/data/repository/organization/organization_members_repository.ts b/src/data/repository/organization/organization_members_repository.ts index 3443b8583..f898b5029 100644 --- a/src/data/repository/organization/organization_members_repository.ts +++ b/src/data/repository/organization/organization_members_repository.ts @@ -1,10 +1,10 @@ -import { logDebugInfo, logError } from "../../../utils/logger"; +import { logDebugInfo } from "../../../utils/logger"; import { collectOrganizationMembers, selectAvailableMembers } from "../project_members_policy"; import type { OrganizationMembersPort } from "../../../application/ports/organization_members_ports"; import type { GithubClientPort } from "../../../infrastructure/github/ports/github_client_provider_port"; import type { GithubOrganizationMembersClient } from "../../../infrastructure/github/ports/github_identity_provider_ports"; import { listOrganizationTeamMembers, listOrganizationTeams } from "./organization_members_query"; -import { toApplicationError } from '../../../application/errors/application_error'; +import { withAssignmentProviderFailure } from '../github/assignment_provider_failure'; export class OrganizationMembersRepository implements OrganizationMembersPort { constructor(private readonly githubClient: GithubClientPort) {} @@ -16,7 +16,7 @@ export class OrganizationMembersRepository implements OrganizationMembersPort { token: string, ): Promise => { if (membersToAdd === 0) return []; - try { + return withAssignmentProviderFailure('initialize-client', async () => { const client = this.githubClient.getClient(token); const teams = await listOrganizationTeams(client, organization); if (teams.length === 0) { @@ -32,27 +32,21 @@ export class OrganizationMembersRepository implements OrganizationMembersPort { logDebugInfo(`No available members to assign for organization ${organization}.`); } return selectedMembers; - } catch (error) { - logError(toApplicationError(error, 'provider.unavailable', 'Unable to select organization members.')); - throw error; - } + }); }; getAllMembers = async (organization: string, token: string): Promise => { - try { + return withAssignmentProviderFailure('initialize-client', async () => { const client = this.githubClient.getClient(token); const teams = await listOrganizationTeams(client, organization); if (teams.length === 0) { logDebugInfo(`${organization} doesn't have any team.`); return []; } - return collectOrganizationMembers( + return await collectOrganizationMembers( teams, (teamSlug) => listOrganizationTeamMembers(client, organization, teamSlug), ); - } catch (error) { - logError(toApplicationError(error, 'provider.unavailable', 'Unable to list organization members.')); - throw error; - } + }); }; } diff --git a/src/domain/__tests__/issue_assignment_diagnostic.test.ts b/src/domain/__tests__/issue_assignment_diagnostic.test.ts new file mode 100644 index 000000000..8f6f85277 --- /dev/null +++ b/src/domain/__tests__/issue_assignment_diagnostic.test.ts @@ -0,0 +1,56 @@ +import { ApplicationError } from '../../application/errors/application_error'; +import { isIssueAssignmentDiagnostic, isAssignmentProviderStatus, type IssueAssignmentDiagnostic } from '../issue_assignment_diagnostic'; + +const assignmentFacts: IssueAssignmentDiagnostic = { + targetNumber: 17, desiredCount: 3, stage: 'lookup-members', operation: 'list-teams', providerStatus: 'http-403', + assigneeRead: 'verified', observedAssignees: 1, confirmedAssignments: 0, remainingAssignments: 2, mutation: 'not-attempted', +}; + +describe('bounded issue assignment diagnostics', () => { + it('copies and freezes the complete public descriptor without raw error data', () => { + const facts = { ...assignmentFacts }; + const error = new ApplicationError('authorization.denied', 'Safe assignment error.', { recovery: { id: 'issue-assignment-failed', variables: facts } }); + facts.stage = 'select-members'; + expect(error.recovery?.variables).toEqual(assignmentFacts); + expect(Object.isFrozen(error.recovery?.variables)).toBe(true); + expect(error.toJSON().recovery).toEqual({ id: 'issue-assignment-failed', variables: assignmentFacts }); + }); + + it.each([ + { targetNumber: 0 }, { targetNumber: 1.5 }, { targetNumber: Number.MAX_SAFE_INTEGER + 1 }, + { desiredCount: 0 }, { desiredCount: 11 }, { desiredCount: 1.5 }, + { stage: 'private-token' }, { operation: 'private-token' }, { providerStatus: 'http-600' }, + { providerStatus: 'private-token' }, { providerStatus: 403 }, + { assigneeRead: 'maybe' }, { mutation: 'private-token' }, + { observedAssignees: -1 }, { observedAssignees: 11 }, { confirmedAssignments: 0.5 }, + { remainingAssignments: 11 }, { remainingAssignments: 1 }, + { confirmedAssignments: 4 }, { confirmedAssignments: 1, remainingAssignments: 1 }, + { mutation: 'verified' }, + { assigneeRead: 'unavailable', stage: 'read-assignees' }, + { assigneeRead: 'unavailable', stage: 'read-assignees', observedAssignees: 'unavailable' }, + { assigneeRead: 'unavailable', stage: 'lookup-members', observedAssignees: 'unavailable', remainingAssignments: 'unavailable' }, + { assigneeRead: 'unavailable', stage: 'read-assignees', observedAssignees: 'unavailable', remainingAssignments: 'unavailable', mutation: 'unverified' }, + { assigneeRead: 'unavailable', stage: 'read-assignees', observedAssignees: 'unavailable', remainingAssignments: 'unavailable', confirmedAssignments: 1, mutation: 'unverified' }, + ])('rejects malformed or contradictory bounded facts (%j)', override => { + const variables = { ...assignmentFacts, ...override }; + expect(isIssueAssignmentDiagnostic(variables)).toBe(false); + expect(() => new ApplicationError('unexpected', 'Safe.', { recovery: { id: 'issue-assignment-failed', variables: variables as IssueAssignmentDiagnostic } })) + .toThrow('Issue assignment diagnostic is invalid.'); + }); + + it.each([{ token: 'private-token' }, { rawMessage: 'private-member' }])('rejects extra public fields (%j)', extra => { + expect(() => new ApplicationError('unexpected', 'Safe.', { recovery: { id: 'issue-assignment-failed', variables: { ...assignmentFacts, ...extra } } })) + .toThrow('Application error recovery variables are invalid'); + }); + + it.each(['http-100', 'http-599', 'http-unavailable'])('accepts safe provider status %s', value => { + expect(isAssignmentProviderStatus(value)).toBe(true); + expect(isIssueAssignmentDiagnostic({ ...assignmentFacts, providerStatus: value })).toBe(true); + }); + + it('serializes unread counts as unavailable, without placeholder zero facts', () => { + const variables = { ...assignmentFacts, stage: 'read-assignees', assigneeRead: 'unavailable', observedAssignees: 'unavailable', remainingAssignments: 'unavailable' } as IssueAssignmentDiagnostic; + const error = new ApplicationError('provider.unavailable', 'Safe.', { recovery: { id: 'issue-assignment-failed', variables } }); + expect(error.toJSON().recovery?.variables).toMatchObject({ observedAssignees: 'unavailable', remainingAssignments: 'unavailable' }); + }); +}); diff --git a/src/domain/issue_assignment_diagnostic.ts b/src/domain/issue_assignment_diagnostic.ts new file mode 100644 index 000000000..d825a5d98 --- /dev/null +++ b/src/domain/issue_assignment_diagnostic.ts @@ -0,0 +1,44 @@ +export const ASSIGNMENT_STAGES = Object.freeze(['read-assignees', 'lookup-members', 'select-members', 'assign-creator', 'assign-members'] as const); +export const ASSIGNMENT_OPERATIONS = Object.freeze(['read-assignees', 'list-teams', 'list-team-members', 'add-assignees', 'initialize-client', 'unavailable'] as const); +export type AssignmentStage = typeof ASSIGNMENT_STAGES[number]; +export type AssignmentOperation = typeof ASSIGNMENT_OPERATIONS[number]; + +/** Counts describe only this assignment attempt; other lifecycle results remain independent. */ +export interface IssueAssignmentDiagnostic { + readonly targetNumber: number; + readonly desiredCount: number; + readonly stage: AssignmentStage; + readonly operation: AssignmentOperation; + readonly providerStatus: string; + readonly assigneeRead: 'verified' | 'unavailable'; + readonly observedAssignees: number | 'unavailable'; + readonly confirmedAssignments: number; + readonly remainingAssignments: number | 'unavailable'; + readonly mutation: 'not-attempted' | 'unverified' | 'verified'; +} + +export function isAssignmentProviderStatus(value: unknown): value is string { + return typeof value === 'string' && (value === 'http-unavailable' || /^http-[1-5][0-9]{2}$/u.test(value)); +} + +export function isIssueAssignmentDiagnostic(value: Readonly>): boolean { + return Number.isSafeInteger(value.targetNumber) && Number(value.targetNumber) > 0 + && Number.isInteger(value.desiredCount) && Number(value.desiredCount) >= 1 && Number(value.desiredCount) <= 10 + && ASSIGNMENT_STAGES.includes(value.stage as AssignmentStage) + && ASSIGNMENT_OPERATIONS.includes(value.operation as AssignmentOperation) + && isAssignmentProviderStatus(value.providerStatus) + && ['verified', 'unavailable'].includes(value.assigneeRead as string) + && ['not-attempted', 'unverified', 'verified'].includes(value.mutation as string) + && validCount(value.confirmedAssignments) && Number(value.confirmedAssignments) <= Number(value.desiredCount) + && (value.mutation !== 'not-attempted' || value.confirmedAssignments === 0) + && (value.mutation !== 'verified' || Number(value.confirmedAssignments) > 0) + && (value.assigneeRead === 'unavailable' + ? value.observedAssignees === 'unavailable' && value.remainingAssignments === 'unavailable' + && value.confirmedAssignments === 0 && value.mutation === 'not-attempted' && value.stage === 'read-assignees' + : validCount(value.observedAssignees) && validCount(value.remainingAssignments) + && value.remainingAssignments === Math.max(0, Number(value.desiredCount) - Number(value.observedAssignees) - Number(value.confirmedAssignments))); +} + +function validCount(value: unknown): boolean { + return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 10; +} diff --git a/src/tooling/__tests__/issue_assignment_diagnostics.test.ts b/src/tooling/__tests__/issue_assignment_diagnostics.test.ts new file mode 100644 index 000000000..9144d252c --- /dev/null +++ b/src/tooling/__tests__/issue_assignment_diagnostics.test.ts @@ -0,0 +1,162 @@ +import { OrganizationMembersRepository } from '../../data/repository/organization/organization_members_repository'; +import { AssignMemberToIssueUseCase } from '../../application/usecases/steps/issue/assign_members_to_issue_use_case'; +import { renderApplicationErrorText } from '../../application/policies/application_error_presentation_policy'; +import type { GithubOrganizationMembersClient } from '../../infrastructure/github/ports/github_identity_provider_ports'; +import { AssignmentProviderError } from '../../application/ports/assignment_provider_error'; +import { ApplicationError } from '../../application/errors/application_error'; +import { issueAssignmentFailure } from '../../application/errors/issue_assignment_failure'; +import { Result } from '../../data/model/result'; +import { runIssueWorkflow, type IssueWorkflowPorts, type IssueWorkflowRouteContext } from '../../application/usecases/issue_workflow'; + +jest.mock('../../utils/logger', () => ({ logInfo: jest.fn(), logDebugInfo: jest.fn(), logError: jest.fn() })); + +const request = { target: 'issue' as const, number: 17, desiredAssigneesCount: 1, creator: 'alice' }; + +function failingMembership(cause: unknown) { + const iterator = async function* () { yield { data: [] }; throw cause; }; + const client = { paginate: { iterator }, rest: { teams: { list: jest.fn(), listMembersInOrg: jest.fn() } } } as unknown as GithubOrganizationMembersClient; + const repository = new OrganizationMembersRepository({ getClient: () => client }); + return { getAllMembers: () => repository.getAllMembers('private-org', 'private-token'), + getRandomMembers: (count: number, current: readonly string[]) => repository.getRandomMembers('private-org', count, [...current], 'private-token') }; +} + +describe('issue assignment failure evidence', () => { + it('normalizes unknown failures into closed facts without retaining their text', () => { + const error = issueAssignmentFailure(new Error('private-token'), { targetNumber: 17, desiredCount: 1, stage: 'lookup-members', + assigneeRead: 'verified', observedAssignees: 0, confirmedAssignments: 0, remainingAssignments: 1, mutation: 'not-attempted' }); + expect(error).toMatchObject({ code: 'unexpected', retryable: false, recovery: { variables: { operation: 'unavailable', providerStatus: 'http-unavailable' } } }); + expect(JSON.stringify(error)).not.toContain('private-token'); + }); + + it('rejects a missing target without reading membership or attempting writes', async () => { + const read = jest.fn(), write = jest.fn(), lookup = jest.fn(); + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: read, assignMembersToIssue: write }, + { getAllMembers: lookup, getRandomMembers: jest.fn() }).invoke({ ...request, number: 0 }); + expect(result).toEqual([expect.objectContaining({ success: false })]); + expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled(); expect(lookup).not.toHaveBeenCalled(); + }); + it.each([ + { status: 503, code: 'provider.unavailable', retryable: true }, + { status: 403, code: 'authorization.denied', retryable: false }, + { status: undefined, code: 'unexpected', retryable: false }, + ])('keeps safe lookup context and truthful retryability for $code', async ({ status, code, retryable }) => { + const assign = jest.fn(); + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => [], assignMembersToIssue: assign }, + failingMembership({ status, message: 'private-token private-org private-member', response: { data: { secret: 'private-token' } } })).invoke(request); + const error = result.flatMap(item => [...item.errors])[0]; + expect(error).toMatchObject({ code, retryable, recovery: { id: 'issue-assignment-failed', variables: { + stage: 'lookup-members', operation: 'list-teams', providerStatus: status ? `http-${status}` : 'http-unavailable', + } } }); + const presentation = renderApplicationErrorText(error); + expect(presentation).toContain('organization teams'); + expect(presentation).not.toMatch(/private-token|private-org|private-member/); + expect(assign).not.toHaveBeenCalled(); + }); + + it('rereads an already assigned creator without depending on organization lookup', async () => { + const members = failingMembership({ status: 403 }); + const lookup = jest.spyOn(members, 'getAllMembers'); + const assign = jest.fn(); + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => ['ALICE'], assignMembersToIssue: assign }, members).invoke(request); + expect(result.every(item => item.success)).toBe(true); + expect(lookup).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('does not claim creator assignment when the returned assignment omits the creator', async () => { + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => [], assignMembersToIssue: async () => [] }, + { getAllMembers: async () => ['alice'], getRandomMembers: async () => [] }).invoke(request); + expect(result.some(item => !item.success && item.errors.some(error => error.code === 'provider.contract-invalid'))).toBe(true); + expect(result.some(item => item.success && item.steps.some(step => step.includes('(creator)')))).toBe(false); + }); + + it('retains a confirmed creator across additional-member failure and rereads it on retry', async () => { + const current: string[] = []; + const assign = jest.fn(async (_number: number, members: readonly string[]) => { + if (members.includes('bob') && assign.mock.calls.length === 2) throw new AssignmentProviderError('provider.unavailable', 'add-assignees', 'http-503'); + current.push(...members); return [...current]; + }); + const members = { getAllMembers: async () => ['alice', 'bob'], getRandomMembers: jest.fn(async () => ['bob']) }; + const useCase = new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => [...current], assignMembersToIssue: assign }, members); + const first = await useCase.invoke({ ...request, desiredAssigneesCount: 2 }); + const error = first.flatMap(item => [...item.errors])[0]; + expect(error.recovery).toMatchObject({ variables: { stage: 'assign-members', confirmedAssignments: 1, remainingAssignments: 1, mutation: 'unverified' } }); + expect(first.filter(item => item.success).flatMap(item => [...item.steps])).toContain('The issue was assigned to @alice (creator).'); + expect(error.toJSON().retainedState).toContain('Verified assignments'); + expect(members.getRandomMembers).toHaveBeenNthCalledWith(1, 1, ['alice']); + const retried = await useCase.invoke({ ...request, desiredAssigneesCount: 2 }); + expect(retried.every(item => item.success)).toBe(true); + expect(assign.mock.calls.map(call => call[1])).toEqual([['alice'], ['bob'], ['bob']]); + expect(current).toEqual(['alice', 'bob']); + }); + + it('does not repeat a write whose response failed after it applied remotely', async () => { + const current: string[] = []; + const assign = jest.fn(async (_number: number, members: readonly string[]) => { + current.push(...members); throw new AssignmentProviderError('provider.unavailable', 'add-assignees', 'http-503'); + }); + const lookup = jest.fn(async () => ['alice']); + const useCase = new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => [...current], assignMembersToIssue: assign }, { getAllMembers: lookup, getRandomMembers: async () => [] }); + const first = await useCase.invoke(request); + expect(first.flatMap(item => [...item.errors])[0].recovery).toMatchObject({ variables: { confirmedAssignments: 0, mutation: 'unverified' } }); + lookup.mockRejectedValue(new Error('private-token')); + expect((await useCase.invoke(request)).every(item => item.success)).toBe(true); + expect(assign).toHaveBeenCalledTimes(1); + expect(lookup).toHaveBeenCalledTimes(1); + }); + + it('retains partial requested-member confirmations and exposes the unconfirmed remainder', async () => { + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => [], assignMembersToIssue: async () => ['bob', 'BOB', 'foreign'] }, + { getAllMembers: async () => ['bob', 'carol'], getRandomMembers: async () => ['bob', 'carol'] }).invoke({ ...request, creator: '', desiredAssigneesCount: 2 }); + expect(result.filter(item => item.success)).toHaveLength(1); + expect(result.flatMap(item => [...item.errors])[0]).toMatchObject({ code: 'provider.contract-invalid', recovery: { variables: { confirmedAssignments: 1, remainingAssignments: 1, operation: 'add-assignees', mutation: 'unverified' } } }); + }); + + it('keeps the verified creator when the subsequent member selection fails', async () => { + const cause = new AssignmentProviderError('authorization.denied', 'list-team-members', 'http-403'); + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => [], assignMembersToIssue: async () => ['alice'] }, + { getAllMembers: async () => ['alice'], getRandomMembers: async () => { throw cause; } }).invoke({ ...request, desiredAssigneesCount: 2 }); + expect(result.flatMap(item => [...item.errors])[0]).toMatchObject({ correlationId: cause.correlationId, retryable: false, + recovery: { variables: { stage: 'select-members', operation: 'list-team-members', mutation: 'verified', confirmedAssignments: 1 } } }); + }); + + it('keeps an unread current count unavailable rather than claiming zero remaining work', async () => { + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => { throw new ApplicationError('authorization.denied', 'Safe.'); }, assignMembersToIssue: jest.fn() }, + { getAllMembers: jest.fn(), getRandomMembers: jest.fn() }).invoke(request); + const error = result.flatMap(item => [...item.errors])[0]; + expect(error.recovery).toMatchObject({ variables: { stage: 'read-assignees', assigneeRead: 'unavailable', operation: 'read-assignees', mutation: 'not-attempted' } }); + expect(renderApplicationErrorText(error)).toContain('Remaining target count: unavailable'); + }); + + it('retains an already full target instead of adding an eligible but different creator', async () => { + const lookup = jest.fn(async () => ['alice', 'bob']); + const assign = jest.fn(); + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => ['bob'], assignMembersToIssue: assign }, + { getAllMembers: lookup, getRandomMembers: jest.fn() }).invoke(request); + expect(result.every(item => item.success)).toBe(true); + expect(lookup).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('counts existing identities without case before selecting remaining members', async () => { + const random = jest.fn(async () => ['bob']); + const result = await new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => ['alice', 'ALICE'], assignMembersToIssue: async () => ['alice', 'bob'] }, + { getAllMembers: async () => ['alice', 'bob'], getRandomMembers: random }).invoke({ ...request, creator: '', desiredAssigneesCount: 2 }); + expect(result.every(item => item.success)).toBe(true); + expect(random).toHaveBeenCalledWith(1, ['alice', 'ALICE']); + }); + + it.each([503, 403, 422, undefined])('continues independent issue lifecycle steps after lookup failure (%s)', async status => { + const assignment = new AssignMemberToIssueUseCase({ getCurrentAssignees: async () => [], assignMembersToIssue: jest.fn() }, failingMembership({ status })); + const calls: string[] = []; + const step = (id: string) => ({ taskId: id, invoke: async () => { calls.push(id); return [new Result({ id, success: true, executed: true })]; } }); + const ports = { sharedContexts: { permissions: {}, title: { kind: 'issue', labelFacts: {} }, projectLink: {}, steps: { assignment: request, issueType: {}, priority: {} } }, + workflowSteps: { checkPermissions: step('permissions'), assignMemberToIssue: assignment, updateIssueType: step('type'), linkIssueProject: step('project'), checkPriorityIssueSize: step('priority'), updateTitle: step('title') } } as unknown as IssueWorkflowPorts; + const context = { started: false, branchRequired: false, membersOnly: false, newIssue: false } as IssueWorkflowRouteContext; + const outcome = await runIssueWorkflow(context, 'IssueUseCase', ports); + expect(calls).toEqual(['permissions', 'type', 'project', 'priority', 'title']); + expect(outcome.results.filter(item => item.success).map(item => item.id)).toEqual(['type', 'project', 'priority', 'title']); + expect(outcome.results.flatMap(item => [...item.errors])).toHaveLength(1); + expect(JSON.stringify(outcome.results)).toContain('issue-assignment-failed'); + }); +}); From a2561fbeaea5bf950da46e7b021c364469ce8803 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 23:38:23 +0100 Subject: [PATCH 2/4] bugfix-849-issue-assignment-loses-organization-lookup-diagnostics-behind-opaque-retryable-provider-unavailable: recognize secondary limits in response bodies --- build/cli/index.js | 2 +- build/github_action/index.js | 2 +- specs/issue-assignment-failure-recovery.md | 10 ++++++++-- .../__tests__/assignment_provider_failure.test.ts | 7 +++++++ .../repository/github/assignment_provider_failure.ts | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 57c29e018..9a5e1fa05 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -89370,7 +89370,7 @@ async function withAssignmentProviderFailure(operation, perform) { const status = [error.status, response.status].find(value => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599); const code = cause instanceof application_error_1.ApplicationError ? cause.code - : (0, github_error_policy_1.isGithubRateLimited)({ status, response: { headers: response.headers ?? error.headers }, message: error.message }) ? 'provider.rate-limited' + : (0, github_error_policy_1.isGithubRateLimited)({ status, response: { headers: response.headers ?? error.headers, data: response.data }, message: error.message }) ? 'provider.rate-limited' : status === 401 ? 'authorization.credential-invalid' : status === 403 ? 'authorization.denied' : status === 404 ? 'provider.not-found' diff --git a/build/github_action/index.js b/build/github_action/index.js index edf800cae..02a86ffed 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -80295,7 +80295,7 @@ async function withAssignmentProviderFailure(operation, perform) { const status = [error.status, response.status].find(value => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599); const code = cause instanceof application_error_1.ApplicationError ? cause.code - : (0, github_error_policy_1.isGithubRateLimited)({ status, response: { headers: response.headers ?? error.headers }, message: error.message }) ? 'provider.rate-limited' + : (0, github_error_policy_1.isGithubRateLimited)({ status, response: { headers: response.headers ?? error.headers, data: response.data }, message: error.message }) ? 'provider.rate-limited' : status === 401 ? 'authorization.credential-invalid' : status === 403 ? 'authorization.denied' : status === 404 ? 'provider.not-found' diff --git a/specs/issue-assignment-failure-recovery.md b/specs/issue-assignment-failure-recovery.md index 8db56e2fc..dc7224082 100644 --- a/specs/issue-assignment-failure-recovery.md +++ b/specs/issue-assignment-failure-recovery.md @@ -181,6 +181,12 @@ target identity, known stages/operations, HTTP 100–599 or unavailable, and bou counts or explicit `unavailable` markers for unread current/remaining values. No token, organization/team/member list, raw cause or response is retained in the error. SDK fault tests use fixed fakes and no service credentials. +Rate-limit classification MAY inspect the provider's response-body message +transiently, including when the top-level exception message is absent or only +`Forbidden`. The response body MUST NOT be retained in the semantic error, log, +Result or recovery descriptor. The two additional boundary regressions verify +both classification and redaction; they are part of the R1/R2 adapter budget. + ## 12. Observability and operational UX One assignment failure reaches its existing Result and log, with the same @@ -210,9 +216,9 @@ confirmation/replay/partial-state cases; 8 localized presentation cases; 4 issue route integration cases; and 4 semantic identity/serialization/architecture compatibility cases. Cases count once; repeated runs do not count as new cases. -Local acceptance on 2026-11-03 passes **886 suites / 17,317 Jest tests** +Local acceptance on 2026-11-03 passes **886 suites / 17,319 Jest tests** (35 pre-existing skips) and **267 extension tests** at 100% in all four metrics. -There are **133 new distinct cases**: 29 pure validation, 67 provider/adapter, +There are **135 new distinct cases**: 29 pure validation, 69 provider/adapter, 14 application, 14 localized presentation, four issue-route integration and five semantic/serialization cases (four allocated from the 33 domain cases plus the new exhaustive recovery-inventory case). No case is counted twice. All eleven diff --git a/src/data/repository/github/__tests__/assignment_provider_failure.test.ts b/src/data/repository/github/__tests__/assignment_provider_failure.test.ts index 4f06d4ab4..3f8e6d9c6 100644 --- a/src/data/repository/github/__tests__/assignment_provider_failure.test.ts +++ b/src/data/repository/github/__tests__/assignment_provider_failure.test.ts @@ -42,6 +42,13 @@ describe('assignment provider failure boundary', () => { .rejects.toMatchObject({ code: 'provider.unavailable', providerStatus: 'http-504' }); }); + it.each([undefined, 'Forbidden'])('recognizes a secondary limit only present in the private response body (top-level %s)', async message => { + const cause = { status: 403, message, response: { data: { message: 'You have exceeded a secondary rate limit. private-token', members: ['private-member'] } } }; + const error = await withAssignmentProviderFailure('list-teams', async () => { throw cause; }).catch(value => value); + expect(error).toMatchObject({ code: 'provider.rate-limited', retryable: true, operation: 'list-teams', providerStatus: 'http-403' }); + expect(JSON.stringify(error)).not.toMatch(/private-token|private-member|"data":|"response":|"cause":/); + }); + it('preserves an existing semantic category, narrowed retryability and correlation', async () => { const original = new ApplicationError('provider.unavailable', 'Safe diagnostic.', { retryable: false, cause: 'private-token' }); const error = await withAssignmentProviderFailure('initialize-client', async () => { throw original; }).catch(value => value); diff --git a/src/data/repository/github/assignment_provider_failure.ts b/src/data/repository/github/assignment_provider_failure.ts index 69bc1057c..a7036e123 100644 --- a/src/data/repository/github/assignment_provider_failure.ts +++ b/src/data/repository/github/assignment_provider_failure.ts @@ -11,7 +11,7 @@ export async function withAssignmentProviderFailure(operation: AssignmentOper const status = [error.status, response.status].find(value => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599) as number | undefined; const code: ApplicationErrorCode = cause instanceof ApplicationError ? cause.code - : isGithubRateLimited({ status, response: { headers: response.headers ?? error.headers }, message: error.message }) ? 'provider.rate-limited' + : isGithubRateLimited({ status, response: { headers: response.headers ?? error.headers, data: response.data }, message: error.message }) ? 'provider.rate-limited' : status === 401 ? 'authorization.credential-invalid' : status === 403 ? 'authorization.denied' : status === 404 ? 'provider.not-found' From c62a0fb4a1ab603d631038e6a887bb76f21eb115 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 4 Nov 2026 00:08:46 +0100 Subject: [PATCH 3/4] bugfix-849-issue-assignment-loses-organization-lookup-diagnostics-behind-opaque-retryable-provider-unavailable: retain lifecycle state through completion and retry --- specs/CATALOG.md | 4 +- specs/catalog.json | 1 + specs/issue-assignment-failure-recovery.md | 18 ++- ..._assignment_completion.integration.test.ts | 137 ++++++++++++++++++ 4 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/tooling/__tests__/issue_assignment_completion.integration.test.ts diff --git a/specs/CATALOG.md b/specs/CATALOG.md index a62cc3ae2..0ee7c328e 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -21,7 +21,7 @@ debt or convert unknown historic intent into a design decision. | `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT, verify bot identity and grants, recover explicitly from wrong-account input within the session, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 39 paths · 2026-10-07 | | `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 36 paths · 2026-10-18 | | `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 6 companion | 164 paths · 2026-11-03 | -| `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) + 1 companion | 100 paths · 2026-11-03 | +| `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) + 1 companion | 101 paths · 2026-11-03 | | `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) | 88 paths · 2026-10-26 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze bounded diff partitions with version-grounded dependency, policy-to-request transformation and source-bound assertion and generated-source coordinates with independent candidate verification, configuration-selected initial-session credential ownership, delegated test-path and external Action input-contract, host-aware self-repository references and Kotlin/Wasm lexical-binding and renderer-grounded Markdown path evidence, publish stable findings atomically, and apply authorized verified fixes with regression coverage for cwd/pattern-grounded indirect unittest discovery and source-bound module execution evidence, with active-instance Kotlin Json nullable-input policy grounding; verify complete large diffs through a source-bound closed framed profile with immutable local Git proof, live-base freshness and atomic global publication; ground Python exception syntax in declared interpreter versions and PEP 758 while preserving older-target and independent runtime defects; ground filesystem recovery findings in retention/no-overwrite, ownership and publication contracts while preserving required disposable cleanup and data-loss defects; independently adjudicate candidates for supplied Python source, retaining existing bounded source, identity and history controls; trace concrete helper defaults/overrides, database-file parent containment and stored query values before judging assertions, preserving exact fragment coordinates and genuine assertion failures; co-deliver complete long discussion to every source-bound partition within closed native prompt and invocation traffic limits, preserving provenance and truthful provider partial coverage; verify actual resource content, source binding, human authority and intended entry availability before reporting an authentication defect; ground asynchronous findings in complete statements, actual receiver/cycle/caller and reached lifecycle effects, preserving genuine unfinished updates and omitting disproved claims | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 9 companion | 211 paths · 2026-11-03 | | `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) + 3 companion | 159 paths · 2026-11-03 | @@ -164,7 +164,7 @@ debt or convert unknown historic intent into a design decision. - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) - Entrypoints: [`src/application/usecases/issue_use_case.ts`](../src/application/usecases/issue_use_case.ts) · [`src/application/usecases/commit_use_case.ts`](../src/application/usecases/commit_use_case.ts) - Core code: [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.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/issue/link_issue_project_use_case.ts`](../src/application/usecases/steps/issue/link_issue_project_use_case.ts) · [`src/application/usecases/steps/issue/branch_preparation_strategy.ts`](../src/application/usecases/steps/issue/branch_preparation_strategy.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.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/data/repository/branch_lifecycle_repository.ts`](../src/data/repository/branch_lifecycle_repository.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/application/policies/implementation_result_policy.ts`](../src/application/policies/implementation_result_policy.ts) · [`src/domain/issue_completion.ts`](../src/domain/issue_completion.ts) · [`src/application/ports/issue_completion_ports.ts`](../src/application/ports/issue_completion_ports.ts) · [`src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts`](../src/application/usecases/steps/issue/close_issue_after_merging_use_case.ts) · [`src/data/repository/issue/issue_completion_repository.ts`](../src/data/repository/issue/issue_completion_repository.ts) · [`src/infrastructure/composition/issue_completion_composition_root.ts`](../src/infrastructure/composition/issue_completion_composition_root.ts) · [`src/infrastructure/github/ports/github_issue_completion_client.ts`](../src/infrastructure/github/ports/github_issue_completion_client.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/application/usecases/steps/issue/resume_issue_project_use_case.ts`](../src/application/usecases/steps/issue/resume_issue_project_use_case.ts) · [`src/data/repository/project/project_board_field_update.ts`](../src/data/repository/project/project_board_field_update.ts) · [`src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts`](../src/application/usecases/actions/reconcile_closed_issue_metadata_use_case.ts) · [`src/application/usecases/actions/reconcile_closed_issue_use_case.ts`](../src/application/usecases/actions/reconcile_closed_issue_use_case.ts) · [`src/domain/closed_issue_reconciliation.ts`](../src/domain/closed_issue_reconciliation.ts) · [`src/application/errors/closed_issue_reconciliation_error.ts`](../src/application/errors/closed_issue_reconciliation_error.ts) · [`src/application/usecases/actions/reconcile_issue_completion_metadata.ts`](../src/application/usecases/actions/reconcile_issue_completion_metadata.ts) · [`src/application/usecases/actions/verify_closed_issue_card_retirement.ts`](../src/application/usecases/actions/verify_closed_issue_card_retirement.ts) · [`src/application/policies/closed_issue_reconciliation_outcome_policy.ts`](../src/application/policies/closed_issue_reconciliation_outcome_policy.ts) · [`src/domain/deployment_issue_completion.ts`](../src/domain/deployment_issue_completion.ts) · [`src/application/usecases/actions/deferred_deployment_completion.ts`](../src/application/usecases/actions/deferred_deployment_completion.ts) · [`src/domain/issue_assignment_diagnostic.ts`](../src/domain/issue_assignment_diagnostic.ts) · [`src/application/ports/assignment_provider_error.ts`](../src/application/ports/assignment_provider_error.ts) · [`src/application/errors/issue_assignment_failure.ts`](../src/application/errors/issue_assignment_failure.ts) · [`src/application/usecases/steps/issue/assign_members_workflow.ts`](../src/application/usecases/steps/issue/assign_members_workflow.ts) · [`src/application/policies/assignee_assignment_policy.ts`](../src/application/policies/assignee_assignment_policy.ts) · [`src/application/policies/issue_assignment_messages.ts`](../src/application/policies/issue_assignment_messages.ts) · [`src/data/repository/github/assignment_provider_failure.ts`](../src/data/repository/github/assignment_provider_failure.ts) · [`src/data/repository/organization/organization_members_query.ts`](../src/data/repository/organization/organization_members_query.ts) · [`src/data/repository/organization/organization_members_repository.ts`](../src/data/repository/organization/organization_members_repository.ts) · [`src/data/repository/issue/issue_assignment_repository.ts`](../src/data/repository/issue/issue_assignment_repository.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/issue_assignment_diagnostic_presentation.ts`](../src/application/policies/issue_assignment_diagnostic_presentation.ts) -- Tests: [`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__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.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/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/data/repository/__tests__/branch_lifecycle_repository.test.ts`](../src/data/repository/__tests__/branch_lifecycle_repository.test.ts) · [`src/domain/__tests__/copilot_lifecycle.test.ts`](../src/domain/__tests__/copilot_lifecycle.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__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.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/domain/__tests__/issue_completion.test.ts`](../src/domain/__tests__/issue_completion.test.ts) · [`src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts) · [`src/data/repository/issue/__tests__/issue_completion_repository.test.ts`](../src/data/repository/issue/__tests__/issue_completion_repository.test.ts) · [`src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts) · [`src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts`](../src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts) · [`src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts`](../src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts) · [`src/domain/__tests__/deployment_issue_completion.test.ts`](../src/domain/__tests__/deployment_issue_completion.test.ts) · [`src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts`](../src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts) · [`src/domain/__tests__/issue_assignment_diagnostic.test.ts`](../src/domain/__tests__/issue_assignment_diagnostic.test.ts) · [`src/data/repository/github/__tests__/assignment_provider_failure.test.ts`](../src/data/repository/github/__tests__/assignment_provider_failure.test.ts) · [`src/data/repository/__tests__/assignment_provider_contract.test.ts`](../src/data/repository/__tests__/assignment_provider_contract.test.ts) · [`src/application/policies/__tests__/issue_assignment_messages.test.ts`](../src/application/policies/__tests__/issue_assignment_messages.test.ts) · [`src/tooling/__tests__/issue_assignment_diagnostics.test.ts`](../src/tooling/__tests__/issue_assignment_diagnostics.test.ts) · [`src/application/policies/__tests__/assignee_assignment_policy.test.ts`](../src/application/policies/__tests__/assignee_assignment_policy.test.ts) · [`src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts) · [`src/data/repository/__tests__/organization_members_repository.test.ts`](../src/data/repository/__tests__/organization_members_repository.test.ts) · [`src/data/repository/__tests__/issue_assignment_repository.test.ts`](../src/data/repository/__tests__/issue_assignment_repository.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) +- Tests: [`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__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/link_issue_project_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.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/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/data/repository/__tests__/branch_lifecycle_repository.test.ts`](../src/data/repository/__tests__/branch_lifecycle_repository.test.ts) · [`src/domain/__tests__/copilot_lifecycle.test.ts`](../src/domain/__tests__/copilot_lifecycle.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__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.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/domain/__tests__/issue_completion.test.ts`](../src/domain/__tests__/issue_completion.test.ts) · [`src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/close_issue_after_merging_use_case.test.ts) · [`src/data/repository/issue/__tests__/issue_completion_repository.test.ts`](../src/data/repository/issue/__tests__/issue_completion_repository.test.ts) · [`src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_completion_composition_root.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/resume_issue_project_use_case.test.ts) · [`src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts`](../src/infrastructure/composition/__tests__/issue_use_case_composition_root.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_metadata_use_case.test.ts) · [`src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts`](../src/application/usecases/actions/__tests__/reconcile_closed_issue_use_case.test.ts) · [`src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts`](../src/application/errors/__tests__/closed_issue_reconciliation_error.test.ts) · [`src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts`](../src/application/policies/__tests__/closed_issue_reconciliation_summary.test.ts) · [`src/domain/__tests__/deployment_issue_completion.test.ts`](../src/domain/__tests__/deployment_issue_completion.test.ts) · [`src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts`](../src/application/usecases/actions/__tests__/deployment_issue_completion.integration.test.ts) · [`src/domain/__tests__/issue_assignment_diagnostic.test.ts`](../src/domain/__tests__/issue_assignment_diagnostic.test.ts) · [`src/data/repository/github/__tests__/assignment_provider_failure.test.ts`](../src/data/repository/github/__tests__/assignment_provider_failure.test.ts) · [`src/data/repository/__tests__/assignment_provider_contract.test.ts`](../src/data/repository/__tests__/assignment_provider_contract.test.ts) · [`src/application/policies/__tests__/issue_assignment_messages.test.ts`](../src/application/policies/__tests__/issue_assignment_messages.test.ts) · [`src/tooling/__tests__/issue_assignment_diagnostics.test.ts`](../src/tooling/__tests__/issue_assignment_diagnostics.test.ts) · [`src/tooling/__tests__/issue_assignment_completion.integration.test.ts`](../src/tooling/__tests__/issue_assignment_completion.integration.test.ts) · [`src/application/policies/__tests__/assignee_assignment_policy.test.ts`](../src/application/policies/__tests__/assignee_assignment_policy.test.ts) · [`src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts) · [`src/data/repository/__tests__/organization_members_repository.test.ts`](../src/data/repository/__tests__/organization_members_repository.test.ts) · [`src/data/repository/__tests__/issue_assignment_repository.test.ts`](../src/data/repository/__tests__/issue_assignment_repository.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) - User documentation: [`docs/issues/branch-management.mdx`](../docs/issues/branch-management.mdx) · [`docs/issues/labels-and-branch-types.mdx`](../docs/issues/labels-and-branch-types.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/issues/notifications-and-auto-close.mdx`](../docs/issues/notifications-and-auto-close.mdx) · [`docs/issues/index.mdx`](../docs/issues/index.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) ### `comment-automation` — Comment automation and authorization diff --git a/specs/catalog.json b/specs/catalog.json index 70d1d35f0..a619a8037 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -2030,6 +2030,7 @@ "src/data/repository/__tests__/assignment_provider_contract.test.ts", "src/application/policies/__tests__/issue_assignment_messages.test.ts", "src/tooling/__tests__/issue_assignment_diagnostics.test.ts", + "src/tooling/__tests__/issue_assignment_completion.integration.test.ts", "src/application/policies/__tests__/assignee_assignment_policy.test.ts", "src/application/usecases/steps/issue/__tests__/assign_members_to_issue_use_case.test.ts", "src/data/repository/__tests__/organization_members_repository.test.ts", diff --git a/specs/issue-assignment-failure-recovery.md b/specs/issue-assignment-failure-recovery.md index dc7224082..00feb4baa 100644 --- a/specs/issue-assignment-failure-recovery.md +++ b/specs/issue-assignment-failure-recovery.md @@ -172,6 +172,13 @@ No durable assignment cache is introduced, so cancellation leaves native effects for the next ordinary event to reread. Other steps keep their existing read/no-op contracts. No new scratch runtime or production cleanup is needed. +Completion/replay integration MUST verify these effects together through the +actual completion and configuration persistence path: an assignment failure does +not undo the selected issue type, Project link or stored branch configuration. +A fresh run MUST retain one Project item and one configuration block. The +existing type setter may repeat an idempotent assignment of the same type ID; +this MUST NOT be represented as creating another type entity or as zero writes. + ## 11. Security and privacy Existing credential-bound ports and permissions remain. No failed diagnostic @@ -216,13 +223,16 @@ confirmation/replay/partial-state cases; 8 localized presentation cases; 4 issue route integration cases; and 4 semantic identity/serialization/architecture compatibility cases. Cases count once; repeated runs do not count as new cases. -Local acceptance on 2026-11-03 passes **886 suites / 17,319 Jest tests** +Local acceptance on 2026-11-04 passes **887 suites / 17,321 Jest tests** (35 pre-existing skips) and **267 extension tests** at 100% in all four metrics. -There are **135 new distinct cases**: 29 pure validation, 69 provider/adapter, -14 application, 14 localized presentation, four issue-route integration and five +There are **137 new distinct cases**: 29 pure validation, 69 provider/adapter, +14 application, 14 localized presentation, six issue-route/completion integration and five semantic/serialization cases (four allocated from the 33 domain cases plus the new exhaustive recovery-inventory case). No case is counted twice. All eleven affected assignment modules reach 100% lines/statements/functions/branches. +The two completion cases retain type, Project and persisted configuration +together after HTTP 503 and HTTP 403 lookup failures and verify fresh retries +through real completion/storage logic with deterministic provider transports. Full-suite LCOV hits **150/150 modified coverable lines**, with zero misses or partials against `7d1e8d3b`. This is local evidence; official Codecov and remote current-head checks remain PR delivery gates. @@ -258,7 +268,7 @@ failure. API JSON and English/Spanish text preserve safe recovery/correlation. | Requirements | Verification | Documentation | |---|---|---| | R1–R2 | `src/data/repository/github/__tests__/assignment_provider_failure.test.ts` and `src/data/repository/__tests__/assignment_provider_contract.test.ts` | error reference | -| R3–R4 | `src/tooling/__tests__/issue_assignment_diagnostics.test.ts`, including four real issue-route cases, and `src/application/policies/__tests__/assignee_assignment_policy.test.ts` | assignees and projects | +| R3–R4 | `src/tooling/__tests__/issue_assignment_diagnostics.test.ts`, including four real issue-route cases; `src/tooling/__tests__/issue_assignment_completion.integration.test.ts`, covering completion, actual configuration persistence and replay after transient and persistent failures; and `src/application/policies/__tests__/assignee_assignment_policy.test.ts` | assignees and projects | | R5 | `src/domain/__tests__/issue_assignment_diagnostic.test.ts`, `src/application/policies/__tests__/issue_assignment_messages.test.ts`, exhaustive `application_error_presentation_policy.test.ts` recovery inventory | both pages | | R6 | architecture/types/docs/catalog/package/bundles and current-head CI/review/Codecov | this SDD/catalog | diff --git a/src/tooling/__tests__/issue_assignment_completion.integration.test.ts b/src/tooling/__tests__/issue_assignment_completion.integration.test.ts new file mode 100644 index 000000000..4cf74a7a4 --- /dev/null +++ b/src/tooling/__tests__/issue_assignment_completion.integration.test.ts @@ -0,0 +1,137 @@ +import * as core from '@actions/core'; +import { finishGithubAction } from '../../actions/github_action_completion'; +import { AssignMemberToIssueUseCase } from '../../application/usecases/steps/issue/assign_members_to_issue_use_case'; +import { UpdateIssueTypeUseCase } from '../../application/usecases/steps/issue/update_issue_type_use_case'; +import { runProjectContentLinkWorkflow } from '../../application/usecases/steps/common/project_content_link_workflow'; +import { runIssueWorkflow, type IssueWorkflowPorts, type IssueWorkflowRouteContext } from '../../application/usecases/issue_workflow'; +import { Result } from '../../data/model/result'; +import { Config } from '../../data/model/config'; +import { Ai } from '../../data/model/ai'; +import { ProjectDetail } from '../../data/model/project_detail'; +import type { Execution } from '../../data/model/execution'; +import { IssueAssignmentRepository } from '../../data/repository/issue/issue_assignment_repository'; +import { IssueTypeAssignmentRepository } from '../../data/repository/issue/issue_type_assignment_repository'; +import { OrganizationMembersRepository } from '../../data/repository/organization/organization_members_repository'; +import { ProjectBoardLinkRepository } from '../../data/repository/project/project_board_link_repository'; +import { ConfigurationHandler } from '../../manager/description/configuration_handler'; +import { readConfigurationBlock } from '../../domain/configuration_markers'; +import type { GithubIssueAssignmentClient } from '../../infrastructure/github/ports/github_issue_provider_ports'; +import type { GithubOrganizationMembersClient } from '../../infrastructure/github/ports/github_identity_provider_ports'; +import type { GithubGraphqlTransportClient } from '../../infrastructure/github/ports/github_graphql_transport_port'; + +jest.mock('@actions/core', () => ({ setOutput: jest.fn(), setFailed: jest.fn() })); +jest.mock('../../utils/logger', () => ({ logInfo: jest.fn(), logDebugInfo: jest.fn(), logError: jest.fn(), logWarn: jest.fn() })); + +describe('assignment failure through issue completion and replay', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each([503, 403])('retains type, Project and persisted configuration after HTTP %s, then safely retries the same issue', async status => { + const events: string[] = []; + const assignees: string[] = []; + let lookupStatus: number | undefined = status; + let body = 'Original issue prose.'; + let linkedItemId: string | undefined; + let selectedType: string | undefined; + const typeAssignments: string[] = []; + const nativeTypes = [{ id: 'IT_bug', name: 'Bug' }]; + (core.setFailed as jest.Mock).mockImplementation(() => events.push('failed')); + + const assignmentClient = { rest: { issues: { + get: async () => ({ data: { assignees: assignees.map(login => ({ login })) } }), + addAssignees: jest.fn(async ({ assignees: requested }: { assignees: string[] }) => { + assignees.push(...requested); events.push('assignment'); + return { data: { assignees: assignees.map(login => ({ login })) } }; + }), + } } }; + const assignments = new IssueAssignmentRepository({ getClient: () => assignmentClient as unknown as GithubIssueAssignmentClient }); + const membershipClient = { paginate: { iterator: async function* (_method: unknown, params: { team_slug?: string }) { + if (lookupStatus !== undefined) throw { status: lookupStatus, message: 'private-token', response: { data: { members: ['private-member'] } } }; + yield { data: params.team_slug ? [{ login: 'alice' }] : [{ slug: 'core' }] }; + } }, rest: { teams: { list: jest.fn(), listMembersInOrg: jest.fn() } } }; + const members = new OrganizationMembersRepository({ getClient: () => membershipClient as unknown as GithubOrganizationMembersClient }); + const assignment = new AssignMemberToIssueUseCase({ + getCurrentAssignees: number => assignments.getCurrentAssignees('acme', 'repo', number, 'product-pat'), + assignMembersToIssue: (number, requested) => assignments.assignMembersToIssue('acme', 'repo', number, [...requested], 'product-pat'), + }, { getAllMembers: () => members.getAllMembers('acme', 'product-pat'), + getRandomMembers: (count, current) => members.getRandomMembers('acme', count, [...current], 'product-pat') }); + + const graphql = jest.fn(async (query: string, variables?: Record) => { + if (query.includes('issueTypes(first: 20)')) return { organization: { id: 'O1', issueTypes: { nodes: nativeTypes } } }; + if (query.includes('updateIssueIssueType')) { + selectedType = String(variables?.issueTypeId); typeAssignments.push(selectedType); events.push('type'); + return { updateIssueIssueType: { issue: { id: 'I17', issueType: nativeTypes[0] } } }; + } + if (query.includes('addProjectV2ItemById')) { + linkedItemId = 'PVTI17'; events.push('project'); + return { addProjectV2ItemById: { item: { id: linkedItemId } } }; + } + throw new Error('Unexpected provider operation; no live services are used.'); + }); + const transport = { getClient: () => ({ graphql }) as unknown as GithubGraphqlTransportClient }; + const types = new IssueTypeAssignmentRepository(async () => 'I17', transport); + const typeStep = new UpdateIssueTypeUseCase({ setIssueType: (number, type) => types.setIssueType('acme', 'repo', number, type, 'product-pat') }); + const links = new ProjectBoardLinkRepository({ getLinkedContentItemId: async () => linkedItemId } as never, transport); + const project = { id: 'PVT1', title: 'Product', owner: 'acme', type: 'organization', number: 1, url: 'https://github.com/orgs/acme/projects/1' }; + const projectStep = { taskId: 'LinkIssueProjectUseCase', invoke: () => runProjectContentLinkWorkflow({ + contentType: 'issue', contentNumber: 17, columnName: 'Todo', projects: [project], + }, 'LinkIssueProjectUseCase', { resolveIssueContentId: async () => 'I17', + linkContentId: (reference, id) => links.linkContentId(new ProjectDetail(reference), id, 'product-pat'), + initializeStatus: async () => 'retained', + }) }; + const description = { getDescription: async () => body, + updateDescription: jest.fn(async (_owner: string, _repo: string, _number: number, updated: string) => { body = updated; events.push('configuration'); }) }; + const configuration = new ConfigurationHandler(description); + const query = { owner: 'acme', repository: 'repo', issueNumber: 17, token: 'product-pat' }; + const summary = { publish: jest.fn(async (_text: string) => undefined) }; + const ports = { sharedContexts: { permissions: {}, title: { kind: 'issue', labelFacts: {} }, projectLink: {}, steps: { + assignment: { target: 'issue', number: 17, desiredAssigneesCount: 1, creator: 'alice' }, + issueType: { issueNumber: 17, issueType: { name: 'Bug', description: 'A bug', color: 'red' } }, priority: {}, + } }, workflowSteps: { checkPermissions: { invoke: async () => [new Result({ id: 'permissions', success: true, executed: false })] }, + assignMemberToIssue: assignment, updateIssueType: typeStep, linkIssueProject: projectStep, + checkPriorityIssueSize: { invoke: async () => [] }, updateTitle: { invoke: async () => [] }, + } } as unknown as IssueWorkflowPorts; + const route = { started: false, branchRequired: false, membersOnly: false, newIssue: false } as IssueWorkflowRouteContext; + const run = async (currentConfiguration: Config) => { + const results = [...(await runIssueWorkflow(route, 'IssueUseCase', ports)).results]; + const execution = { owner: 'acme', repo: 'repo', eventName: 'issues', inputs: { issue: { state: 'open' } }, + isSingleAction: false, isIssue: true, isPullRequest: false, isPush: false, issueNumber: 17, + issue: { number: 17 }, pullRequest: { number: -1 }, tokenUser: 'bot', currentConfiguration, + locale: { repository: 'en-US', issue: 'en-US', pullRequest: 'en-US' }, singleAction: { issue: -1 }, + release: { active: false }, hotfix: { active: false }, debug: false, tokens: { token: 'product-pat' }, + ai: new Ai('', 'model', false, [], false, 'low', 20), + } as unknown as Execution; + await finishGithubAction(execution, results, {} as never, { update: context => configuration.update(query, context) }, undefined, summary); + return results; + }; + + const first = await run(new Config({ branchType: 'bugfix', workingBranch: 'bugfix/17-work', parentBranch: 'develop' })); + const failure = first.flatMap(result => [...result.errors])[0]; + expect(failure).toMatchObject({ code: status === 503 ? 'provider.unavailable' : 'authorization.denied', retryable: status === 503 }); + expect(events).toEqual(['type', 'project', 'configuration', 'failed']); + expect(first.some(result => result.id === 'LinkIssueProjectUseCase' && result.success)).toBe(true); + expect(selectedType).toBe('IT_bug'); expect(linkedItemId).toBe('PVTI17'); + expect(body).toContain('Original issue prose.'); + expect(body).not.toMatch(/private-token|private-member|product-pat/); + const stored = await configuration.get(query); + expect(stored).toMatchObject({ branchType: 'bugfix', workingBranch: 'bugfix/17-work', parentBranch: 'develop' }); + const firstStoredBody = body; + + // A fresh ordinary run observes native state. Only the transient source recovers. + if (status === 503) lookupStatus = undefined; + const second = await run(stored as Config); + expect(second.flatMap(result => [...result.errors])).toHaveLength(status === 503 ? 0 : 1); + expect(assignees).toEqual(status === 503 ? ['alice'] : []); + expect(assignmentClient.rest.issues.addAssignees).toHaveBeenCalledTimes(status === 503 ? 1 : 0); + expect(nativeTypes).toEqual([{ id: 'IT_bug', name: 'Bug' }]); + // Existing type assignment is an idempotent field set, not another type entity. + expect(typeAssignments).toEqual(['IT_bug', 'IT_bug']); + expect(graphql.mock.calls.filter(([q]) => q.includes('addProjectV2ItemById'))).toHaveLength(1); + expect(description.updateDescription).toHaveBeenCalledTimes(1); + expect(body).toBe(firstStoredBody); + expect(body.match(/