diff --git a/build/api/index.js b/build/api/index.js index a013e2f26..f3275a06f 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,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', + 'issue-assignment-count-invalid', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -7270,9 +7273,13 @@ 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 === 'issue-assignment-count-invalid' + ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' + : 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 +7322,8 @@ 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']), + 'issue-assignment-count-invalid': Object.freeze([]), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -7455,6 +7464,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 +8598,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..93001912d 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-assignment-count-invalid", "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>; readonly 'issue-workflow-admission-blocked': IssueWorkflowDiagnosticFacts; readonly 'progress-binary-evidence-unavailable': ProgressBinaryEvidenceFacts; readonly 'progress-inventory-incomplete': ProgressInventoryDiagnostic; diff --git a/build/api/src/domain/issue_assignment_diagnostic.d.ts b/build/api/src/domain/issue_assignment_diagnostic.d.ts new file mode 100644 index 000000000..b7b5f3e90 --- /dev/null +++ b/build/api/src/domain/issue_assignment_diagnostic.d.ts @@ -0,0 +1,19 @@ +export declare const ASSIGNMENT_STAGES: readonly ["read-assignees", "lookup-members", "select-members", "assign-creator", "assign-members"]; +export declare const ASSIGNMENT_OPERATIONS: readonly ["read-assignees", "list-teams", "list-team-members", "add-assignees", "initialize-client", "unavailable"]; +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 declare function isAssignmentProviderStatus(value: unknown): value is string; +export declare function isIssueAssignmentDiagnostic(value: Readonly>): boolean; diff --git a/build/cli/index.js b/build/cli/index.js index e0ea4b9ff..3122f04f7 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, @@ -40476,12 +40509,16 @@ exports.SPANISH_APPROVAL_DOCTOR_MESSAGES = Object.freeze({ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.resolveAssigneeTarget = resolveAssigneeTarget; +exports.isValidAssigneeCount = isValidAssigneeCount; exports.resolveCreatorAssignment = resolveCreatorAssignment; exports.calculateRemainingAssignees = calculateRemainingAssignees; exports.selectConfirmedAssignees = selectConfirmedAssignees; function resolveAssigneeTarget(context) { return { number: context.number, desiredCount: context.desiredAssigneesCount }; } +function isValidAssigneeCount(value) { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 10; +} function isEligibleCreator(creator, projectMembers, currentMembers) { if (!creator) return false; @@ -40500,7 +40537,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 +46160,89 @@ 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-count-invalid': { + impact: 'Automatic assignment did not start because desired-assignees-count is invalid.', + action: 'Set desired-assignees-count to an integer from 0 to 10 (0 disables assignment), then rerun the same issue workflow.', + retainedState: 'No assignment operation was attempted. Independent completed lifecycle effects are retained in their own results.', + }, + '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-count-invalid': { + impact: 'La asignación automática no empezó porque desired-assignees-count no es válido.', + action: 'Configura desired-assignees-count como un entero de 0 a 10 (0 desactiva la asignación) y repite el mismo workflow.', + retainedState: 'No se intentó ninguna operación de asignación. Los efectos independientes completados se conservan en sus propios resultados.', + }, + '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 +57096,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 +75776,80 @@ 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); + if (!(0, assignee_assignment_policy_1.isValidAssigneeCount)(target.desiredCount)) { + return [new result_1.Result({ id: TASK_ID, success: false, executed: false, + errors: [new application_error_1.ApplicationError('configuration.invalid', 'The desired-assignees-count must be an integer from 0 to 10.', { + recovery: { id: 'issue-assignment-count-invalid', variables: {} }, + })] })]; + } 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 +75866,7 @@ function assignmentResult(success, step) { id: TASK_ID, success, executed: true, - steps: step ? [step] : [], + steps: [step], }); } @@ -83379,6 +83560,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 +83599,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', + 'issue-assignment-count-invalid', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -83599,9 +83783,13 @@ 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 === 'issue-assignment-count-invalid' + ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' + : 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 +83832,8 @@ 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']), + 'issue-assignment-count-invalid': Object.freeze([]), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -83784,6 +83974,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 +89370,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, data: response.data }, 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 +90691,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 +92012,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 +92071,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 +92091,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 +98507,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..773a30f0a 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,58 @@ 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-count-invalid': { + impact: 'Automatic assignment did not start because desired-assignees-count is invalid.', + action: 'Set desired-assignees-count to an integer from 0 to 10 (0 disables assignment), then rerun the same issue workflow.', + retainedState: 'No assignment operation was attempted. Independent completed lifecycle effects are retained in their own results.', + }, + '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-count-invalid': { + impact: 'La asignación automática no empezó porque desired-assignees-count no es válido.', + action: 'Configura desired-assignees-count como un entero de 0 a 10 (0 desactiva la asignación) y repite el mismo workflow.', + retainedState: 'No se intentó ninguna operación de asignación. Los efectos independientes completados se conservan en sus propios resultados.', + }, + '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 +33931,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 +33970,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', + 'issue-assignment-count-invalid', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -34093,9 +34154,13 @@ 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 === 'issue-assignment-count-invalid' + ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' + : 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 +34203,8 @@ 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']), + 'issue-assignment-count-invalid': Object.freeze([]), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -34278,6 +34345,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 +36242,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..cb8e51fa7 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, @@ -41946,12 +41979,16 @@ function renderApplicationErrorMarkdown(error, message = application_error_messa Object.defineProperty(exports, "__esModule", ({ value: true })); exports.resolveAssigneeTarget = resolveAssigneeTarget; +exports.isValidAssigneeCount = isValidAssigneeCount; exports.resolveCreatorAssignment = resolveCreatorAssignment; exports.calculateRemainingAssignees = calculateRemainingAssignees; exports.selectConfirmedAssignees = selectConfirmedAssignees; function resolveAssigneeTarget(context) { return { number: context.number, desiredCount: context.desiredAssigneesCount }; } +function isValidAssigneeCount(value) { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 10; +} function isEligibleCreator(creator, projectMembers, currentMembers) { if (!creator) return false; @@ -41970,7 +42007,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 +47922,89 @@ 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-count-invalid': { + impact: 'Automatic assignment did not start because desired-assignees-count is invalid.', + action: 'Set desired-assignees-count to an integer from 0 to 10 (0 disables assignment), then rerun the same issue workflow.', + retainedState: 'No assignment operation was attempted. Independent completed lifecycle effects are retained in their own results.', + }, + '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-count-invalid': { + impact: 'La asignación automática no empezó porque desired-assignees-count no es válido.', + action: 'Configura desired-assignees-count como un entero de 0 a 10 (0 desactiva la asignación) y repite el mismo workflow.', + retainedState: 'No se intentó ninguna operación de asignación. Los efectos independientes completados se conservan en sus propios resultados.', + }, + '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 +55117,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 +71907,80 @@ 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); + if (!(0, assignee_assignment_policy_1.isValidAssigneeCount)(target.desiredCount)) { + return [new result_1.Result({ id: TASK_ID, success: false, executed: false, + errors: [new application_error_1.ApplicationError('configuration.invalid', 'The desired-assignees-count must be an integer from 0 to 10.', { + recovery: { id: 'issue-assignment-count-invalid', variables: {} }, + })] })]; + } 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 +71997,7 @@ function assignmentResult(success, step) { id: TASK_ID, success, executed: true, - steps: step ? [step] : [], + steps: [step], }); } @@ -74160,6 +74341,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 +74380,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', + 'issue-assignment-count-invalid', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -74380,9 +74564,13 @@ 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 === 'issue-assignment-count-invalid' + ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' + : 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 +74613,8 @@ 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']), + 'issue-assignment-count-invalid': Object.freeze([]), 'pull-request-link-restored': Object.freeze([]), 'pull-request-link-base-retained': Object.freeze([]), 'pull-request-link-reference-retained': Object.freeze([]), @@ -74565,6 +74755,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 +80295,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, data: response.data }, 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 +81652,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 +82973,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 +83032,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 +83052,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 +89507,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..e771bf798 100644 --- a/docs/issues/assignees-and-projects.mdx +++ b/docs/issues/assignees-and-projects.mdx @@ -34,6 +34,44 @@ 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. +The shared assignment workflow rejects invalid counts before contacting GitHub. +`configuration.invalid` with `issue-assignment-count-invalid` asks you to set an +integer from `0` to `10`; the supplied invalid value is not echoed. Other issue +steps continue and retain their own completed effects. + +### 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 diff --git a/docs/security-operations/operations/error-reference.mdx b/docs/security-operations/operations/error-reference.mdx index 339a39c28..fb41346e3 100644 --- a/docs/security-operations/operations/error-reference.mdx +++ b/docs/security-operations/operations/error-reference.mdx @@ -284,3 +284,28 @@ 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-count-invalid` uses nonretryable `configuration.invalid` when +the shared workflow receives a count outside its integer `0`–`10` contract. +Correct `desired-assignees-count` and rerun the same issue workflow. No assignment +operation was attempted; independent completed effects retain their own results. +The descriptor has no parameters and never echoes the supplied invalid value. + +`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..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) | 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 | 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 | @@ -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/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 c5e5ef885..a619a8037 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,18 @@ "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/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", + "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 +2043,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..46fd905ee --- /dev/null +++ b/specs/issue-assignment-failure-recovery.md @@ -0,0 +1,318 @@ +# 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. + +Review of candidate `c62a0fb4a` additionally verified that integer input parsing +did not enforce the documented upper count bound. A value above 10 could make +the assignment-error diagnostic reject its own facts and throw from the catch +handler. Nine new regression cases fail before this correction, including the +real completion path. This does not establish that the original consumer used +an invalid count. Count validation now belongs to the shared assignment +workflow before any provider access. + +## 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 validate the configured count before provider access, then 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` is an 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. +The shared workflow MUST reject out-of-range, fractional, nonfinite or nonnumeric +runtime counts without coercion or provider access. It MUST return a nonretryable +`configuration.invalid` result with the closed, parameter-free +`issue-assignment-count-invalid` recovery; an invalid raw value MUST NOT become +assignment facts or public diagnostic content. Independent steps MUST continue. + +## 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 | +|---|---|---| +| invalid desired count | correct desired-assignees-count to an integer 0–10 | no assignment call attempted; independent effects retain their own results | +| 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. + +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 +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. + +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 +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-04 passes **887 suites / 17,346 Jest tests** +(35 pre-existing skips) and **267 extension tests** at 100% in all four metrics. +There are **162 new distinct cases**: 41 pure validation, 69 provider/adapter, +23 application, 16 localized presentation, seven issue-route/completion integration and six +semantic/serialization cases (four allocated from the 33 domain cases plus the +two new exhaustive recovery-inventory cases). No case is counted twice. All eleven +affected assignment modules reach 100% lines/statements/functions/branches. +The three completion cases retain type, Project and persisted configuration +together after HTTP 503/403 lookup failures or invalid configuration and verify fresh retries +through real completion/storage logic with deterministic provider transports. +Full-suite LCOV hits **155/155 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; `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 | + +## 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) +- [Add assignees](https://docs.github.com/en/rest/issues/assignees#add-assignees-to-an-issue) +- [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..275918b87 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,12 @@ const RECOVERY_CASES: readonly Readonly<{ english: string; spanish: string; }>[] = [ + { recovery: { id: 'issue-assignment-count-invalid', variables: {} }, + english: 'No assignment operation was attempted. Independent completed lifecycle effects are retained in their own results.', + spanish: 'No se intentó ninguna operación de asignación. Los efectos independientes completados se conservan en sus propios resultados.' }, + { 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__/assignee_assignment_policy.test.ts b/src/application/policies/__tests__/assignee_assignment_policy.test.ts index 5721c9e82..cb7f61784 100644 --- a/src/application/policies/__tests__/assignee_assignment_policy.test.ts +++ b/src/application/policies/__tests__/assignee_assignment_policy.test.ts @@ -1,5 +1,6 @@ import { calculateRemainingAssignees, + isValidAssigneeCount, resolveAssigneeTarget, resolveCreatorAssignment, selectConfirmedAssignees, @@ -13,6 +14,9 @@ const context = { }; describe('assignee assignment policy', () => { + it.each([0, 1, 10, 11, -1, 0.5, NaN, Infinity, -Infinity, '1', undefined, null])('validates the bounded count %s without coercion', count => { + expect(isValidAssigneeCount(count)).toBe(count === 0 || count === 1 || count === 10); + }); it('resolves the issue target when the event is an issue', () => { expect(resolveAssigneeTarget(context)).toEqual({ number: 42, desiredCount: 2 }); expect(resolveAssigneeTarget({ ...context, number: 99, desiredAssigneesCount: 1 })).toEqual({ number: 99, desiredCount: 1 }); 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..a1655597a --- /dev/null +++ b/src/application/policies/__tests__/issue_assignment_messages.test.ts @@ -0,0 +1,49 @@ +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('names invalid configuration and retained independent effects without retaining the supplied count', () => { + const error = new ApplicationError('configuration.invalid', 'Safe.', { recovery: { id: 'issue-assignment-count-invalid', variables: {} } }); + const view = buildApplicationErrorPresentation(error, message); + expect(view.impact).toContain('desired-assignees-count'); + expect(view.action).toContain('0'); expect(view.action).toContain('10'); + expect(view.retainedState).toContain(locale === 'en' ? 'Independent completed' : 'efectos independientes completados'); + expect(view.retryable).toBe(message('error.label.no')); + expect(renderApplicationErrorText(error, message)).toContain(view.action); + expect(error.toJSON().retainedState).toContain('independently completed'); + expect(error.toJSON().recovery).toEqual({ id: 'issue-assignment-count-invalid', variables: {} }); + expect(() => new ApplicationError('configuration.invalid', 'Safe.', { recovery: { id: 'issue-assignment-count-invalid', variables: { supplied: 'private-token' } } as never })).toThrow(); + }); + 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..12cd4073a 100644 --- a/src/application/policies/assignee_assignment_policy.ts +++ b/src/application/policies/assignee_assignment_policy.ts @@ -19,6 +19,10 @@ export function resolveAssigneeTarget(context: AssigneeAssignmentContext): Assig return { number: context.number, desiredCount: context.desiredAssigneesCount }; } +export function isValidAssigneeCount(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 10; +} + function isEligibleCreator(creator: string, projectMembers: readonly string[], currentMembers: readonly string[]): boolean { if (!creator) return false; const identity = creator.toLowerCase(); @@ -47,5 +51,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..ba7a0bc5f --- /dev/null +++ b/src/application/policies/issue_assignment_messages.ts @@ -0,0 +1,42 @@ +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-count-invalid': { + impact: 'Automatic assignment did not start because desired-assignees-count is invalid.', + action: 'Set desired-assignees-count to an integer from 0 to 10 (0 disables assignment), then rerun the same issue workflow.', + retainedState: 'No assignment operation was attempted. Independent completed lifecycle effects are retained in their own results.', + }, + '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-count-invalid': { + impact: 'La asignación automática no empezó porque desired-assignees-count no es válido.', + action: 'Configura desired-assignees-count como un entero de 0 a 10 (0 desactiva la asignación) y repite el mismo workflow.', + retainedState: 'No se intentó ninguna operación de asignación. Los efectos independientes completados se conservan en sus propios resultados.', + }, + '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..3e02af9e7 100644 --- a/src/application/usecases/steps/issue/assign_members_workflow.ts +++ b/src/application/usecases/steps/issue/assign_members_workflow.ts @@ -5,11 +5,14 @@ import { logDebugInfo, logError, logInfo } from '../../../ports/logging_ports'; import { getTaskEmoji } from '../../../../utils/task_emoji'; import { calculateRemainingAssignees, + isValidAssigneeCount, resolveAssigneeTarget, 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 { @@ -26,28 +29,48 @@ export async function runAssignMembersWorkflow( ): Promise { logInfo(`${getTaskEmoji(TASK_ID)} Executing ${TASK_ID}.`); const target = resolveAssigneeTarget(param); + if (!isValidAssigneeCount(target.desiredCount)) { + return [new Result({ id: TASK_ID, success: false, executed: false, + errors: [new ApplicationError('configuration.invalid', 'The desired-assignees-count must be an integer from 0 to 10.', { + recovery: { id: 'issue-assignment-count-invalid', variables: {} }, + })] })]; + } 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 +78,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 +121,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..10d2b33af 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,8 @@ export const APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', 'change-size-reconciliation-failed', + 'issue-assignment-failed', + 'issue-assignment-count-invalid', 'issue-workflow-admission-blocked', 'progress-binary-evidence-unavailable', 'progress-inventory-incomplete', @@ -108,6 +111,8 @@ interface ApplicationErrorRecoveryVariables extends Readonly>; readonly 'issue-workflow-admission-blocked': IssueWorkflowDiagnosticFacts; readonly 'progress-binary-evidence-unavailable': ProgressBinaryEvidenceFacts; readonly 'progress-inventory-incomplete': ProgressInventoryDiagnostic; @@ -365,6 +370,10 @@ export 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 === 'issue-assignment-failed' + ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' + : options.recovery?.id === 'issue-assignment-count-invalid' + ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' : options.recovery?.id === 'deployment-publication-context-missing' ? PRESERVED_STATE : metadata.retainedState; @@ -410,6 +419,8 @@ const RECOVERY_VARIABLE_KEYS: 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..3f8e6d9c6 --- /dev/null +++ b/src/data/repository/github/__tests__/assignment_provider_failure.test.ts @@ -0,0 +1,72 @@ +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.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); + 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..a7036e123 --- /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, data: response.data }, 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_completion.integration.test.ts b/src/tooling/__tests__/issue_assignment_completion.integration.test.ts new file mode 100644 index 000000000..82f972fd6 --- /dev/null +++ b/src/tooling/__tests__/issue_assignment_completion.integration.test.ts @@ -0,0 +1,138 @@ +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([{ status: 503, count: 1 }, { status: 403, count: 1 }, { status: 503, count: 11 }])('retains type, Project and persisted configuration after HTTP $status with count $count, then safely retries the same issue', async ({ status, count }) => { + 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: count, 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]; + const recovers = status === 503 && count === 1; + expect(failure).toMatchObject({ code: count > 10 ? 'configuration.invalid' : status === 503 ? 'provider.unavailable' : 'authorization.denied', retryable: recovers }); + 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(recovers ? 0 : 1); + expect(assignees).toEqual(recovers ? ['alice'] : []); + expect(assignmentClient.rest.issues.addAssignees).toHaveBeenCalledTimes(recovers ? 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(/