diff --git a/.claude/reviews/global/factory-m3-round14-mutations.json b/.claude/reviews/global/factory-m3-round14-mutations.json new file mode 100644 index 00000000..372185a2 --- /dev/null +++ b/.claude/reviews/global/factory-m3-round14-mutations.json @@ -0,0 +1,635 @@ +{ + "date": "2026-09-14", + "checks": [ + { + "name": "table-RepositoryRegistryPage", + "file": "spire-ui/src/components/repositories/RepositoryRegistryPage.tsx", + "from": "", + "to": "
", + "test": "spire-ui/src/settingsTables.contract.test.ts", + "title": "uses the shared table vocabulary", + "snapshot": ".handoff\\r14-mutations\\table-RepositoryRegistryPage.snapshot", + "sha256": "5fc10d62d08136ade1507b834adee5693464555d795be7f5ac06dbbc97261d42", + "mutant": { + "report": ".handoff\\r14-mutations\\table-RepositoryRegistryPage-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\table-RepositoryRegistryPage-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "5fc10d62d08136ade1507b834adee5693464555d795be7f5ac06dbbc97261d42" + }, + { + "name": "table-RepositoryDetail", + "file": "spire-ui/src/components/repositories/RepositoryDetail.tsx", + "from": "
", + "to": "
", + "test": "spire-ui/src/settingsTables.contract.test.ts", + "title": "uses the shared table vocabulary", + "snapshot": ".handoff\\r14-mutations\\table-RepositoryDetail.snapshot", + "sha256": "c10213b1063e55cb86b196d5032113336c01009b98bccd4f262a44874d1bbfb3", + "mutant": { + "report": ".handoff\\r14-mutations\\table-RepositoryDetail-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\table-RepositoryDetail-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "c10213b1063e55cb86b196d5032113336c01009b98bccd4f262a44874d1bbfb3" + }, + { + "name": "table-RepositoryPending", + "file": "spire-ui/src/components/repositories/RepositoryPending.tsx", + "from": "
", + "to": "
", + "test": "spire-ui/src/settingsTables.contract.test.ts", + "title": "uses the shared table vocabulary", + "snapshot": ".handoff\\r14-mutations\\table-RepositoryPending.snapshot", + "sha256": "40b0a2f9eb1b6a55c16d3b2cff303212f56cd6cd1f11e83aa76df77476bddc2e", + "mutant": { + "report": ".handoff\\r14-mutations\\table-RepositoryPending-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\table-RepositoryPending-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "40b0a2f9eb1b6a55c16d3b2cff303212f56cd6cd1f11e83aa76df77476bddc2e" + }, + { + "name": "table-WorkSources", + "file": "spire-ui/src/components/work-items/WorkSources.tsx", + "from": "
", + "to": "
", + "test": "spire-ui/src/settingsTables.contract.test.ts", + "title": "uses the shared table vocabulary", + "snapshot": ".handoff\\r14-mutations\\table-WorkSources.snapshot", + "sha256": "6e122ba5be356eedcdffb7c212aaef801ba2571b81272836f6cd9d510b32ec52", + "mutant": { + "report": ".handoff\\r14-mutations\\table-WorkSources-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\table-WorkSources-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "fb9b7ae18a611781bd6f847e7ce419e83422d54a244623ba0df4541cd34b7379" + }, + { + "name": "path-heading", + "file": "spire-ui/src/components/repositories/RepositoryDetail.tsx", + "from": "", + "to": "", + "test": "spire-ui/src/components/repositories/RepositoryDetail.test.tsx", + "title": "preserves the webhook path heading", + "snapshot": ".handoff\\r14-mutations\\path-heading.snapshot", + "sha256": "c10213b1063e55cb86b196d5032113336c01009b98bccd4f262a44874d1bbfb3", + "mutant": { + "report": ".handoff\\r14-mutations\\path-heading-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\path-heading-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "c10213b1063e55cb86b196d5032113336c01009b98bccd4f262a44874d1bbfb3" + }, + { + "name": "path-bound", + "file": "spire-ui/src/components/repositories/RepositoryDetail.tsx", + "from": "
", + "to": "
", + "test": "spire-ui/src/components/repositories/RepositoryDetail.test.tsx", + "title": "preserves the webhook path heading", + "snapshot": ".handoff\\r14-mutations\\path-bound.snapshot", + "sha256": "c10213b1063e55cb86b196d5032113336c01009b98bccd4f262a44874d1bbfb3", + "mutant": { + "report": ".handoff\\r14-mutations\\path-bound-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\path-bound-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "c10213b1063e55cb86b196d5032113336c01009b98bccd4f262a44874d1bbfb3" + }, + { + "name": "account-stack", + "file": "spire-ui/src/components/repositories/RepositoryAccountsCell.tsx", + "from": "className=\"serving-pair\"", + "to": "className=\"serving-cell\"", + "test": "spire-ui/src/components/repositories/RepositoryDetail.test.tsx", + "title": "preserves the webhook path heading", + "snapshot": ".handoff\\r14-mutations\\account-stack.snapshot", + "sha256": "b34c3be88949272e7cf20636f28d05d0dfb642bb16cb440c3a3d1ead5efeab90", + "mutant": { + "report": ".handoff\\r14-mutations\\account-stack-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\account-stack-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "b34c3be88949272e7cf20636f28d05d0dfb642bb16cb440c3a3d1ead5efeab90" + }, + { + "name": "path-width", + "file": "spire-ui/src/index.css", + "from": ".wh-url { max-width: 180px; }", + "to": ".wh-url { max-width: 360px; }", + "test": "spire-ui/src/repositoryLayout.contract.test.ts", + "title": "retains the operator requested", + "snapshot": ".handoff\\r14-mutations\\path-width.snapshot", + "sha256": "1dbdb3c935ecd5c93cd6b33679df13636acc9e56f07fb6981f1aefc4c7b34111", + "mutant": { + "report": ".handoff\\r14-mutations\\path-width-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\path-width-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "1dbdb3c935ecd5c93cd6b33679df13636acc9e56f07fb6981f1aefc4c7b34111" + }, + { + "name": "pair-direction", + "file": "spire-ui/src/index.css", + "from": "flex-direction: column; align-items: flex-start; gap: 4px; white-space: nowrap;", + "to": "flex-direction: row; align-items: flex-start; gap: 4px; white-space: nowrap;", + "test": "spire-ui/src/repositoryLayout.contract.test.ts", + "title": "retains the operator requested", + "snapshot": ".handoff\\r14-mutations\\pair-direction.snapshot", + "sha256": "1dbdb3c935ecd5c93cd6b33679df13636acc9e56f07fb6981f1aefc4c7b34111", + "mutant": { + "report": ".handoff\\r14-mutations\\pair-direction-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\pair-direction-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "1dbdb3c935ecd5c93cd6b33679df13636acc9e56f07fb6981f1aefc4c7b34111" + }, + { + "name": "pair-alignment", + "file": "spire-ui/src/index.css", + "from": "flex-direction: column; align-items: flex-start; gap: 4px; white-space: nowrap;", + "to": "flex-direction: column; align-items: center; gap: 4px; white-space: nowrap;", + "test": "spire-ui/src/repositoryLayout.contract.test.ts", + "title": "retains the operator requested", + "snapshot": ".handoff\\r14-mutations\\pair-alignment.snapshot", + "sha256": "1dbdb3c935ecd5c93cd6b33679df13636acc9e56f07fb6981f1aefc4c7b34111", + "mutant": { + "report": ".handoff\\r14-mutations\\pair-alignment-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\pair-alignment-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "1dbdb3c935ecd5c93cd6b33679df13636acc9e56f07fb6981f1aefc4c7b34111" + }, + { + "name": "WorkSources-wh-empty", + "file": "spire-ui/src/components/work-items/WorkSources.tsx", + "from": "className=\"wh-empty\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work sources screen", + "snapshot": ".handoff\\r14-mutations\\WorkSources-wh-empty.snapshot", + "sha256": "6e122ba5be356eedcdffb7c212aaef801ba2571b81272836f6cd9d510b32ec52", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "fb9b7ae18a611781bd6f847e7ce419e83422d54a244623ba0df4541cd34b7379" + }, + { + "name": "WorkSources-wh-empty-icon", + "file": "spire-ui/src/components/work-items/WorkSources.tsx", + "from": "className=\"wh-empty-icon\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work sources screen", + "snapshot": ".handoff\\r14-mutations\\WorkSources-wh-empty-icon.snapshot", + "sha256": "6e122ba5be356eedcdffb7c212aaef801ba2571b81272836f6cd9d510b32ec52", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-icon-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-icon-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "fb9b7ae18a611781bd6f847e7ce419e83422d54a244623ba0df4541cd34b7379" + }, + { + "name": "WorkSources-wh-empty-title", + "file": "spire-ui/src/components/work-items/WorkSources.tsx", + "from": "className=\"wh-empty-title\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work sources screen", + "snapshot": ".handoff\\r14-mutations\\WorkSources-wh-empty-title.snapshot", + "sha256": "6e122ba5be356eedcdffb7c212aaef801ba2571b81272836f6cd9d510b32ec52", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-title-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-title-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "fb9b7ae18a611781bd6f847e7ce419e83422d54a244623ba0df4541cd34b7379" + }, + { + "name": "WorkSources-wh-empty-text", + "file": "spire-ui/src/components/work-items/WorkSources.tsx", + "from": "className=\"wh-empty-text\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work sources screen", + "snapshot": ".handoff\\r14-mutations\\WorkSources-wh-empty-text.snapshot", + "sha256": "6e122ba5be356eedcdffb7c212aaef801ba2571b81272836f6cd9d510b32ec52", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-text-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkSources-wh-empty-text-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "fb9b7ae18a611781bd6f847e7ce419e83422d54a244623ba0df4541cd34b7379" + }, + { + "name": "WorkItems-wh-empty", + "file": "spire-ui/src/components/work-items/WorkItems.tsx", + "from": "className=\"wh-empty\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work items screen", + "snapshot": ".handoff\\r14-mutations\\WorkItems-wh-empty.snapshot", + "sha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6" + }, + { + "name": "WorkItems-wh-empty-icon", + "file": "spire-ui/src/components/work-items/WorkItems.tsx", + "from": "className=\"wh-empty-icon\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work items screen", + "snapshot": ".handoff\\r14-mutations\\WorkItems-wh-empty-icon.snapshot", + "sha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-icon-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-icon-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6" + }, + { + "name": "WorkItems-wh-empty-title", + "file": "spire-ui/src/components/work-items/WorkItems.tsx", + "from": "className=\"wh-empty-title\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work items screen", + "snapshot": ".handoff\\r14-mutations\\WorkItems-wh-empty-title.snapshot", + "sha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-title-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-title-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6" + }, + { + "name": "WorkItems-wh-empty-text", + "file": "spire-ui/src/components/work-items/WorkItems.tsx", + "from": "className=\"wh-empty-text\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Work items screen", + "snapshot": ".handoff\\r14-mutations\\WorkItems-wh-empty-text.snapshot", + "sha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6", + "mutant": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-text-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\WorkItems-wh-empty-text-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "ec0147cbd718c77a0cde28dc226d32e480dd384dc677fabd038f8f52a5e0d4c6" + }, + { + "name": "Approvals-wh-empty", + "file": "spire-ui/src/components/work-items/Approvals.tsx", + "from": "className=\"wh-empty\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Approvals screen", + "snapshot": ".handoff\\r14-mutations\\Approvals-wh-empty.snapshot", + "sha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190", + "mutant": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190" + }, + { + "name": "Approvals-wh-empty-icon", + "file": "spire-ui/src/components/work-items/Approvals.tsx", + "from": "className=\"wh-empty-icon\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Approvals screen", + "snapshot": ".handoff\\r14-mutations\\Approvals-wh-empty-icon.snapshot", + "sha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190", + "mutant": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-icon-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-icon-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190" + }, + { + "name": "Approvals-wh-empty-title", + "file": "spire-ui/src/components/work-items/Approvals.tsx", + "from": "className=\"wh-empty-title\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Approvals screen", + "snapshot": ".handoff\\r14-mutations\\Approvals-wh-empty-title.snapshot", + "sha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190", + "mutant": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-title-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-title-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190" + }, + { + "name": "Approvals-wh-empty-text", + "file": "spire-ui/src/components/work-items/Approvals.tsx", + "from": "className=\"wh-empty-text\"", + "to": "className=\"\"", + "test": "spire-ui/src/components/work-items/FactoryEmptyStates.test.tsx", + "title": "welcomes the operator to an empty Approvals screen", + "snapshot": ".handoff\\r14-mutations\\Approvals-wh-empty-text.snapshot", + "sha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190", + "mutant": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-text-mutant.json", + "passed": 0, + "failed": 1 + }, + "restored": { + "report": ".handoff\\r14-mutations\\Approvals-wh-empty-text-restored.json", + "passed": 1, + "failed": 0 + }, + "finalSourceSha256": "cc7425f1fe5035162075c02a87584168dc68f93e80513f395aca28bd12fa4190" + }, + { + "name": "source-label", + "file": "spire-ui/src/components/work-items/WorkSources.tsx", + "from": "
Webhook pathPayload URL
--no-parallel; UI uses vitest with +one exact case. Full affected suites run after restoration in the final tiers. + +The first stored-account-check mutant reached the wrong adapter endpoint and threw an API +exception: this was not counted as an assertion kill. The witness now asserts the selected +binding's check completes successfully before verifying its exact authorized request; rerunning +the same mutant produces one assertion failure and restores green. Initial runner argument +errors likewise are not counted as mutation kills. + +The first five lookup mutations remove kind/origin/workspace/slug equality or origin +canonicalization individually. Their selected RepositoryLookupTest methods are +samePathOnAnotherForgeKindCannotMatch, samePathOnAnotherOriginCannotMatch, +anotherNamespaceCannotMatch, anotherSlugCannotMatch and canonicalOriginFindsRegisteredRepository. +Their shared original SHA-256 is +4CFC8B7740343CA99B6CBD0D76B3EBA67FFD7E8D28D1F2B842F4841DBE9B12A8. + +The tables record the original scratch hash for each subsequent mutation. Each row means +one selected assertion failure followed by one passing restored case; JSON/XML/log files are +under .handoff/s2-java-* and .handoff/s2-* locally. + +| Java mutation | Selected witness | Original SHA-256 | +|---|---|---| +| binding_filter | RepositoryResourceTest.repositoryOwnsWorkspaceAndRoleBindings | 0EC390C51F6E08EEBA37BD1E7F33779803688D62D164147B0F6C08FF879678B7 | +| kind_unique | RepositoryWebhookKindsTest.refusesASecondWebhookForTheSameKind | 85C6A85A1DAAC63BB7C03F05D78728435593109145269F9B854500374C0CB628 | +| scope | RepositoryWebhookKindsTest.validSignatureCannotCrossRepositoryScope | E09D2A501E6F9CF4BD2A51E2A0A4E0CDE37D5AC17D78AD451BA1FC7A87146847 | +| gateway_kind | RepositoryWebhookKindsTest.preservesLegacyKeyAndRejectsWrongKind | E09D2A501E6F9CF4BD2A51E2A0A4E0CDE37D5AC17D78AD451BA1FC7A87146847 | +| workspace_read | AccountWorkspaceIsUnusedTest.noProductionCodeReadsLegacyAccountWorkspace | 1523A575467F9F52BCD5E81251F78401BBF5DC82A1FBD93ABD37AC5CF78AD6FD | +| unregistered_attention | UnregisteredRepositoryAttentionTest.namesRepositoryOriginAndRegistration | 1082F772345144F671BED4629CDF6E77D545B404BC4828CEA468DE25AD5AD8A3 | +| nested_lookup | RepositoryLookupTest.legacyNestedReviewCoordinatesFindTheCanonicalRepository | 0EC390C51F6E08EEBA37BD1E7F33779803688D62D164147B0F6C08FF879678B7 | +| review_role | RepositoryResolverCutoverTest.allDispatchPathsUseTheSelectedRepository | EECEB4D34A97387046FCB314A30ADB6B007E4AB67B3263889588BFEFD6F8CEAF | +| factory_role | RepositoryResolverCutoverTest.allDispatchPathsUseTheSelectedRepository | 47C5249F3677130A4E66243EA63572F0012350F093597C01DD636E0CD01666EC | +| unmapped_manual | RepositoryResolverCutoverTest.unmappedLegacyReviewCannotDispatch | 4C4F3DFC0598F4A5BCF0075ED852EDB5D184598EE2B5FB3827EA0FF6E0C098BE | +| unmapped_claim | RepositoryResolverCutoverTest.unmappedLegacyReviewCannotDispatch | B5131957AD156893BCC41232794CAE56E3C97EE64DF85A072A45FFDEC7212413 | +| command_mapping | ConversationFindingSagaTest.findingOnAnUnregisteredPrFilesNothingAndConfirmsNothing | AED0F96B4FC5E6BA8D21FCA5BCD08EE71895EF0188FB676A3C3344F0D3700002 | +| ingress_kind | RepositoryIngressRoutingTest.aFactoryHookCannotDeliverReviewerCommands | 5E059DD1B9D048781CD743CC2F3901FCDFD115BEE95FE0C34E6C0D6A8557A24E | +| ingress_disabled | RepositoryIngressRoutingTest.aDisabledRepositoryCannotProcessAnAlreadyVerifiedDelivery | 5E059DD1B9D048781CD743CC2F3901FCDFD115BEE95FE0C34E6C0D6A8557A24E | +| ingress_id | RepositoryIngressRoutingTest.anExplicitRepositoryIdCannotNameAnotherMatchingPath | 5E059DD1B9D048781CD743CC2F3901FCDFD115BEE95FE0C34E6C0D6A8557A24E | +| reply_scope | RepositoryIngressRoutingTest.aReplyCannotNameAnotherReviewThanItsRepositoryCoordinates | 5E059DD1B9D048781CD743CC2F3901FCDFD115BEE95FE0C34E6C0D6A8557A24E | +| factory_routing | RepositoryIngressRoutingTest.factoryActivityNeverEntersTheReviewLifecycle | 5E059DD1B9D048781CD743CC2F3901FCDFD115BEE95FE0C34E6C0D6A8557A24E | +| missing_origin | RepositoryIngressRoutingTest.aMissingOriginIsAttentionEvenWhenThePathIsRegistered | 5E059DD1B9D048781CD743CC2F3901FCDFD115BEE95FE0C34E6C0D6A8557A24E | +| delivery_dlq | DlqTopicsTest.repositoryDeliveriesReplayWithTheirProvenance | 2F04F887A0C79715A4BE3B7955768452839CACC65268B106AD512A82E5DD9C52 | +| legacy_dlq | RepositoryResolverCutoverTest.legacyWireDeliveryIsDeadLetteredWithItsProvenanceProblem | AED0F96B4FC5E6BA8D21FCA5BCD08EE71895EF0188FB676A3C3344F0D3700002 | +| validation_origin | ProviderIdentityResolverTest.validationRepositoryMustBelongToTheAccountsForgeOrigin | 3C5983C6FEBE6A0BCD3E2CAE0C40D714FCCECE9376489C233F16FC67AC4284F4 | +| validation_kind | ProviderIdentityResolverTest.validationRepositoryMustBelongToTheAccountsForgeKind | 3C5983C6FEBE6A0BCD3E2CAE0C40D714FCCECE9376489C233F16FC67AC4284F4 | +| simulator_id | DevSimulatorRepositoryTest.simulationRequiresAnExplicitRepository | 10ABA0AE719CA0FD2705651647D9054F618AB7527AA35D95E13A8A30E3F5FE38 | +| simulator_scope | DevSimulatorRepositoryTest.simulationCarriesItsSelectedRepositoryAndCannotUseRealNamespaces | 10ABA0AE719CA0FD2705651647D9054F618AB7527AA35D95E13A8A30E3F5FE38 | +| simulator_stub | DevSimulatorRepositoryTest.simulationRequiresStubMode | 10ABA0AE719CA0FD2705651647D9054F618AB7527AA35D95E13A8A30E3F5FE38 | +| duplicate_http | WebhookRepoResourceTest.duplicateKindReturnsARepairableConflict | 6F25000D2E242EB5A3847BE1953C6B66C2CEF5DDEAAA3B4CF01B6C0ABAE1EDAC | +| issue_create | WebhookRepoResourceTest.issueKindRequiresASourceOnCreate | 6F25000D2E242EB5A3847BE1953C6B66C2CEF5DDEAAA3B4CF01B6C0ABAE1EDAC | +| issue_update | WebhookRepoResourceTest.issueKindRequiresASourceOnUpdateAndPreservesAnExistingSource | 6F25000D2E242EB5A3847BE1953C6B66C2CEF5DDEAAA3B4CF01B6C0ABAE1EDAC | +| delivery_identity | RepositoryDeliveryTest.requiresARepositoryOrRegistration | 8F42F5338D7B3CB5CC99FE92781FA14A60E9903B24555B89F74AF6ADF8337659 | +| delivery_revision | RepositoryDeliveryTest.requiresAPositiveRegistrationRevision | 8F42F5338D7B3CB5CC99FE92781FA14A60E9903B24555B89F74AF6ADF8337659 | +| delivery_id | RepositoryDeliveryTest.requiresANonblankDeliveryId | 8F42F5338D7B3CB5CC99FE92781FA14A60E9903B24555B89F74AF6ADF8337659 | +| check_binding | ProviderIdentityResolverTest.storedAccountChecksUseAnExplicitRepositoryBinding | 3C5983C6FEBE6A0BCD3E2CAE0C40D714FCCECE9376489C233F16FC67AC4284F4 | +| manual_request_id | RepositoryRequestIdentityTest.manualRequiresExplicitRepositoryIdentity | 72A13F65CB67F7EEF5D1D68F6827D57B0ADD6D7E297C367351826AAB0DDF3F16 | +| manual_request_workspace | RepositoryRequestIdentityTest.manualRejectsContradictoryCoordinates | 72A13F65CB67F7EEF5D1D68F6827D57B0ADD6D7E297C367351826AAB0DDF3F16 | +| manual_request_slug | RepositoryRequestIdentityTest.manualRejectsContradictoryCoordinates | 72A13F65CB67F7EEF5D1D68F6827D57B0ADD6D7E297C367351826AAB0DDF3F16 | +| manual_request_providerType | RepositoryRequestIdentityTest.manualRejectsContradictoryCoordinates | 72A13F65CB67F7EEF5D1D68F6827D57B0ADD6D7E297C367351826AAB0DDF3F16 | +| run_request_id | RepositoryRequestIdentityTest.runRequiresExplicitRepositoryIdentity | CDC238E5DBD09120082950CB86E1D5BA35E958FDA30479B60A80990A22FDA1D5 | +| run_request_workspace | RepositoryRequestIdentityTest.runRejectsContradictoryCoordinates | CDC238E5DBD09120082950CB86E1D5BA35E958FDA30479B60A80990A22FDA1D5 | +| run_request_slug | RepositoryRequestIdentityTest.runRejectsContradictoryCoordinates | CDC238E5DBD09120082950CB86E1D5BA35E958FDA30479B60A80990A22FDA1D5 | +| run_request_providerType | RepositoryRequestIdentityTest.runRejectsContradictoryCoordinates | CDC238E5DBD09120082950CB86E1D5BA35E958FDA30479B60A80990A22FDA1D5 | +| legacy_kind_default | RepositoryRegistrationTest.legacyMetadataDefaultsToReviewerWithoutInventingRepositoryOrSource | 6C8D5FEF66427F9B117CABFAA16A2FB8B0C34AAB075EBC6DEF858F1E61493011 | +| delivery_discriminator | RepositoryDeliveryTest.verifiedEnvelopeRoundTripsWithProvenance | 8F42F5338D7B3CB5CC99FE92781FA14A60E9903B24555B89F74AF6ADF8337659 | + +| UI mutation | Selected witness | Original SHA-256 | +|---|---|---| +| detail_workspace | RepositoryDetail: shows workspace selected accounts and one webhook per event kind | FABE9681EB0DBA21C44EF10A43ECCE676520A107A114E7CB2EF380E7753A2D46 | +| detail_accounts | same criterion 7 case; hide accounts | FABE9681EB0DBA21C44EF10A43ECCE676520A107A114E7CB2EF380E7753A2D46 | +| detail_hooks | same criterion 7 case; hide hooks | FABE9681EB0DBA21C44EF10A43ECCE676520A107A114E7CB2EF380E7753A2D46 | +| account_workspace | SettingsProviders.form: does not offer workspace on an account | EE780071CEFFE3820D947CCABDD9B5FCF2B219B5274E54111CA3D828E39D216D | +| lost_response | RepositoryDetail: recovers a lost webhook response without creating a duplicate | FABE9681EB0DBA21C44EF10A43ECCE676520A107A114E7CB2EF380E7753A2D46 | +| legacy_repair | RepositoryDetail: requires explicit origin repair before creating beside an unresolved legacy hook | FABE9681EB0DBA21C44EF10A43ECCE676520A107A114E7CB2EF380E7753A2D46 | +| validation_origin | SettingsProviders.form: validates an account-less token against an explicit same-origin repository | EE780071CEFFE3820D947CCABDD9B5FCF2B219B5274E54111CA3D828E39D216D | +| validation_kind | same validation case; remove kind match | EE780071CEFFE3820D947CCABDD9B5FCF2B219B5274E54111CA3D828E39D216D | +| validation_reset | SettingsProviders.form: clears the validation repository when the account origin changes | EE780071CEFFE3820D947CCABDD9B5FCF2B219B5274E54111CA3D828E39D216D | +| attention_prefill | RepositoryRegistryPage: prefills registration from Attention while the repository page is already mounted | A5EA9BB78A9AC73243533A353E768A9AFCC88922746F6EBF4032D9A475915315 | +| pending_owner | RepositoryRegistryPage: repairs a gateway registration at its owner so new deliveries carry the selected origin | 147CC4284502664C71BA2F88BA8AA60864F2BDF813302A2F9737775B26AA50E2 | +| pending_type | RepositoryRegistryPage: refuses to repair a registration whose current forge or coordinates changed | 147CC4284502664C71BA2F88BA8AA60864F2BDF813302A2F9737775B26AA50E2 | +| pending_scope | same stale-repair case; remove scope check | 147CC4284502664C71BA2F88BA8AA60864F2BDF813302A2F9737775B26AA50E2 | +| pending_target | same stale-repair case; remove target check | 147CC4284502664C71BA2F88BA8AA60864F2BDF813302A2F9737775B26AA50E2 | +| pending_origin | same stale-repair case; remove origin check | 147CC4284502664C71BA2F88BA8AA60864F2BDF813302A2F9737775B26AA50E2 | + +## Final verification and real rollout + +Forced testFast then testServices passed sequentially with JDK 25 and --no-parallel: +3048 Java tests across 357 suites, zero failures and 1 existing Windows symlink privilege skip. Assemble passed. No live run worker was started; the service tier's isolated worker tests ran under the Docker lock. UI: 630 tests across 77 files and production build passed. The final full UI run used +--maxWorkers=4 after one unchanged role test timed out under default concurrency; its isolated +three-case file also passed. Local pinned Semgrep 1.172.0 scanned 1,090 files with 442 rules and +zero findings on the final runtime sources, including the UI repair and wire-contract tests. +Two architecture checks initially inspected the scanner's duplicate source copy inside .handoff; +moving that completed snapshot outside the worktree removed the duplicate inputs. + +The existing backup is unchanged: 182 objects, 492,480 bytes, SHA-256 +7660D8EAE4110141887BD565747D45D1AA2621025A157D36741F70B6E65A1051. +After rebuilding gateway first, then orchestrator and UI with --build --no-deps, readiness +was UP and UI returned HTTP 200. Gateway V4 and orchestrator V61 are live. All three V4 +snapshots were acknowledged and the live DLQ remained empty. + +Post-cutover counts are exactly 6 accounts / 37 reviews / 85 findings / 14 runs / 3 hooks. +The six retained workspace values and all hook rejection metadata match their baseline. +The encrypted comparisons matched all 9 account/context entries and all 12 webhook entries: +no missing, added or changed entries. Authenticated API reads showed six repositories, eight +selected role bindings, six workspace-free accounts, three retained REVIEWER hooks and the +three explicit origin repairs. This was a read-only proof; no synthetic live row was introduced. + +Live image IDs from the rebuilt source: + +- /spire-gateway-dev sha256:7d747d684ee6fccf0fb87583ab1070f5eb12673700bd2731a341671550aa9ed1 +- /spire-orchestrator-dev sha256:be662d7084cef6fd3c1ccfd06d1a0b8119740598a206379487274b8da8f0c150 +- /spire-ui-dev sha256:44c64ad66bf86aa5a7ce31ee1d67f1dbb2717cee385b2149d8cfd434969b106e +The encrypted account/context +baseline has 9 entries; the separate webhook baseline has 12 entries (key, ciphertext, +decrypted secret and provider/scope/target for each of three real hooks). + +The three missing origins still require operator confirmation. They remain pending by design, +so their deliveries cannot dispatch until repaired; they are not reported as live routing proofs. A read-only attempt to find +the retained keys in forge webhook settings returned GitHub 404, Bitbucket 403 and GitLab 403; +none confirms an origin. No webhook origin, key or secret was changed by that probe. Later M3 +work-item/ISSUE behavior and native forge permission measurements remain outside this slice. +No synthetic live rows, additional database dump, or live run-worker start were made. + +## Exact mutation edits + +Paths and line numbers below refer to the recorded scratch snapshot. A dash means the replacement is empty. +Every recorded restored hash equals its original hash in the tables above. The selected mutant has +one failed case and zero skipped cases; the selected restored case passes. Baselines are the +successful selected/full suites recorded above and in the corresponding local logs. + +| Mutation | Production path and original line | From → to | Exact selector | +|---|---|---|---| +| binding_filter | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryRegistry.java:28 | AND ra.role='REVIEWER' → — | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryResourceTest.repositoryOwnsWorkspaceAndRoleBindings | +| kind_unique | spire-gateway/src/main/resources/db/migration/V4__repository_webhook_kinds.sql:8 | ALTER TABLE webhook_repo ADD CONSTRAINT webhook_repo_repository_kind_key UNIQUE (repository_id,event_kind); → -- TEST mutation: remove repository/kind uniqueness | :spire-gateway:test --rerun --tests dev.codespire.gateway.registry.RepositoryWebhookKindsTest.refusesASecondWebhookForTheSameKind | +| scope | spire-gateway/src/main/java/dev/codespire/gateway/RegistryWebhookEdge.java:161 | eventRepo.full().equals(reg.target()) → true | :spire-gateway:test --rerun --tests dev.codespire.gateway.registry.RepositoryWebhookKindsTest.validSignatureCannotCrossRepositoryScope | +| gateway_kind | spire-gateway/src/main/java/dev/codespire/gateway/RegistryWebhookEdge.java:117 | if (!repo.eventKind().accepts(event)) → if (false) | :spire-gateway:test --rerun --tests dev.codespire.gateway.registry.RepositoryWebhookKindsTest.preservesLegacyKeyAndRejectsWrongKind | +| workspace_read | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryAccounts.java:39 | SELECT a.account_id, r.scm_type → SELECT (SELECT workspace FROM scm_provider WHERE id=a.account_id) legacy_workspace, a.account_id, r.scm_type | :spire-arch:test --rerun --tests dev.codespire.arch.AccountWorkspaceIsUnusedTest.noProductionCodeReadsLegacyAccountWorkspace | +| unregistered_attention | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/UnregisteredRepositoryEvents.java:31 | ps.executeUpdate(); → ; | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.UnregisteredRepositoryAttentionTest.namesRepositoryOriginAndRegistration | +| nested_lookup | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryRegistry.java:64 | statement.setString(3, fullPath.substring(0, leaf)); → statement.setString(3, workspace); | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryLookupTest.legacyNestedReviewCoordinatesFindTheCanonicalRepository | +| review_role | spire-orchestrator/src/main/java/dev/codespire/orchestrator/provider/ReviewProviderResolver.java:28 | accounts.resolve(id, ProviderRole.REVIEWER) → accounts.resolve(id, ProviderRole.FACTORY) | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryResolverCutoverTest.allDispatchPathsUseTheSelectedRepository | +| factory_role | spire-orchestrator/src/main/java/dev/codespire/orchestrator/factory/MachineAccounts.java:38 | return accounts.resolve(repositoryId, ProviderRole.FACTORY)
.filter → return accounts.resolve(repositoryId, ProviderRole.REVIEWER)
.filter | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryResolverCutoverTest.allDispatchPathsUseTheSelectedRepository | +| unmapped_manual | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/ManualRegisterResource.java:82 | if (projection.registered(requestedReviewId) → if (false && projection.registered(requestedReviewId) | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryResolverCutoverTest.unmappedLegacyReviewCannotDispatch | +| unmapped_claim | spire-orchestrator/src/main/java/dev/codespire/orchestrator/readmodel/ReviewProjection.java:105 | WHERE review_status.repository_id=EXCLUDED.repository_id → WHERE review_status.repository_id=EXCLUDED.repository_id OR review_status.repository_id IS NULL | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryResolverCutoverTest.unmappedLegacyReviewCannotDispatch | +| command_mapping | spire-orchestrator/src/main/java/dev/codespire/orchestrator/pipeline/IntegrationSaga.java:123 | } else if (!projection.repositoryIdOf(reviewId).filter(repositoryId::equals).isPresent()) → } else if (false && !projection.repositoryIdOf(reviewId).filter(repositoryId::equals).isPresent()) | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.pipeline.ConversationFindingSagaTest.findingOnAnUnregisteredPrFilesNothingAndConfirmsNothing | +| ingress_kind | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryIngressConsumer.java:33 | if (!delivery.eventKind().accepts(delivery.event())) return; → if (false) return; | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryIngressRoutingTest.aFactoryHookCannotDeliverReviewerCommands | +| ingress_disabled | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryIngressConsumer.java:42 | !repository.enabled() \|\| → false \|\| | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryIngressRoutingTest.aDisabledRepositoryCannotProcessAnAlreadyVerifiedDelivery | +| ingress_id | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryIngressConsumer.java:43 | !repository.id().equals(delivery.repositoryId()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryIngressRoutingTest.anExplicitRepositoryIdCannotNameAnotherMatchingPath | +| reply_scope | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryIngressConsumer.java:46 | !dev.codespire.contract.event.ReviewIds.parse(reply.reviewId()).repo().equals(delivery.repo()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryIngressRoutingTest.aReplyCannotNameAnotherReviewThanItsRepositoryCoordinates | +| factory_routing | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryIngressConsumer.java:49 | } else if (delivery.eventKind() == RepositoryEventKind.FACTORY) → } else if (false) | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryIngressRoutingTest.factoryActivityNeverEntersTheReviewLifecycle | +| missing_origin | spire-orchestrator/src/main/java/dev/codespire/orchestrator/repository/RepositoryIngressConsumer.java:34 | delivery.forgeOrigin() == null ? Optional.empty() → false ? Optional.empty() | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryIngressRoutingTest.aMissingOriginIsAttentionEvenWhenThePathIsRegistered | +| delivery_dlq | spire-orchestrator/src/main/java/dev/codespire/orchestrator/dlq/DlqTopics.java:57 | if ("RepositoryDelivery".equals(type)) return "cs.repository-integration"; → if ("RepositoryDelivery".equals(type)) return "cs.commands"; | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.dlq.DlqTopicsTest.repositoryDeliveriesReplayWithTheirProvenance | +| legacy_dlq | spire-orchestrator/src/main/java/dev/codespire/orchestrator/pipeline/IntegrationSaga.java:105 | throw new IllegalStateException("Legacy ingress has no verified repository identity; redeliver through its registered webhook"); → return; | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.repository.RepositoryResolverCutoverTest.legacyWireDeliveryIsDeadLetteredWithItsProvenanceProblem | +| validation_origin | spire-orchestrator/src/main/java/dev/codespire/orchestrator/provider/ProviderIdentityResolver.java:50 | !repository.forgeOrigin().equals(dev.codespire.contract.scm.ForgeOrigin.of(in.baseUrl())) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.provider.ProviderIdentityResolverTest.validationRepositoryMustBelongToTheAccountsForgeOrigin | +| validation_kind | spire-orchestrator/src/main/java/dev/codespire/orchestrator/provider/ProviderIdentityResolver.java:49 | !repository.scmType().equals(in.type()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.provider.ProviderIdentityResolverTest.validationRepositoryMustBelongToTheAccountsForgeKind | +| simulator_id | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/DevSimulatorResource.java:50 | if (repositoryId == null) throw → if (false) throw | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.DevSimulatorRepositoryTest.simulationRequiresAnExplicitRepository | +| simulator_scope | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/DevSimulatorResource.java:52 | if (!repository.workspace().startsWith("TEST-")) → if (false) | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.DevSimulatorRepositoryTest.simulationCarriesItsSelectedRepositoryAndCannotUseRealNamespaces | +| simulator_stub | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/DevSimulatorResource.java:46 | if (!stubScm) → if (false) | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.DevSimulatorRepositoryTest.simulationRequiresStubMode | +| duplicate_http | spire-gateway/src/main/java/dev/codespire/gateway/registry/WebhookRepoResource.java:91 | "23505".equals(sql.getSQLState()) → false | :spire-gateway:test --rerun --tests dev.codespire.gateway.WebhookRepoResourceTest.duplicateKindReturnsARepairableConflict | +| issue_create | spire-gateway/src/main/java/dev/codespire/gateway/registry/WebhookRepoResource.java:68 | in.eventKind() == dev.codespire.contract.event.RepositoryEventKind.ISSUE && in.sourceId() == null → false | :spire-gateway:test --rerun --tests dev.codespire.gateway.WebhookRepoResourceTest.issueKindRequiresASourceOnCreate | +| issue_update | spire-gateway/src/main/java/dev/codespire/gateway/registry/WebhookRepoResource.java:81 | kind == dev.codespire.contract.event.RepositoryEventKind.ISSUE && sourceId == null → false | :spire-gateway:test --rerun --tests dev.codespire.gateway.WebhookRepoResourceTest.issueKindRequiresASourceOnUpdateAndPreservesAnExistingSource | +| delivery_identity | spire-contract/src/main/java/dev/codespire/contract/event/RepositoryDelivery.java:19 | registrationId == null && repositoryId == null → false | :spire-contract:test --rerun --tests dev.codespire.contract.event.RepositoryDeliveryTest.requiresARepositoryOrRegistration | +| delivery_revision | spire-contract/src/main/java/dev/codespire/contract/event/RepositoryDelivery.java:20 | registrationId != null && registrationRevision < 1 → false | :spire-contract:test --rerun --tests dev.codespire.contract.event.RepositoryDeliveryTest.requiresAPositiveRegistrationRevision | +| delivery_id | spire-contract/src/main/java/dev/codespire/contract/event/RepositoryDelivery.java:18 | deliveryId == null \|\| deliveryId.isBlank() → false | :spire-contract:test --rerun --tests dev.codespire.contract.event.RepositoryDeliveryTest.requiresANonblankDeliveryId | +| check_binding | spire-orchestrator/src/main/java/dev/codespire/orchestrator/provider/ProviderIdentityResolver.java:59 | .filter(repository -> (repository.reviewer() != null && p.id().equals(repository.reviewer().id()))
\|\| (repository.factory() != null && p.id().equals(repository.factory().id()))) → .filter(repository -> true) | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.provider.ProviderIdentityResolverTest.storedAccountChecksUseAnExplicitRepositoryBinding | +| manual_request_id | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/ManualRegisterResource.java:187 | req.repositoryId() == null → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.manualRequiresExplicitRepositoryIdentity | +| manual_request_workspace | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/ManualRegisterResource.java:192 | req.workspace() != null && !req.workspace().equals(repository.workspace()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.manualRejectsContradictoryCoordinates | +| manual_request_slug | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/ManualRegisterResource.java:193 | req.slug() != null && !req.slug().equals(repository.slug()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.manualRejectsContradictoryCoordinates | +| manual_request_providerType | spire-orchestrator/src/main/java/dev/codespire/orchestrator/ingress/ManualRegisterResource.java:194 | req.providerType() != null && !req.providerType().equals(repository.scmType()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.manualRejectsContradictoryCoordinates | +| run_request_id | spire-orchestrator/src/main/java/dev/codespire/orchestrator/factory/RunResource.java:125 | req.repositoryId() == null → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.runRequiresExplicitRepositoryIdentity | +| run_request_workspace | spire-orchestrator/src/main/java/dev/codespire/orchestrator/factory/RunResource.java:130 | req.workspace() != null && !req.workspace().equals(repository.workspace()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.runRejectsContradictoryCoordinates | +| run_request_slug | spire-orchestrator/src/main/java/dev/codespire/orchestrator/factory/RunResource.java:131 | req.slug() != null && !req.slug().equals(repository.slug()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.runRejectsContradictoryCoordinates | +| run_request_providerType | spire-orchestrator/src/main/java/dev/codespire/orchestrator/factory/RunResource.java:132 | req.providerType() != null && !req.providerType().equals(repository.scmType()) → false | :spire-orchestrator:test --rerun --tests dev.codespire.orchestrator.ingress.RepositoryRequestIdentityTest.runRejectsContradictoryCoordinates | +| legacy_kind_default | spire-contract/src/main/java/dev/codespire/contract/event/RepositoryRegistration.java:20 | if (eventKind == null) eventKind = RepositoryEventKind.REVIEWER; → if (eventKind == null) eventKind = RepositoryEventKind.FACTORY; | :spire-contract:test --rerun --tests dev.codespire.contract.RepositoryRegistrationTest.legacyMetadataDefaultsToReviewerWithoutInventingRepositoryOrSource | +| delivery_discriminator | spire-contract/src/main/java/dev/codespire/contract/event/RepositoryDelivery.java:10 | JsonTypeName("RepositoryDelivery") → JsonTypeName("TEST-WrongDelivery") | :spire-contract:test --rerun --tests dev.codespire.contract.event.RepositoryDeliveryTest.verifiedEnvelopeRoundTripsWithProvenance | +| detail_workspace | spire-ui/src/components/repositories/RepositoryDetail.tsx:91 |
→
{repos.map((w) => { // One lookup for both chips: the same (type, owner) pair answers reviewer and factory. - const serves = serving[servingKey(w.providerType, ownerOf(w))]; + const serves = w.repositoryId ? serving[w.repositoryId] : { error: 'Select a repository to see its accounts' }; return ( @@ -228,273 +220,52 @@ export default function SettingsWebhookRepos() { ); } -/** Loads enabled providers and preselects one — the row's provider on edit (matched by type + owner), - * else the first. Keeps the modal under the max-8 useState rule. - * Reviewer accounts only: this form registers what will be reviewed, and a Factory account has - * nothing to review with. */ -function useWebhookProviders(initial: WebhookRepoView | null) { - const [providers, setProviders] = useState([]); - const [providersLoaded, setProvidersLoaded] = useState(false); - const [providerId, setProviderId] = useState(''); - const [loadError, setLoadError] = useState(null); - - useEffect(() => { - let alive = true; - fetchProviders() - .then((all) => { - if (!alive) return; - const usable = all.filter((p) => p.enabled && p.role === 'REVIEWER'); - setProviders(usable); - if (initial) { - const owner = ownerOf(initial); - const match = usable.find((p) => p.type === initial.providerType && p.workspace === owner); - setProviderId(match?.id ?? ''); - } else if (usable.length > 0) { - setProviderId(usable[0].id); - } - }) - .catch((err) => alive && setLoadError(err instanceof Error ? err.message : String(err))) - .finally(() => alive && setProvidersLoaded(true)); - return () => { - alive = false; - }; - }, [initial]); - - return { providers, providersLoaded, providerId, setProviderId, loadError }; -} - -function WebhookRepoFormModal({ - initial, - onClose, - onSaved, -}: { - initial: WebhookRepoView | null; - onClose: () => void; - onSaved: () => void; +/** Legacy registration repair preserves its existing key, ciphertext and product kind. */ +function WebhookRepoFormModal({ initial, onClose, onSaved }: { + initial: WebhookRepoView | null; onClose: () => void; onSaved: () => void; }) { - const editing = initial !== null; - - const { providers, providersLoaded, providerId, setProviderId, loadError } = useWebhookProviders(initial); - const [scope, setScope] = useState(initial?.scope ?? 'repo'); - const [slug, setSlug] = useState(() => { - if (initial && initial.scope === 'repo') { - const i = initial.target.indexOf('/'); - return i >= 0 ? initial.target.slice(i + 1) : ''; - } - return ''; - }); + const [origin, setOrigin] = useState(initial?.forgeOrigin ?? ''); + const [revealed, setRevealed] = useState(null); const [enabled, setEnabled] = useState(initial?.enabled ?? true); - const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [revealed, setRevealed] = useState(null); - - const [verify, setVerify] = useState<{ state: 'idle' | 'checking' | 'ok' | 'fail'; detail?: string }>({ - state: 'idle', - }); - const verifyReq = useRef(0); - - // A changed provider / scope / slug invalidates any prior/in-flight verify result. - useEffect(() => { - verifyReq.current += 1; - setVerify({ state: 'idle' }); - }, [providerId, scope, slug]); - - const selectedProvider = providers.find((p) => p.id === providerId) ?? null; - // On edit, if the provider was deleted we can't derive the owner — fall back to the stored row (read-only). - const legacyEdit = editing && providersLoaded && !selectedProvider; - const owner = selectedProvider?.workspace ?? (legacyEdit ? initial!.target.split('/')[0] : ''); - const providerType = selectedProvider?.type ?? initial?.providerType ?? ''; - const target = legacyEdit ? initial!.target : scope === 'org' ? owner : `${owner}/${slug.trim()}`; - const targetHelp = webhookTargetHelp(providerType, scope); - const validSlug = /^[^/\s]+$/.test(slug.trim()); - const valid = legacyEdit ? true : selectedProvider != null && (scope === 'org' ? owner.length > 0 : validSlug); - const noProviders = providersLoaded && providers.length === 0 && !legacyEdit; - - async function submit(e: React.FormEvent) { - e.preventDefault(); - if (!valid) { - setError(selectedProvider == null ? 'Select a provider first.' : 'Repository must be a single name (no slash).'); - return; - } - const input: WebhookRepoInput = { providerType, scope, target, enabled }; - setBusy(true); - setError(null); - try { - if (editing && initial) { - await updateWebhookRepo(initial.id, input); - onSaved(); - } else { - setRevealed(await createWebhookRepo(input)); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - } - - async function rotate() { + async function save(event: React.FormEvent) { + event.preventDefault(); if (!initial) return; - setBusy(true); - setError(null); + setBusy(true); setError(null); try { - setRevealed(await rotateWebhookSecret(initial.id)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } + await updateWebhookRepo(initial.id, { providerType: initial.providerType, scope: initial.scope, + target: initial.target, enabled, forgeOrigin: origin.trim() || null }); + onSaved(); + } catch (failure) { setError(String(failure)); } + finally { setBusy(false); } } - - async function onVerify() { - if (!selectedProvider) return; - const reqId = ++verifyReq.current; - setVerify({ state: 'checking' }); - try { - const result: RepoCheck = await verifyRepo(selectedProvider.id, target); - if (reqId !== verifyReq.current) return; // input changed while in flight — drop the stale result - setVerify(result.ok ? { state: 'ok' } : { state: 'fail', detail: result.detail ?? 'Not reachable' }); - } catch (err) { - if (reqId !== verifyReq.current) return; - setVerify({ state: 'fail', detail: err instanceof Error ? err.message : String(err) }); - } - } - - if (revealed) { - return ; + async function rotate() { + if (!initial || !window.confirm('Rotate this secret? Update the forge webhook afterward.')) return; + setBusy(true); setError(null); + try { setRevealed(await rotateWebhookSecret(initial.id)); } + catch (failure) { setError(String(failure)); } + finally { setBusy(false); } } - - return ( -
-
e.stopPropagation()} role="dialog" aria-modal="true"> -
-

{editing ? 'Edit webhook' : 'Add webhook'}

- -
-
- {noProviders ? ( -
- Register a reviewer account first under Settings → Accounts, then add a webhook for one of its repositories. -
- ) : ( - <> -
- -
- - {scope === 'repo' ? ( - - ) : ( - - )} - - {editing && initial && ( -
- Webhook secret -
-
Stored — write-only
- -
- - The secret is never shown after creation. Rotate to mint a new one — paste it into the provider’s - webhook settings (the old value stops working). - -
- )} - - {editing && initial && ( - - )} - - - - )} - - {(error ?? loadError) &&
{error ?? loadError}
} - -
- - -
- -
-
- ); + if (revealed) return ; + return
+
+

{initial ? 'Edit existing webhook' : 'Register a repository'}

+ {initial ? <> +

{initial.providerType} · {initial.scope} · {initial.target}

+

Existing organization hooks accept events only for explicitly registered repositories.

+ + +

Saving preserves the existing URL, secret and event kind.

+ + + :

Register a repository, then choose its webhook kinds.

} + {error &&

{error}

} + + +
; } -/** Provider-specific "what to do on the portal" steps, shown under the freshly revealed URL + secret. */ function WebhookSetupChecklist({ providerType }: { providerType: string }) { const guide = webhookSetupGuide(providerType); if (!guide) { diff --git a/spire-ui/src/components/SidePanel.tsx b/spire-ui/src/components/SidePanel.tsx new file mode 100644 index 00000000..6f7b8762 --- /dev/null +++ b/spire-ui/src/components/SidePanel.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react'; +import { X } from 'lucide-react'; + +/** One tab in the panel's own tab bar. `count` renders beside the label when it is a number. */ +export interface PanelTab { + id: string; + label: string; + count?: number; +} + +interface Props { + title: string; + /** Read-only context under the title — coordinates, origin, scope. Never a control. */ + subtitle?: ReactNode; + /** Locks every control inside while a save is in flight, so a second click cannot submit twice. */ + busy: boolean; + onClose: () => void; + /** Omit for a single-section panel. Two or more sections earn a tab bar. */ + tabs?: PanelTab[]; + tab?: string; + onTab?: (id: string) => void; + /** For a panel that carries a table of its own, which a form-width column would squeeze. */ + wide?: boolean; + /** The footer controls. Put the primary action first. */ + actions: ReactNode; + children: ReactNode; +} + +/** + * The shared side panel for reading one row and for changing it. A settings screen lists what + * exists at full width; the panel slides in over the right-hand side, so a detail view never + * renders below the list where it cannot be found, and a mutation form never renders inline + * beside the list it changes. + * + *

The form is a `fieldset` on purpose: `disabled` on it locks every descendant control for the + * duration of a save, which a `div` cannot do. The tab bar sits inside it for the same reason — + * switching section mid-save would show controls that look editable and are not. `form-lock` only + * removes the fieldset's native border and legend spacing; the element is kept for what it does, + * not for how it looks. + */ +export default function SidePanel({ title, subtitle, busy, onClose, tabs, tab, onTab, wide, actions, children }: Props) { + return ( +

+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}> +
+
+

{title}

+ {subtitle &&
{subtitle}
} +
+ {/* Outside the fieldset, so the save lock is repeated: closing mid-save leaves an answer nobody sees land. */} + +
+
+ {tabs && tabs.length > 1 && ( +
+ {tabs.map(entry => ( + + ))} +
+ )} +
{children}
+
+
{actions}
+
+
+ ); +} diff --git a/spire-ui/src/components/actorsApi.ts b/spire-ui/src/components/actorsApi.ts new file mode 100644 index 00000000..88311509 --- /dev/null +++ b/spire-ui/src/components/actorsApi.ts @@ -0,0 +1,40 @@ +import { apiFetch } from '../auth'; + +export interface Actor { + providerUserId: string; + handle: string | null; + displayName: string | null; +} +export interface ActorDisplay extends Actor { + resolvedAt: string | null; + stale: boolean; + effect: 'ALLOW' | 'DENY'; + revision: number; +} +export interface ActorPolicy { revision: number; actors: ActorDisplay[]; } +export interface ActorResult { status: 'FOUND' | 'SELECTION_REQUIRED'; actors: Actor[]; detail: string | null; } +export interface ActorInput { handle: string; providerUserId?: string; repositoryId?: string; revision?: number; effect?: 'ALLOW' | 'DENY'; } + +async function request(path: string, method = 'GET', body?: ActorInput): Promise { + const response = await apiFetch(path, { method, ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}) }); + if (!response.ok) { + const text = await response.text(); + let detail = text; + try { detail = (JSON.parse(text) as { error?: string }).error ?? text; } catch { /* plain API error */ } + throw new Error(detail || 'Could not read or save people. Retry after checking the selected account.'); + } + return response.json(); +} +export function actorPath(accountId: string, repositoryId?: string): string { + return repositoryId ? `/api/repositories/${repositoryId}/fix-actors` : `/api/providers/${accountId}/actors`; +} +export async function fetchActors(path: string, refresh = false): Promise { + const result = await request(`${path}?refresh=${refresh}`); + return Array.isArray(result) ? { revision: 0, actors: result } : result; +} +export const resolveActor = (path: string, input: ActorInput) => request(`${path}/resolve`, 'POST', input); +export const saveActor = (path: string, input: ActorInput) => request(path, 'POST', input); +export const deleteActor = (path: string, actor: ActorDisplay, revision: number) => request(`${path}/${encodeURIComponent(actor.providerUserId)}?revision=${revision}`, 'DELETE'); +export function actorLabel(actor: Actor): string { + return actor.handle ? `@${actor.handle}` : actor.displayName || `Unresolved identity (${actor.providerUserId})`; +} diff --git a/spire-ui/src/components/repositories/RepositoryAccountsCell.test.tsx b/spire-ui/src/components/repositories/RepositoryAccountsCell.test.tsx new file mode 100644 index 00000000..d676cb7a --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryAccountsCell.test.tsx @@ -0,0 +1,16 @@ +import { expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import RepositoryAccountsCell from './RepositoryAccountsCell'; +import type { Repository } from './repositoriesApi'; + +function show(state: string | null) { + const reviewer = state === null ? null : { id: 'TEST-account', name: 'TEST-reviewer', role: 'REVIEWER', handle: 'TEST-bot', state }; + render(); + return screen.getByText(/^Reviewer:/); +} +it('shows a configured account as usable', () => { expect(show('configured')).toHaveClass('completed'); }); +it('shows a disabled account as disabled', () => { expect(show('disabled')).toHaveClass('cancelled'); }); +it('shows an absent account as missing', () => { + const cell = show(null); expect(cell).toHaveClass('cancelled'); expect(cell).toHaveTextContent('No account selected'); +}); +it('never shows an unknown serving state as usable', () => { expect(show('TEST-unknown')).toHaveClass('refused'); }); diff --git a/spire-ui/src/components/repositories/RepositoryAccountsCell.tsx b/spire-ui/src/components/repositories/RepositoryAccountsCell.tsx new file mode 100644 index 00000000..e5de4da9 --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryAccountsCell.tsx @@ -0,0 +1,17 @@ +import type { Repository, RepositoryAccount } from './repositoriesApi'; + +function Account({ role, account }: { role: string; account: RepositoryAccount | null }) { + const tone = !account || account.state === 'disabled' ? 'cancelled' + : ['configured', 'ok'].includes(account.state) ? 'completed' : 'refused'; + const label = account ? `${account.name}${account.handle ? ` (@${account.handle})` : ''} · ${account.state}` : 'No account selected'; + return
+ {role}: {label} +
; +} + +export default function RepositoryAccountsCell({ repository }: { repository: Repository }) { + return
+ + +
; +} diff --git a/spire-ui/src/components/repositories/RepositoryDetail.test.tsx b/spire-ui/src/components/repositories/RepositoryDetail.test.tsx new file mode 100644 index 00000000..29b2960b --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryDetail.test.tsx @@ -0,0 +1,98 @@ +vi.mock('../actorsApi', async importOriginal => ({ ...await importOriginal(), fetchActors: vi.fn().mockResolvedValue({ revision: 0, actors: [] }) })); +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'; +import * as api from '../../api'; +import RepositoryDetail from './RepositoryDetail'; +import type { Repository } from './repositoriesApi'; +import type { WebhookRepoView, WebhookEventKind } from '../../api'; + +const repository: Repository = { + id: 'TEST-repository', scmType: 'gitlab', forgeOrigin: 'https://forge.example.test', workspace: 'TEST-group/nested', + slug: 'service', enabled: true, revision: 1, + reviewer: { id: 'TEST-reviewer', name: 'Review account', role: 'REVIEWER', handle: 'review-bot', state: 'ok' }, + factory: { id: 'TEST-factory', name: 'Push account', role: 'FACTORY', handle: 'push-bot', state: 'disabled' }, +}; +const hooks: WebhookRepoView[] = (['REVIEWER', 'FACTORY', 'ISSUE'] as WebhookEventKind[]).map(kind => ({ + id: `TEST-${kind}`, providerType: 'gitlab', repositoryId: repository.id, eventKind: kind, + forgeOrigin: repository.forgeOrigin, scope: 'repo', target: 'TEST-group/nested/service', webhookKey: `TEST-key-${kind}`, + hasSecret: true, enabled: true, createdAt: '2026-09-13T00:00:00Z', +})); + +describe('RepositoryDetail', () => { + it('preserves the webhook path heading truncation and vertical account badges', () => { + render(); + expect(screen.getByRole('columnheader', { name: 'Webhook path' })).toBeInTheDocument(); + const path = screen.getByText('/webhooks/gitlab/TEST-key-REVIEWER'); + expect(path.closest('.wh-url')).not.toBeNull(); + expect(path).toHaveAttribute('title', '/webhooks/gitlab/TEST-key-REVIEWER'); + const accounts = screen.getByRole('region', { name: 'Selected accounts' }); + expect(accounts.querySelector('.serving-pair')).toContainElement(within(accounts).getByText('Reviewer: Review account (@review-bot) · ok')); + expect(accounts.querySelector('.serving-pair')).toContainElement(within(accounts).getByText('Factory: Push account (@push-bot) · disabled')); + }); + + it('links a known-origin legacy hook while preserving its key and secret', async () => { + const legacy = { ...hooks[0], repositoryId: null }; + const update = vi.spyOn(api, 'updateWebhookRepo').mockResolvedValue(hooks[0]); + const changed = vi.fn(); + render(); + expect(screen.queryByRole('button', { name: 'Create REVIEWER webhook' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Link existing REVIEWER webhook' })); + await waitFor(() => expect(changed).toHaveBeenCalledWith([hooks[0]])); + expect(update).toHaveBeenCalledWith(legacy.id, expect.objectContaining({ repositoryId: repository.id, forgeOrigin: repository.forgeOrigin, eventKind: 'REVIEWER' })); + expect(update.mock.calls[0][1]).not.toHaveProperty('secret'); + }); + + it('requires explicit origin repair before creating beside an unresolved legacy hook', async () => { + vi.spyOn(api, 'fetchWebhookRepos').mockResolvedValue([{ ...hooks[0], repositoryId: null, forgeOrigin: null }]); + const create = vi.spyOn(api, 'createWebhookRepo'); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Create REVIEWER webhook' })); + expect(await screen.findByRole('alert')).toHaveTextContent('forge origin confirmed'); + expect(create).not.toHaveBeenCalled(); + }); + it('recovers a lost webhook response without creating a duplicate', async () => { + const saved = hooks[0]; + vi.spyOn(api, 'fetchWebhookRepos').mockResolvedValueOnce([]).mockResolvedValueOnce([saved]); + const create = vi.spyOn(api, 'createWebhookRepo').mockRejectedValue(new Error('TEST-response lost')); + const rotate = vi.spyOn(api, 'rotateWebhookSecret'); + const changed = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Create REVIEWER webhook' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Repository remains saved'); + fireEvent.click(screen.getByRole('button', { name: 'Create REVIEWER webhook' })); + await waitFor(() => expect(changed).toHaveBeenCalledWith([saved])); + expect(create).toHaveBeenCalledTimes(1); + expect(rotate).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent('rotate it explicitly'); + }); + + it('verifies the full namespace with the selected reviewer and names a failure', async () => { + const verify = vi.spyOn(api, 'verifyRepo').mockResolvedValue({ ok: false, detail: 'TEST-token cannot see repository' }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Verify reviewer access' })); + expect(await screen.findByRole('status')).toHaveTextContent('TEST-token cannot see repository'); + expect(verify).toHaveBeenCalledWith('TEST-reviewer', 'TEST-group/nested/service'); + }); + + it('shows workspace selected accounts and one webhook per event kind', () => { + render(); + expect(within(screen.getByRole('region', { name: 'Workspace' })).getByText('TEST-group/nested')).toBeInTheDocument(); + const accounts = within(screen.getByRole('region', { name: 'Selected accounts' })); + expect(accounts.getByText('Reviewer: Review account (@review-bot) · ok')).toBeInTheDocument(); + expect(accounts.getByText('Factory: Push account (@push-bot) · disabled')).toBeInTheDocument(); + const webhookSection = within(screen.getByRole('region', { name: 'Webhooks' })); + for (const kind of ['REVIEWER', 'FACTORY', 'ISSUE']) { + const groups = webhookSection.getAllByRole('group', { name: `${kind} webhook` }); + expect(groups).toHaveLength(1); + expect(within(groups[0]).getByText(`/webhooks/gitlab/TEST-key-${kind}`)).toBeInTheDocument(); + } + }); + + it('shows missing role bindings and permits a repository with no hooks', () => { + render(); + expect(screen.getByText('Reviewer: No account selected')).toBeInTheDocument(); + expect(screen.getByText('Factory: No account selected')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create REVIEWER webhook' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Create FACTORY webhook' })).toBeEnabled(); + }); +}); diff --git a/spire-ui/src/components/repositories/RepositoryDetail.tsx b/spire-ui/src/components/repositories/RepositoryDetail.tsx new file mode 100644 index 00000000..2295529a --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryDetail.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { createWebhookRepo, fetchWebhookRepos, updateWebhookRepo, rotateWebhookSecret, verifyRepo, + type WebhookRepoView, type WebhookRepoSecret, type WebhookEventKind } from '../../api'; +import type { Repository } from './repositoriesApi'; +import ActorPicker from '../ActorPicker'; +import RepositoryAccountsCell from './RepositoryAccountsCell'; +import { CopyableValue } from '../../render'; +import WebhookSecretReveal from './WebhookSecretReveal'; +import { webhookPath } from '../SettingsWebhookRepos'; + +interface Props { + repository: Repository; + hooks: WebhookRepoView[]; + onHooksChanged: (hooks: WebhookRepoView[]) => void; +} +const kinds: WebhookEventKind[] = ['REVIEWER', 'FACTORY', 'ISSUE']; + +/** The body of the repository side panel. The panel supplies the name, coordinates and actions. */ +export default function RepositoryDetail({ repository, hooks, onHooksChanged }: Props) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [revealed, setRevealed] = useState(null); + const [verification, setVerification] = useState(null); + const isLegacyCandidate = (hook: WebhookRepoView, kind: WebhookEventKind) => !hook.repositoryId + && hook.providerType === repository.scmType && hook.scope === 'repo' && hook.eventKind === kind + && hook.target === `${repository.workspace}/${repository.slug}` + && (!hook.forgeOrigin || hook.forgeOrigin === repository.forgeOrigin); + + async function verify() { + if (!repository.reviewer) return; + setBusy(true); setVerification(null); + try { + const result = await verifyRepo(repository.reviewer.id, `${repository.workspace}/${repository.slug}`); + setVerification(result.ok ? 'Repository found with the selected reviewer account.' : result.detail ?? 'Repository access could not be verified.'); + } catch (failure) { setVerification(String(failure)); } + finally { setBusy(false); } + } + + async function create(kind: WebhookEventKind) { + setBusy(true); setError(null); + try { + // A previous response may have been lost after the save committed. Read before retrying. + const current = await fetchWebhookRepos(); + const existing = current.find(h => h.repositoryId === repository.id && h.eventKind === kind); + const legacy = current.find(h => isLegacyCandidate(h, kind)); + if (existing) { + onHooksChanged(current); + setError('This webhook is already saved. If its secret was not copied, rotate it explicitly.'); + } else if (legacy) { + onHooksChanged(current); + setError('An existing legacy webhook needs its forge origin confirmed and repository linked. Repair it before creating another webhook.'); + } else { + const result = await createWebhookRepo({ providerType: repository.scmType, forgeOrigin: repository.forgeOrigin, + repositoryId: repository.id, eventKind: kind, scope: 'repo', target: `${repository.workspace}/${repository.slug}`, enabled: true }); + onHooksChanged([...current, result.repo]); setRevealed(result); + } + } catch (failure) { setError(`Repository remains saved. Webhook could not be created: ${String(failure)}. Retry when ready.`); } + finally { setBusy(false); } + } + + async function link(hook: WebhookRepoView) { + setBusy(true); setError(null); + try { + const changed = await updateWebhookRepo(hook.id, { providerType: hook.providerType, scope: hook.scope, + target: hook.target, enabled: hook.enabled, repositoryId: repository.id, forgeOrigin: repository.forgeOrigin, + eventKind: hook.eventKind }); + onHooksChanged(hooks.map(h => h.id === changed.id ? changed : h)); + } catch (failure) { setError(String(failure)); } + finally { setBusy(false); } + } + + async function toggle(hook: WebhookRepoView) { + setBusy(true); setError(null); + try { + const changed = await updateWebhookRepo(hook.id, { providerType: hook.providerType, scope: hook.scope, + target: hook.target, enabled: !hook.enabled }); + onHooksChanged(hooks.map(h => h.id === changed.id ? changed : h)); + } catch (failure) { setError(String(failure)); } + finally { setBusy(false); } + } + + async function rotate(hook: WebhookRepoView) { + if (!window.confirm('Rotate this secret? Update the forge webhook with the new secret afterward.')) return; + setBusy(true); setError(null); + try { setRevealed(await rotateWebhookSecret(hook.id)); } + catch (failure) { setError(String(failure)); } + finally { setBusy(false); } + } + + return
+

Workspace

{repository.workspace}

{repository.scmType} · {repository.forgeOrigin}

+

Selected accounts

+ + {repository.reviewer &&
} + {verification &&

{verification}

} +
+

Webhooks

+

Hooks are optional. A registered repository can be used for manual reviews and runs.

+
+ + {kinds.map(kind => { + const hook = hooks.find(h => h.repositoryId === repository.id && h.eventKind === kind) + ?? hooks.find(h => isLegacyCandidate(h, kind) && h.forgeOrigin === repository.forgeOrigin); + return + + + + + ; + })} +
KindWebhook pathStateActions
{kind}{hook ?
+ : {kind === 'ISSUE' ? 'Turn on instant updates in the Factory tab.' : 'Not configured'}}
{hook &&
{hook.enabled ? 'Enabled' : 'Disabled'}
}
{hook ? <> + {!hook.repositoryId && } + + + : kind !== 'ISSUE' && }
+ +
{repository.reviewer ? + :

Select a reviewer account to edit fix overrides.

}
+ {error &&

{error}

} + {revealed && setRevealed(null)} />} + ; +} diff --git a/spire-ui/src/components/repositories/RepositoryForm.tsx b/spire-ui/src/components/repositories/RepositoryForm.tsx new file mode 100644 index 00000000..a054c161 --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryForm.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import type { ProviderView } from '../../api'; +import { saveRepository, type Repository, type RepositoryInput } from './repositoriesApi'; +import { accountOptionLabel } from '../accounts'; +import SettingField from '../SettingField'; +import SidePanel from '../SidePanel'; + +interface Props { + initial: Repository | null; + providers: ProviderView[]; + kinds: string[]; + onSaved: (repository: Repository) => void; + onCancel: () => void; + prefill?: URLSearchParams; +} +function origin(value: string): string { + try { return new URL(value).origin; } catch { return ''; } +} + +/** Coordinates identify the repository; account choices never silently follow a workspace match. */ +export default function RepositoryForm({ initial, providers, kinds, onSaved, onCancel, prefill = new URLSearchParams() }: Props) { + const [fields, setFields] = useState({ + scmType: initial?.scmType ?? prefill.get('scmType') ?? '', forgeOrigin: initial?.forgeOrigin ?? prefill.get('forgeOrigin') ?? '', + workspace: initial?.workspace ?? prefill.get('workspace') ?? '', slug: initial?.slug ?? prefill.get('slug') ?? '', enabled: initial?.enabled ?? true, + reviewerAccountId: initial?.reviewer?.id ?? null, factoryAccountId: initial?.factory?.id ?? null, + }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const patch = (next: Partial) => setFields(previous => ({ ...previous, ...next })); + const compatible = providers.filter(p => p.type === fields.scmType && origin(p.baseUrl) === origin(fields.forgeOrigin)); + + async function submit() { + setBusy(true); setError(null); + try { onSaved(await saveRepository(fields, initial)); } + catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); } + finally { setBusy(false); } + } + const title = initial ? 'Repository details' : 'Register repository'; + // Coordinates are the repository's identity, so an existing one shows them and does not offer + // to change them — a different origin or slug is a different repository. + const settled = !!initial; + return ( + + + }> + {!initial && prefill.get('registration') &&

Incoming registration: {prefill.get('registration')}

} + + + + patch({ forgeOrigin: e.target.value, reviewerAccountId: null, factoryAccountId: null })} /> + + patch({ workspace: e.target.value })} /> + + patch({ slug: e.target.value })} /> + {(['REVIEWER', 'FACTORY'] as const).map(role => { + const key = role === 'REVIEWER' ? 'reviewerAccountId' : 'factoryAccountId'; + const label = role === 'REVIEWER' ? 'Reviewer account' : 'Factory account'; + return + ; + })} + {fields.scmType && fields.forgeOrigin && compatible.length === 0 + &&

No accounts on {origin(fields.forgeOrigin) || 'this origin'}. Add an account to select one.

} + + {error &&

{error}

} +
+ ); +} diff --git a/spire-ui/src/components/repositories/RepositoryPending.tsx b/spire-ui/src/components/repositories/RepositoryPending.tsx new file mode 100644 index 00000000..18e9ea5a --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryPending.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import { fetchPendingMappings, linkMapping, type PendingMapping, type Repository } from './repositoriesApi'; +import { fetchWebhookRepos, updateWebhookRepo, type WebhookRepoView } from '../../api'; +import { AlertTriangle } from 'lucide-react'; + +export default function RepositoryPending({ repositories, onHooksChanged }: { + repositories: Repository[]; onHooksChanged: (hooks: WebhookRepoView[]) => void; +}) { + const [pending, setPending] = useState([]); + const [error, setError] = useState(null); + useEffect(() => { + let active = true; + fetchPendingMappings().then(rows => { if (active) setPending(rows); }) + .catch(failure => { if (active) setError(String(failure)); }); + return () => { active = false; }; + }, []); + async function link(row: PendingMapping, repository: Repository) { + try { + // Repair the owning registration so future verified deliveries carry the selected origin. + // Re-read on every retry; a lost response must not lead to a second hook or a secret rotation. + const hooks = await fetchWebhookRepos(); + const hook = hooks.find(candidate => candidate.id === row.registrationId); + if (hook) { + if (hook.providerType !== repository.scmType || hook.scope !== 'repo' + || hook.target !== `${repository.workspace}/${repository.slug}` + || (hook.forgeOrigin && hook.forgeOrigin !== repository.forgeOrigin)) { + throw new Error('Registration changed. Reload before linking it to a repository.'); + } + const changed = await updateWebhookRepo(hook.id, { providerType: hook.providerType, scope: hook.scope, + target: hook.target, enabled: hook.enabled, eventKind: hook.eventKind, + repositoryId: repository.id, forgeOrigin: repository.forgeOrigin }); + onHooksChanged(hooks.map(candidate => candidate.id === changed.id ? changed : candidate)); + } else { + await linkMapping(row, repository.id); + } + setPending(previous => previous.filter(p => p.registrationId !== row.registrationId)); + } + catch (failure) { setError(String(failure)); } + } + return
+ {error &&

{error}

} + {pending.length > 0 && <>

Mappings needing attention

+
+
+ {pending.map(row => + + + + )} +
RepositoryNeeds attentionLink repository
{row.target}
{row.scmType}
{row.forgeOrigin ?? 'Forge origin unresolved'}
{row.problem.split('_').join(' ')}
{repositories.filter(repo => repo.scmType === row.scmType && `${repo.workspace}/${repo.slug}` === row.target + && (!row.forgeOrigin || repo.forgeOrigin === row.forgeOrigin)).map(repo =>
+ +
{repo.forgeOrigin}
+
)}
+ } +
; +} diff --git a/spire-ui/src/components/repositories/RepositoryRegistryPage.test.tsx b/spire-ui/src/components/repositories/RepositoryRegistryPage.test.tsx new file mode 100644 index 00000000..0117cde2 --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryRegistryPage.test.tsx @@ -0,0 +1,174 @@ +vi.mock('../actorsApi', async importOriginal => ({ ...await importOriginal(), fetchActors: vi.fn().mockResolvedValue({ revision: 0, actors: [] }) })); +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import * as accounts from '../../api'; +import * as api from './repositoriesApi'; +import RepositoryRegistryPage from './RepositoryRegistryPage'; +import { MemoryRouter, Link } from 'react-router'; + +const provider = (id: string, role: accounts.ProviderRole, baseUrl = 'https://forge.example.test/api/v4'): accounts.ProviderView => ({ + id, name: id, role, baseUrl, type: 'gitlab', enabled: true, + authKind: 'bearer', authUsername: null, hasSecret: true, botAccountId: `TEST-id-${id}`, botUsername: id, + authors: [], conversationLevel: null, createdAt: '', lastCheckAt: null, lastCheckOk: null, lastCheckError: null, +}); +const repo: api.Repository = { + id: 'TEST-repo-id', scmType: 'gitlab', forgeOrigin: 'https://forge.example.test', workspace: 'TEST-group/nested', + slug: 'TEST-repo', enabled: true, revision: 3, + reviewer: { id: 'TEST-reviewer', name: 'TEST-reviewer', role: 'REVIEWER', handle: 'TEST-review-bot', state: 'configured' }, + factory: { id: 'TEST-factory', name: 'TEST-factory', role: 'FACTORY', handle: null, state: 'disabled' }, +}; + +describe('Repository registry', () => { + it('uses a text link and shared account column and keeps repair labels concise', async () => { + vi.mocked(api.fetchPendingMappings).mockResolvedValue([{ registrationId: 'TEST-registration', revision: 8, scmType: 'gitlab', + forgeOrigin: null, target: `${repo.workspace}/${repo.slug}`, problem: 'registration_origin_unknown' }]); + render(); + const name = await screen.findByRole('link', { name: repo.slug }); + expect(name).toHaveClass('mono', 'nowrap'); + expect(name).not.toHaveClass('btn'); + expect(screen.getByRole('columnheader', { name: 'Accounts' })).toBeInTheDocument(); + expect(name.closest('tr')?.querySelector('.serving-pair')).not.toBeNull(); + const pending = screen.getByRole('region', { name: 'Mappings needing attention' }); + expect(await within(pending).findByRole('button', { name: 'Link repository' })).toHaveTextContent(/^Link repository$/); + expect(pending).not.toHaveTextContent('TEST-registration'); + }); + + it('prefills registration from Attention while the repository page is already mounted', async () => { + const query = new URLSearchParams({ register: 'true', scmType: repo.scmType, forgeOrigin: repo.forgeOrigin, + workspace: repo.workspace, slug: 'TEST-new', registration: 'TEST-registration' }); + render( + TEST Attention Register + ); + expect(await screen.findByRole('link', { name: 'TEST-repo' })).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Workspace' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('link', { name: 'TEST Attention Register' })); + expect(await screen.findByRole('textbox', { name: 'Workspace' })).toHaveValue(repo.workspace); + expect(screen.getByRole('textbox', { name: 'Forge origin' })).toHaveValue(repo.forgeOrigin); + expect(screen.getByRole('textbox', { name: 'Repository slug' })).toHaveValue('TEST-new'); + // The id is carried in its own `mono` element, so the sentence spans two nodes. + expect(screen.getByText('TEST-registration')).toHaveClass('mono'); + expect(screen.getByText(/Incoming registration:/)).toHaveTextContent('Incoming registration: TEST-registration'); + }); + it('keeps repository registration available through a gateway outage and retries hooks', async () => { + vi.mocked(accounts.fetchWebhookRepos).mockRejectedValueOnce(new Error('TEST-gateway down')).mockResolvedValue([]); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent('TEST-gateway down'); + fireEvent.click(await screen.findByRole('button', { name: 'Register repository' })); + expect(screen.getByRole('textbox', { name: 'Workspace' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry loading webhooks' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + }); + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(accounts, 'fetchWebhookRepos').mockResolvedValue([]); + vi.spyOn(accounts, 'fetchProviders').mockResolvedValue([provider('TEST-reviewer', 'REVIEWER'), provider('TEST-factory', 'FACTORY'), + provider('TEST-wrong-host', 'REVIEWER', 'https://other.example.test'), provider('TEST-context', 'CONTEXT')]); + vi.spyOn(api, 'fetchRepositories').mockResolvedValue([repo]); + vi.spyOn(api, 'fetchRepositoryKinds').mockResolvedValue(['gitlab']); + vi.spyOn(api, 'fetchPendingMappings').mockResolvedValue([]); + vi.spyOn(api, 'saveRepository').mockResolvedValue(repo); + }); + + it('shows workspace and the selected reviewer and disabled factory', async () => { + render(); + expect(await screen.findByText('TEST-group/nested')).toBeInTheDocument(); + expect(screen.getByText('Reviewer: TEST-reviewer (@TEST-review-bot) · configured')).toBeInTheDocument(); + expect(screen.getByText('Factory: TEST-factory · disabled')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('link', { name: 'TEST-repo' })); + fireEvent.click(screen.getByRole('button', { name: 'Edit repository and accounts' })); + expect(screen.getByRole('textbox', { name: 'Workspace' })).toHaveValue('TEST-group/nested'); + expect(screen.getByRole('combobox', { name: 'REVIEWER account' })).toHaveValue('TEST-reviewer'); + expect(screen.getByRole('combobox', { name: 'FACTORY account' })).toHaveValue('TEST-factory'); + }); + + it('offers only same-origin accounts of the chosen role and saves explicit ids', async () => { + render(); + fireEvent.click(await screen.findByRole('link', { name: 'TEST-repo' })); + fireEvent.click(screen.getByRole('button', { name: 'Edit repository and accounts' })); + const reviewer = screen.getByRole('combobox', { name: 'REVIEWER account' }); + expect(within(reviewer).queryByRole('option', { name: 'TEST-wrong-host' })).not.toBeInTheDocument(); + expect(within(reviewer).queryByRole('option', { name: 'TEST-factory' })).not.toBeInTheDocument(); + expect(within(reviewer).queryByRole('option', { name: 'TEST-context' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save repository' })); + await waitFor(() => expect(api.saveRepository).toHaveBeenCalledWith(expect.objectContaining({ + workspace: 'TEST-group/nested', reviewerAccountId: 'TEST-reviewer', factoryAccountId: 'TEST-factory', + }), repo)); + }); + + it('keeps a stale-save error visible without pretending it saved', async () => { + vi.mocked(api.saveRepository).mockRejectedValue(new Error('Repository changed; reload before saving')); + render(); + fireEvent.click(await screen.findByRole('link', { name: 'TEST-repo' })); + fireEvent.click(screen.getByRole('button', { name: 'Edit repository and accounts' })); + fireEvent.click(screen.getByRole('button', { name: 'Save repository' })); + expect(await screen.findByRole('alert')).toHaveTextContent('reload before saving'); + expect(screen.getByRole('heading', { name: 'Repository details' })).toBeInTheDocument(); + }); + + it('can register an unbound repository before accounts exist', async () => { + vi.mocked(api.fetchRepositories).mockResolvedValue([]); vi.mocked(accounts.fetchProviders).mockResolvedValue([]); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Register repository' })); + fireEvent.change(screen.getByRole('combobox', { name: 'Forge kind' }), { target: { value: 'gitlab' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Forge origin' }), { target: { value: repo.forgeOrigin } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Workspace' }), { target: { value: repo.workspace } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Repository slug' }), { target: { value: repo.slug } }); + fireEvent.click(screen.getByRole('button', { name: 'Save repository' })); + await waitFor(() => expect(api.saveRepository).toHaveBeenCalledWith(expect.objectContaining({ reviewerAccountId: null, factoryAccountId: null }), null)); + }); + + it('names the pending registration and links only a matching repository', async () => { + const pending: api.PendingMapping = { registrationId: 'TEST-registration', revision: 8, scmType: 'gitlab', forgeOrigin: null, + target: 'TEST-group/nested/TEST-repo', problem: 'conflicting_forge_origins' }; + vi.mocked(api.fetchPendingMappings).mockResolvedValue([pending]); + vi.spyOn(api, 'linkMapping').mockResolvedValue(undefined); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Link repository' })); + await waitFor(() => expect(api.linkMapping).toHaveBeenCalledWith(pending, repo.id)); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Link repository' })).not.toBeInTheDocument()); + }); + + it('repairs a gateway registration at its owner so new deliveries carry the selected origin', async () => { + const pending: api.PendingMapping = { registrationId: 'TEST-registration', revision: 8, scmType: 'gitlab', + forgeOrigin: null, target: `${repo.workspace}/${repo.slug}`, problem: 'registration_origin_unknown' }; + const hook: accounts.WebhookRepoView = { id: pending.registrationId, providerType: 'gitlab', + scope: 'repo', target: pending.target, webhookKey: 'TEST-retained-key', hasSecret: true, enabled: true, + forgeOrigin: null, repositoryId: null, eventKind: 'REVIEWER', createdAt: '' }; + vi.mocked(api.fetchPendingMappings).mockResolvedValue([pending]); + vi.mocked(accounts.fetchWebhookRepos).mockResolvedValue([hook]); + const historyLink = vi.spyOn(api, 'linkMapping'); + const update = vi.spyOn(accounts, 'updateWebhookRepo').mockResolvedValue({ ...hook, repositoryId: repo.id, forgeOrigin: repo.forgeOrigin }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Link repository' })); + await waitFor(() => expect(update).toHaveBeenCalledWith(hook.id, expect.objectContaining({ + repositoryId: repo.id, forgeOrigin: repo.forgeOrigin, eventKind: 'REVIEWER', target: pending.target, + }))); + expect(update.mock.calls[0][1]).not.toHaveProperty('secret'); + expect(historyLink).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('link', { name: repo.slug })); + expect(await screen.findByText('/webhooks/gitlab/TEST-retained-key')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Create REVIEWER webhook' })).not.toBeInTheDocument(); + }); + + it('refuses to repair a registration whose current forge or coordinates changed', async () => { + const pending: api.PendingMapping = { registrationId: 'TEST-registration', revision: 8, scmType: 'gitlab', + forgeOrigin: null, target: `${repo.workspace}/${repo.slug}`, problem: 'registration_origin_unknown' }; + const hook: accounts.WebhookRepoView = { id: pending.registrationId, providerType: 'gitlab', + scope: 'repo', target: pending.target, webhookKey: 'TEST-retained-key', hasSecret: true, enabled: true, + forgeOrigin: null, repositoryId: null, eventKind: 'REVIEWER', createdAt: '' }; + vi.mocked(api.fetchPendingMappings).mockResolvedValue([pending]); + const update = vi.spyOn(accounts, 'updateWebhookRepo'); + const historyLink = vi.spyOn(api, 'linkMapping'); + for (const changed of [{ providerType: 'github' }, { scope: 'org' as const }, + { target: 'TEST-other/TEST-repo' }, { forgeOrigin: 'https://other.example.test' }]) { + vi.mocked(accounts.fetchWebhookRepos).mockResolvedValue([{ ...hook, ...changed }]); + const page = render(); + fireEvent.click(await screen.findByRole('button', { name: 'Link repository' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Registration changed'); + expect(update).not.toHaveBeenCalled(); + expect(historyLink).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Link repository' })).toBeInTheDocument(); + page.unmount(); + } + }); +}); diff --git a/spire-ui/src/components/repositories/RepositoryRegistryPage.tsx b/spire-ui/src/components/repositories/RepositoryRegistryPage.tsx new file mode 100644 index 00000000..6b78196c --- /dev/null +++ b/spire-ui/src/components/repositories/RepositoryRegistryPage.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router'; +import { fetchProviders, fetchWebhookRepos, type ProviderView, type WebhookRepoView } from '../../api'; +import RepositoryDetail from './RepositoryDetail'; +import RepositoryForm from './RepositoryForm'; +import RepositoryPending from './RepositoryPending'; +import RepositoryAccountsCell from './RepositoryAccountsCell'; +import SidePanel from '../SidePanel'; +import { CopyableValue } from '../../render'; +import { GitBranch } from 'lucide-react'; +import { fetchRepositories, fetchRepositoryKinds, type Repository } from './repositoriesApi'; +import { fetchWorkSources, type WorkSource } from '../work-items/workSourcesApi'; +import { policy as fetchPolicy, type Policy } from '../work-items/workPolicyApi'; +import RepositoryFactory from './factory/RepositoryFactory'; +import FactoryProgress from './factory/FactoryProgress'; +import { readiness } from './factory/factoryModel'; + +/** Repository configuration remains usable when the separate webhook service is unavailable. */ +export default function RepositoryRegistryPage() { + const location = useLocation(); + const navigate = useNavigate(); + const [repositories, setRepositories] = useState([]); + const [providers, setProviders] = useState([]); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [kinds, setKinds] = useState([]); + const [webhooks, setWebhooks] = useState<{ rows: WebhookRepoView[]; error: string | null }>({ rows: [], error: null }); + const { rows: hooks, error: hookError } = webhooks; + const setHooks = (rows: WebhookRepoView[]) => setWebhooks({ rows, error: null }); + const [selected, setSelected] = useState(null); + const [tab, setTab] = useState('details'); + // The setup column is a summary; when it cannot load, the repositories and their panels still work. + const [setup, setSetup] = useState<{ sources: WorkSource[]; policies: Record } | null>(null); + const [setupRefresh, setSetupRefresh] = useState(0); + + useEffect(() => { + let active = true; + fetchWebhookRepos().then(webhooks => { if (active) setHooks(webhooks); }) + .catch(failure => { if (active) setWebhooks(previous => ({ ...previous, error: String(failure) })); }); + Promise.all([fetchRepositories(), fetchProviders(), fetchRepositoryKinds()]).then(([repos, accounts, supported]) => { + if (active) { + setRepositories(repos); setProviders(accounts); setKinds(supported); + } + }).catch(failure => { if (active) setError(String(failure)); }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, []); + + useEffect(() => { + let active = true; + Promise.all([fetchWorkSources(), Promise.all(repositories.map(async repo => [repo.id, await fetchPolicy(repo.id)] as const))]) + .then(([sources, policies]) => { if (active) setSetup({ sources, policies: Object.fromEntries(policies) }); }) + .catch(() => { if (active) setSetup(null); }); + return () => { active = false; }; + }, [repositories, setupRefresh]); + + useEffect(() => { + const query = new URLSearchParams(location.search); + if (query.get('register') === 'true') setEditing('new'); + const legacy = query.get('edit'); + if (legacy) navigate(`/settings/webhooks?edit=${encodeURIComponent(legacy)}`, { replace: true }); + }, [location.search, navigate]); + + function saved(repository: Repository) { + setRepositories(previous => [...previous.filter(row => row.id !== repository.id), repository]); + setEditing(null); + setSelected(repository.id); + } + async function retryHooks() { + try { setHooks(await fetchWebhookRepos()); } + catch (failure) { setWebhooks(previous => ({ ...previous, error: String(failure) })); } + } + const chosen = repositories.find(repository => repository.id === selected); + return
+

Registered repositories

+ {repositories.length > 0 && } +
+

Repositories own their workspace and select the accounts used for reviews and runs.

+

Manage legacy and organization webhooks

+ {loading ?

Loading repositories…

: error ?

{error}

: <> + {hookError &&

Webhooks could not be loaded: {hookError}. Repository settings remain available.

+
} + {/* What is waiting for the operator goes above the list. Below it, it was not found. */} + + {repositories.length === 0 ?
+
+
No registered repositories yet.
+

Register a repository, then select its review and factory accounts and webhook kinds.

+ +
:
+ + {repositories.map(repository => + + + + + + )} +
RepositoryForge originWorkspaceAccountsFactory setupState
{ event.preventDefault(); setTab('details'); setSelected(repository.id); }}>{repository.slug} +
{repository.scmType}
{repository.workspace} source.repositoryId === repository.id), setup.policies[repository.id] ?? null) : null} />
{repository.enabled ? 'Enabled' : 'Disabled'}
} + {chosen && setSelected(null)} tabs={[{ id: 'details', label: 'Details' }, { id: 'factory', label: 'Factory' }]} + tab={tab} onTab={setTab} actions={<> + {tab === 'details' && } + }> + {tab === 'details' + ? + : setSetupRefresh(value => value + 1)} />} + } + {editing && setEditing(null)} />} + } +
; +} diff --git a/spire-ui/src/components/repositories/WebhookSecretReveal.tsx b/spire-ui/src/components/repositories/WebhookSecretReveal.tsx new file mode 100644 index 00000000..566c77dd --- /dev/null +++ b/spire-ui/src/components/repositories/WebhookSecretReveal.tsx @@ -0,0 +1,25 @@ +import type { WebhookRepoSecret } from '../../api'; +import CopyField from '../CopyField'; +import { webhookPath } from '../SettingsWebhookRepos'; + +/** What the forge must send to each kind of hook. A hook subscribed to the wrong events stays silent. */ +const EVENTS: Record = { + ISSUE: 'In the forge webhook settings, subscribe to Issues and Issue comments.', +}; + +/** + * The one moment a webhook secret is visible. It is minted by the gateway and never returned again, + * so this stays centred and modal: dismissing it by accident means rotating the secret. + */ +export default function WebhookSecretReveal({ revealed, onDone }: { revealed: WebhookRepoSecret; onDone: () => void }) { + const events = revealed.repo.eventKind ? EVENTS[revealed.repo.eventKind] : undefined; + return
+

Webhook secret

+

Copy this secret now. It is shown once; store it in the forge webhook settings.

+ + + {events &&

{events}

} +
+
+
; +} diff --git a/spire-ui/src/components/repositories/factory/FactoryOutcome.tsx b/spire-ui/src/components/repositories/factory/FactoryOutcome.tsx new file mode 100644 index 00000000..ec35a8d1 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/FactoryOutcome.tsx @@ -0,0 +1,47 @@ +import { AlertTriangle, Check } from 'lucide-react'; +import { actorLabel } from '../../actorsApi'; +import type { Policy } from '../../work-items/workPolicyApi'; +import type { WorkSource } from '../../work-items/workSourcesApi'; +import PhaseStrip from '../../work-items/PhaseStrip'; +import { clamped, effectiveModes, type Readiness } from './factoryModel'; + +const trackers: Record = { GITHUB: 'GitHub', GITLAB: 'GitLab', JIRA: 'Jira' }; + +/** The first missing part decides the message: fixing a later one first would still admit nothing. */ +function blocker(ready: Readiness) { + if (!ready.source) return 'Nothing reads tickets for this repository yet. Add where tickets come from in step 1.'; + if (!ready.people) return 'Tickets are read, but nobody is allowed to start work, so every label is ignored. Add a person in step 2.'; + if (!ready.ceiling) return 'This repository has no ceiling, so every label is refused. Choose one in step 3.'; + return 'No label starts work yet. Map a label in step 4, or use the presets.'; +} + +const joined = (words: string[]) => words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`; + +/** + * What this setup does, in one sentence, above the parts that make it. Operators could configure every + * part and still not see how the four combine; the consequence is the thing they came to check. + */ +export default function FactoryOutcome({ ready, sources, policy }: { ready: Readiness; sources: WorkSource[]; policy: Policy | null }) { + if (!ready.ready || !policy) { + return
+ +
Not ready — no ticket can start work

{blocker(ready)}

+
; + } + const reading = sources.filter(source => source.enabled && source.allowedPeople.length > 0); + const people = [...new Map(reading.flatMap(source => source.allowedPeople).map(person => [person.providerUserId, person])).values()]; + const places = reading.map(source => `${trackers[source.type]} ${source.type === 'JIRA' ? 'project' : 'issues in'} ${source.scope}`); + return
+ +
+
Ready — tickets can start work
+

When {joined(people.map(actorLabel))} adds one of these labels to an open ticket in {joined(places)}, the factory + picks it up and runs the profile the label names — never more than the ceiling {policy.ceiling!.name} v{policy.ceiling!.version}.

+
    {Object.entries(policy.mappings).map(([label, profile]) =>
  • + {label} + {profile.name} v{profile.version} + {clamped(profile, policy.ceiling) && cut back to the ceiling} +
  • )}
+
+
; +} diff --git a/spire-ui/src/components/repositories/factory/FactoryProgress.tsx b/spire-ui/src/components/repositories/factory/FactoryProgress.tsx new file mode 100644 index 00000000..94118ed9 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/FactoryProgress.tsx @@ -0,0 +1,18 @@ +import type { Readiness } from './factoryModel'; + +const parts: [keyof Readiness, string][] = [['source', 'source'], ['people', 'people'], ['ceiling', 'ceiling'], ['labels', 'labels']]; + +/** + * The list's answer to "can this repository take work?" before anything is opened. Four bars in the + * order of the Factory tab's steps, and one word, so the bars are never the only carrier of meaning. + */ +export default function FactoryProgress({ ready }: { ready: Readiness | null }) { + if (!ready) return —; + const missing = parts.filter(([part]) => !ready[part]).map(([, name]) => name); + const text = ready.ready ? 'Ready' : ready.done === 0 ? 'Not set up' : `${ready.done} of 4 · no ${missing[0]}`; + return + + {ready.ready ? Ready : text} + ; +} diff --git a/spire-ui/src/components/repositories/factory/FactoryStep.tsx b/spire-ui/src/components/repositories/factory/FactoryStep.tsx new file mode 100644 index 00000000..c3025f83 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/FactoryStep.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react'; + +export type StepState = 'done' | 'missing' | 'editing'; + +interface Props { + number: number; + /** What the part is for, in the operator's words — the heading a reader scans. */ + question: string; + /** What the rest of the product calls it, so the vocabulary still connects to docs and logs. */ + term: string; + state: StepState; + status?: ReactNode; + actions?: ReactNode; + children?: ReactNode; +} + +/** + * One part of a repository's factory setup. The number is real order, not decoration: each part only + * does something once the one before it is set — no source reads no labels, nobody allowed makes every + * label ignored, and no ceiling refuses every label mapping. + */ +export default function FactoryStep({ number, question, term, state, status, actions, children }: Props) { + return
  • + +
    +
    + {question}{term} + {status}{actions} +
    + {children &&
    {children}
    } +
    +
  • ; +} diff --git a/spire-ui/src/components/repositories/factory/InstantUpdates.tsx b/spire-ui/src/components/repositories/factory/InstantUpdates.tsx new file mode 100644 index 00000000..b4bca51f --- /dev/null +++ b/spire-ui/src/components/repositories/factory/InstantUpdates.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { createWebhookRepo, fetchWebhookRepos, type WebhookRepoSecret, type WebhookRepoView } from '../../../api'; +import type { Repository } from '../repositoriesApi'; +import type { WorkSource } from '../../work-items/workSourcesApi'; +import { webhookPath } from '../../SettingsWebhookRepos'; +import WebhookSecretReveal from '../WebhookSecretReveal'; + +interface Props { + repository: Repository; + source: WorkSource; + hooks: WebhookRepoView[]; + /** The gateway owns webhooks and may be down while the orchestrator is not. */ + hooksUnavailable: boolean; + onHooksChanged: (hooks: WebhookRepoView[]) => void; +} + +/** + * The issue webhook for one source. Scanning already finds every label within five minutes, and still + * catches what a webhook misses during downtime, so the webhook is the fast path and never the only one. + * The gateway refuses an issue webhook without its source, which is why this lives on the source. + */ +export default function InstantUpdates({ repository, source, hooks, hooksUnavailable, onHooksChanged }: Props) { + const [busy, setBusy] = useState(false), [error, setError] = useState(''), [revealed, setRevealed] = useState(null); + if (source.type === 'JIRA') return

    Jira is polled. Changes are seen within five minutes; it has no instant updates here.

    ; + const hook = hooks.find(value => value.repositoryId === repository.id && value.eventKind === 'ISSUE'); + + async function enable() { + setBusy(true); setError(''); + try { + // A previous response may have been lost after the save committed; a second create would be refused. + const current = await fetchWebhookRepos(); + if (current.some(value => value.repositoryId === repository.id && value.eventKind === 'ISSUE')) { + onHooksChanged(current); + setError('Instant updates were already turned on. If the secret was not copied, rotate it on the Details tab.'); + return; + } + const result = await createWebhookRepo({ providerType: source.type.toLowerCase(), forgeOrigin: repository.forgeOrigin, repositoryId: repository.id, + eventKind: 'ISSUE', sourceId: source.id, scope: 'repo', target: source.scope, enabled: true }); + onHooksChanged([...current, result.repo]); setRevealed(result); + } catch (failure) { setError(`Instant updates could not be turned on: ${String(failure)}. Scanning still finds new labels.`); } + finally { setBusy(false); } + } + + return
    + {hook + ? {hook.enabled ? 'instant updates on' : 'instant updates paused'} + {' '}{webhookPath(hook)} + : Instant updates are off. New labels are seen within five minutes.} + {!hook && } + {hooksUnavailable && !hook && Webhooks cannot be loaded right now.} + {error &&

    {error}

    } + {revealed && setRevealed(null)} />} +
    ; +} diff --git a/spire-ui/src/components/repositories/factory/PeopleStep.tsx b/spire-ui/src/components/repositories/factory/PeopleStep.tsx new file mode 100644 index 00000000..a90cc72e --- /dev/null +++ b/spire-ui/src/components/repositories/factory/PeopleStep.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react'; +import { actorLabel, type ActorResult } from '../../actorsApi'; +import * as api from '../../work-items/workSourcesApi'; +import SettingField from '../../SettingField'; +import FactoryStep from './FactoryStep'; + +interface Props { + sources: api.WorkSource[]; + open: string | null; + setOpen: (open: string | null) => void; + changed: (notice?: string) => void; +} + +/** + * Finds one person on a source's tracker and allows them. A lookup that answers after the handle + * changed, or after the form moved to another source, must not become a selection: it would allow + * someone the operator never looked at. + */ +function AllowPerson({ source, cancelled, changed }: { source: api.WorkSource; cancelled: () => void; changed: Props['changed'] }) { + const [handle, setHandle] = useState(''), [selected, setSelected] = useState(''); + const [resolution, setResolution] = useState(null); + const [busy, setBusy] = useState(false), [error, setError] = useState(''); + const request = useRef(0); + useEffect(() => () => { request.current++; }, []); + async function find() { + const asked = ++request.current; + setBusy(true); setError(''); setResolution(null); setSelected(''); + try { + const result = await api.resolveWorkActor(source.id, handle); + if (asked !== request.current) return; + setResolution(result); + setSelected(result.status === 'FOUND' && result.actors.length === 1 ? result.actors[0].providerUserId : ''); + } catch (failure) { if (asked === request.current) setError(String(failure)); } + finally { if (asked === request.current) setBusy(false); } + } + async function allow() { + setBusy(true); setError(''); + try { await api.saveWorkActor(source, handle, selected); changed(`Allowed ${handle} on ${source.name}.`); } + catch (failure) { setError(String(failure)); setBusy(false); } + } + const chosen = resolution?.actors.find(actor => actor.providerUserId === selected); + return
    + {source.type === 'JIRA' &&

    Jira Cloud display names are not unique. Find the person, then pick the account explicitly. Data Center person lookup is currently unavailable.

    } + + { request.current++; setHandle(event.target.value); setResolution(null); setSelected(''); }} /> +
    + {busy &&

    Asking the tracker. The form unlocks when it answers.

    } + {resolution?.detail &&

    {resolution.detail}

    } + {resolution && (resolution.actors.length > 1 || resolution.status === 'SELECTION_REQUIRED') + ? + + : chosen &&

    {actorLabel(chosen)} {chosen.providerUserId}

    } + {error &&

    {error}

    } +
    + +
    +
    ; +} + +export default function PeopleStep({ sources, open, setOpen, changed }: Props) { + // Holds the person being removed, so only that row reports work — not every row at once. + const [busy, setBusy] = useState(null), [error, setError] = useState(''); + const counted = sources.some(source => source.enabled && source.allowedPeople.length > 0); + async function remove(source: api.WorkSource, id: string, label: string) { + setBusy(id); setError(''); + try { await api.removeWorkActor(source, id); changed(`Removed ${label} from ${source.name}.`); } + catch (failure) { setError(String(failure)); } finally { setBusy(null); } + } + return 0 && !counted && nobody yet}> + {sources.length === 0 &&

    Add a source in step 1 first. People are allowed per source.

    } +

    Only labels these people add can start work. A label from anyone else is ignored, however it is named.

    + {sources.map(source =>
    + {sources.length > 1 &&
    {source.name}
    } +
      {source.allowedPeople.map(person =>
    • + {actorLabel(person)}{person.providerUserId} +
    • )}
    + {open === `people:${source.id}` + ? setOpen(null)} changed={changed} /> + : } +
    )} + {error &&

    {error}

    } +
    ; +} diff --git a/spire-ui/src/components/repositories/factory/PolicySteps.tsx b/spire-ui/src/components/repositories/factory/PolicySteps.tsx new file mode 100644 index 00000000..baa1137a --- /dev/null +++ b/spire-ui/src/components/repositories/factory/PolicySteps.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import * as policyApi from '../../work-items/workPolicyApi'; +import { key, pin, policyInput, rowsOf, type MappingRow } from '../../work-items/policyInput'; +import PhaseStrip from '../../work-items/PhaseStrip'; +import SettingField from '../../SettingField'; +import FactoryStep from './FactoryStep'; +import PresetForm from './PresetForm'; + +interface Props { + repositoryId: string; + policy: policyApi.Policy; + profiles: policyApi.Profile[]; + open: string | null; + setOpen: (open: string | null) => void; + changed: (notice?: string) => void; + reload: () => void; +} + +const versionOptions = (profiles: policyApi.Profile[]) => profiles.map(profile => + ); + +/** Save one form's policy, surfacing the server's words rather than a stack-shaped string. */ +async function save(repositoryId: string, input: Parameters[1], setBusy: (busy: boolean) => void, setError: (error: string) => void) { + setBusy(true); setError(''); + try { await policyApi.savePolicy(repositoryId, input); return true; } + catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); return false; } + finally { setBusy(false); } +} + +export function CeilingStep({ repositoryId, policy, profiles, open, setOpen, changed }: Props) { + const [ceiling, setCeiling] = useState(policy.ceiling ? key(policy.ceiling) : ''); + const [busy, setBusy] = useState(false), [error, setError] = useState(''); + const editing = open === 'ceiling'; + async function submit() { + const chosen = profiles.find(profile => key(profile) === ceiling); + if (!chosen) return; + const mappings = Object.fromEntries(Object.entries(policy.mappings).map(([label, profile]) => [label, pin(profile)])); + if (await save(repositoryId, { revision: policy.revision, ceiling: pin(chosen), mappings }, setBusy, setError)) changed(`Ceiling set to ${chosen.name} v${chosen.version}.`); + } + return setOpen('ceiling')}>{policy.ceiling ? 'Change' : 'Choose ceiling'}}> + {policy.ceiling + ?
    {policy.ceiling.name} v{policy.ceiling.version}
    + :

    No ceiling, so every label is refused.{profiles.length === 0 && ' Create a profile first, or use the presets in step 4.'}

    } +

    A label can ask for less than this, never more. Anything above it is cut back.

    + {editing &&
    + + + {error &&

    {error}

    } +
    + +
    +
    } +
    ; +} + +function LabelRows({ rows, setRows, profiles }: { rows: MappingRow[]; setRows: (update: (rows: MappingRow[]) => MappingRow[]) => void; profiles: policyApi.Profile[] }) { + const change = (index: number, patch: Partial) => setRows(current => current.map((row, i) => i === index ? { ...row, ...patch } : row)); + return <>{rows.map((row, index) =>
    + + change(index, { label: event.target.value })} /> + + + +
    )}; +} + +export function LabelsStep({ repositoryId, policy, profiles, open, setOpen, changed, reload }: Props) { + const [rows, setRows] = useState(() => rowsOf(policy)); + const [busy, setBusy] = useState(false), [error, setError] = useState(''); + const mappings = Object.entries(policy.mappings); + async function submit() { + let input; + try { input = policyInput(policy.revision, policy.ceiling ? key(policy.ceiling) : '', rows, profiles); } + catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); return; } + if (await save(repositoryId, input, setBusy, setError)) changed('Label mappings saved.'); + } + const editing = open === 'labels'; + return + + }> + {mappings.length === 0 + ?

    No label starts work yet.{!policy.ceiling && ' Choose the ceiling in step 3 first, or let the presets set it.'}

    + :
      {mappings.map(([label, profile]) =>
    • + {label} + {profile.name} v{profile.version}
    • )}
    } + {open === 'presets' && setOpen(null)} changed={changed} reload={reload} />} + {editing &&
    + +
    + {error &&

    {error}

    } +
    + +
    +
    } +
    ; +} diff --git a/spire-ui/src/components/repositories/factory/PresetForm.tsx b/spire-ui/src/components/repositories/factory/PresetForm.tsx new file mode 100644 index 00000000..a00c3fdc --- /dev/null +++ b/spire-ui/src/components/repositories/factory/PresetForm.tsx @@ -0,0 +1,51 @@ +import { useMemo, useState } from 'react'; +import * as policyApi from '../../work-items/workPolicyApi'; +import PhaseStrip from '../../work-items/PhaseStrip'; +import SettingField from '../../SettingField'; +import { applyPresets, planPresets, PRESETS } from './presets'; + +interface Props { + repositoryId: string; + policy: policyApi.Policy; + profiles: policyApi.Profile[]; + cancelled: () => void; + changed: (notice?: string) => void; + /** Re-read profiles without closing, so a retry after a partial failure reuses what was created. */ + reload: () => void; +} + +/** + * Shows exactly what the presets will create, reuse and leave alone before anything is written, and + * asks for the ceiling instead of choosing one: the ceiling is the operator's security decision, so the + * safe default is the most restrictive preset, not the one that makes every label work. + */ +export default function PresetForm({ repositoryId, policy, profiles, cancelled, changed, reload }: Props) { + const plan = useMemo(() => planPresets(profiles, policy, () => crypto.randomUUID()), [profiles, policy]); + const presetCeiling = PRESETS.find(preset => preset.name === policy.ceiling?.name)?.name; + const [ceiling, setCeiling] = useState(presetCeiling ?? PRESETS[0].name); + const [busy, setBusy] = useState(false), [error, setError] = useState(''); + async function apply() { + setBusy(true); setError(''); + try { await applyPresets(repositoryId, policy, plan, ceiling); changed(`Presets applied. Ceiling: ${ceiling}.`); } + catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); setBusy(false); reload(); } + } + return
    +

    Three profiles from the factory design, from most to least careful. Create the same labels in your tracker so people can apply them.

    +
      {plan.map(step => { + const profile = step.existing ?? step.created!; + return
    • + {step.preset.label} + {profile.name}{step.existing ? ` v${step.existing.version}` : ''} + {step.conflict ? `kept: already maps to ${step.conflict.name} v${step.conflict.version}` + : step.existing ? 'uses the existing profile as it is' : `new profile, precedence ${profile.precedence}`} +
    • ; + })}
    + + + {error &&

    {error}

    } +
    + +
    +
    ; +} diff --git a/spire-ui/src/components/repositories/factory/RepositoryFactory.test.tsx b/spire-ui/src/components/repositories/factory/RepositoryFactory.test.tsx new file mode 100644 index 00000000..686fc6b3 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/RepositoryFactory.test.tsx @@ -0,0 +1,199 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as sources from '../../work-items/workSourcesApi'; +import * as policies from '../../work-items/workPolicyApi'; +import RepositoryFactory from './RepositoryFactory'; +import { account, policy, profile, repository, source } from './factoryFixtures'; + +const field = { selector: 'input,select,textarea' }; +const renderFactory = (repo = repository) => render(); +const step = (number: number) => screen.findByRole('listitem', { name: new RegExp(`^Step ${number}:`) }); +beforeEach(() => { + vi.spyOn(sources, 'fetchWorkSources').mockResolvedValue([source()]); + vi.spyOn(sources, 'saveWorkActor').mockResolvedValue(source()); + vi.spyOn(sources, 'removeWorkActor').mockResolvedValue(source()); + vi.spyOn(policies, 'profiles').mockResolvedValue([profile]); + vi.spyOn(policies, 'policy').mockResolvedValue(policy()); + vi.spyOn(policies, 'savePolicy').mockResolvedValue(policy({ revision: 8 })); + vi.spyOn(policies, 'saveProfile').mockImplementation(async value => value); +}); + +describe('outcome', () => { + it('says what a complete setup does, naming the person, label, profile and ceiling', async () => { + renderFactory(); + const outcome = (await screen.findByText('Ready — tickets can start work')).closest('.factory-outcome') as HTMLElement; + expect(outcome).toHaveTextContent('When @TEST-person adds one of these labels to an open ticket in GitHub issues in TEST-owner/TEST-repo'); + expect(outcome).toHaveTextContent('never more than the ceiling TEST-assisted v3'); + expect(within(outcome).getByText('TEST-work')).toBeInTheDocument(); + }); + it('names the first missing part rather than a later one', async () => { + vi.mocked(sources.fetchWorkSources).mockResolvedValue([source({ allowedPeople: [] })]); + vi.mocked(policies.policy).mockResolvedValue(policy({ ceiling: null, mappings: {} })); + renderFactory(); + expect(await screen.findByText(/nobody is allowed to start work, so every label is ignored/)).toBeInTheDocument(); + expect(within(await step(2)).getByText('nobody yet')).toBeInTheDocument(); + }); + it('ignores a load that answers after the panel moved to another repository', async () => { + let answer!: (value: sources.WorkSource[]) => void; + vi.mocked(sources.fetchWorkSources).mockReturnValueOnce(new Promise(done => { answer = done; })).mockResolvedValue([]); + const view = renderFactory(); + view.rerender(); + expect(await screen.findByText(/Nothing reads tickets for this repository yet/)).toBeInTheDocument(); + await act(async () => answer([source({ repositoryId: 'TEST-other', name: 'TEST-stale source' })])); + expect(screen.queryByText('TEST-stale source')).toBeNull(); + }); +}); + +describe('people', () => { + async function openPeople() { + renderFactory(); + fireEvent.click(within(await step(2)).getByRole('button', { name: 'Add a person to TEST-source name' })); + return screen.getByRole('group', { name: 'Allow a person on TEST-source name' }); + } + it('names allowed people by handle and keeps the id that authorises them', async () => { + renderFactory(); + const people = within(await step(2)); + expect(people.getByText('@TEST-person')).toBeInTheDocument(); + expect(people.getByText('900123')).toHaveClass('prov-sub'); + fireEvent.click(people.getByRole('button', { name: 'Remove @TEST-person' })); + await waitFor(() => expect(sources.removeWorkActor).toHaveBeenCalledWith(source(), '900123')); + }); + it('allows the one person a lookup finds, naming them on the button, with the source revision', async () => { + vi.spyOn(sources, 'resolveWorkActor').mockResolvedValue({ status: 'FOUND', actors: [{ providerUserId: '900456', handle: 'TEST-new', displayName: 'TEST-new' }], detail: null }); + const form = await openPeople(); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-new' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Find person' })); + fireEvent.click(await within(form).findByRole('button', { name: 'Allow @TEST-new' })); + await waitFor(() => expect(sources.saveWorkActor).toHaveBeenCalledWith(source(), 'TEST-new', '900456')); + }); + it('requires an explicit choice when several people match', async () => { + vi.spyOn(sources, 'resolveWorkActor').mockResolvedValue({ status: 'FOUND', actors: [ + { providerUserId: '900456', handle: 'TEST-a', displayName: 'TEST-a' }, { providerUserId: '900789', handle: 'TEST-b', displayName: 'TEST-b' }], detail: null }); + const form = await openPeople(); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-a' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Find person' })); + const pick = await within(form).findByLabelText('Resolved source person', field); + expect(within(form).getByRole('button', { name: 'Allow person' })).toBeDisabled(); + fireEvent.change(pick, { target: { value: '900789' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Allow @TEST-b' })); + await waitFor(() => expect(sources.saveWorkActor).toHaveBeenCalledWith(source(), 'TEST-a', '900789')); + }); + it('requires an explicit Jira account choice even for a single match', async () => { + vi.mocked(sources.fetchWorkSources).mockResolvedValue([source({ type: 'JIRA', name: 'TEST-source name' })]); + vi.spyOn(sources, 'resolveWorkActor').mockResolvedValue({ status: 'SELECTION_REQUIRED', actors: [{ providerUserId: '900123', handle: '', displayName: 'TEST-person' }], detail: 'Select an account explicitly.' }); + const form = await openPeople(); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-person' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Find person' })); + await within(form).findByLabelText('Resolved source person', field); + expect(within(form).getByRole('button', { name: 'Allow person' })).toBeDisabled(); + }); + it('discards a found person when the typed handle changes', async () => { + vi.spyOn(sources, 'resolveWorkActor').mockResolvedValue({ status: 'FOUND', actors: [{ providerUserId: '900456', handle: 'TEST-new', displayName: 'TEST-new' }], detail: null }); + const form = await openPeople(); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-new' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Find person' })); + await within(form).findByRole('button', { name: 'Allow @TEST-new' }); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-someone-else' } }); + expect(within(form).getByRole('button', { name: 'Allow person' })).toBeDisabled(); + }); + it('ignores a lookup that answers after the handle was edited', async () => { + // The form's fieldset stops typing mid-lookup in a browser; this holds if that lock is ever loosened. + let answer!: (value: Awaited>) => void; + vi.spyOn(sources, 'resolveWorkActor').mockReturnValue(new Promise(done => { answer = done; })); + const form = await openPeople(); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-new' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Find person' })); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-someone-else' } }); + await act(async () => answer({ status: 'FOUND', actors: [{ providerUserId: '900456', handle: 'TEST-new', displayName: 'TEST-new' }], detail: null })); + expect(within(form).queryByText('@TEST-new')).toBeNull(); + expect(within(form).getByRole('button', { name: 'Allow person' })).toBeDisabled(); + }); + it('does not turn a lookup that answers after Cancel into a selection', async () => { + let answer!: (value: Awaited>) => void; + vi.spyOn(sources, 'resolveWorkActor').mockReturnValue(new Promise(done => { answer = done; })); + const form = await openPeople(); + fireEvent.change(within(form).getByLabelText('Person', field), { target: { value: 'TEST-new' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Find person' })); + fireEvent.click(within(form).getByRole('button', { name: 'Cancel' })); + fireEvent.click(within(await step(2)).getByRole('button', { name: 'Add a person to TEST-source name' })); + await act(async () => answer({ status: 'FOUND', actors: [{ providerUserId: '900456', handle: 'TEST-new', displayName: 'TEST-new' }], detail: null })); + expect(screen.getByRole('button', { name: 'Allow person' })).toBeDisabled(); + }); +}); + +describe('ceiling and labels', () => { + it('saves a ceiling with its version, keeping the label mappings and the revision', async () => { + const newer = { ...profile, version: 4 }; + vi.mocked(policies.profiles).mockResolvedValue([profile, newer]); + renderFactory(); + fireEvent.click(within(await step(3)).getByRole('button', { name: 'Change' })); + fireEvent.change(screen.getByLabelText('Repository ceiling', field), { target: { value: 'TEST-profile:4' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save ceiling' })); + await waitFor(() => expect(policies.savePolicy).toHaveBeenCalledWith(repository.id, { revision: 7, ceiling: { id: profile.id, version: 4 }, mappings: { 'TEST-work': { id: profile.id, version: 3 } } })); + }); + it('cannot edit labels before a ceiling exists, and says why', async () => { + vi.mocked(policies.policy).mockResolvedValue(policy({ ceiling: null, mappings: {} })); + renderFactory(); + const labels = within(await step(4)); + expect(labels.getByRole('button', { name: 'Edit labels' })).toBeDisabled(); + expect(labels.getByText(/Choose the ceiling in step 3 first/)).toBeInTheDocument(); + }); + async function editLabels() { + renderFactory(); + fireEvent.click(within(await step(4)).getByRole('button', { name: 'Edit labels' })); + return screen.getByRole('group', { name: 'Edit label mappings' }); + } + it('saves when an added mapping row is left blank', async () => { + const form = await editLabels(); + fireEvent.click(within(form).getByRole('button', { name: 'Add label mapping' })); + fireEvent.click(within(form).getByRole('button', { name: 'Save labels' })); + await waitFor(() => expect(policies.savePolicy).toHaveBeenCalledWith(repository.id, { revision: 7, ceiling: { id: profile.id, version: 3 }, mappings: { 'TEST-work': { id: profile.id, version: 3 } } })); + }); + it('refuses a half-filled row and a duplicate label by name', async () => { + const form = await editLabels(); + fireEvent.click(within(form).getByRole('button', { name: 'Add label mapping' })); + fireEvent.change(within(form).getByLabelText('Label 2', field), { target: { value: 'TEST-half' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Save labels' })); + expect(await within(form).findByRole('alert')).toHaveTextContent('Label 2 needs both a ticket label and a profile version.'); + fireEvent.change(within(form).getByLabelText('Label 2', field), { target: { value: ' TEST-work ' } }); + fireEvent.change(within(form).getByLabelText('Label profile 2', field), { target: { value: 'TEST-profile:3' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Save labels' })); + expect(await within(form).findByRole('alert')).toHaveTextContent('Each label needs one mapping.'); + expect(policies.savePolicy).not.toHaveBeenCalled(); + }); + it('keeps a failed save and what was typed visible', async () => { + vi.mocked(policies.savePolicy).mockRejectedValue(new Error('TEST-policy changed; reload')); + const form = await editLabels(); + fireEvent.click(within(form).getByRole('button', { name: 'Save labels' })); + expect(await within(form).findByRole('alert')).toHaveTextContent('TEST-policy changed; reload'); + expect(within(form).getByLabelText('Label 1', field)).toHaveValue('TEST-work'); + }); +}); + +describe('presets', () => { + it('shows what it will create, defaults the ceiling to the most careful preset, then applies', async () => { + vi.mocked(policies.policy).mockResolvedValue(policy({ ceiling: null, mappings: {} })); + renderFactory(); + fireEvent.click(within(await step(4)).getByRole('button', { name: 'Use presets' })); + const form = screen.getByRole('group', { name: 'Use presets' }); + expect(within(form).getAllByText(/^new profile, precedence/)).toHaveLength(3); + expect(within(form).getByLabelText('Preset ceiling', field)).toHaveValue('suggest'); + fireEvent.click(within(form).getByRole('button', { name: 'Apply presets' })); + await waitFor(() => expect(policies.savePolicy).toHaveBeenCalledTimes(1)); + expect(policies.saveProfile).toHaveBeenCalledTimes(3); + const saved = vi.mocked(policies.savePolicy).mock.calls[0][1]; + expect(Object.keys(saved.mappings)).toEqual(['spire:suggest', 'spire:assisted', 'spire:auto']); + expect(saved.ceiling).toEqual(saved.mappings['spire:suggest']); + }); + it('re-reads profiles after a failed apply so a retry reuses what was created', async () => { + vi.mocked(policies.policy).mockResolvedValue(policy({ ceiling: null, mappings: {} })); + vi.mocked(policies.savePolicy).mockRejectedValueOnce(new Error('TEST-policy save failed')); + renderFactory(); + fireEvent.click(within(await step(4)).getByRole('button', { name: 'Use presets' })); + fireEvent.click(screen.getByRole('button', { name: 'Apply presets' })); + expect(await screen.findByRole('alert')).toHaveTextContent('TEST-policy save failed'); + await waitFor(() => expect(policies.profiles).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/spire-ui/src/components/repositories/factory/RepositoryFactory.tsx b/spire-ui/src/components/repositories/factory/RepositoryFactory.tsx new file mode 100644 index 00000000..154168f1 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/RepositoryFactory.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import type { ProviderView, WebhookRepoView } from '../../../api'; +import type { Repository } from '../repositoriesApi'; +import * as sourcesApi from '../../work-items/workSourcesApi'; +import * as policyApi from '../../work-items/workPolicyApi'; +import { readiness } from './factoryModel'; +import FactoryOutcome from './FactoryOutcome'; +import SourceStep from './SourceStep'; +import PeopleStep from './PeopleStep'; +import { CeilingStep, LabelsStep } from './PolicySteps'; + +interface Props { + repository: Repository; + accounts: ProviderView[]; + webhooks: { hooks: WebhookRepoView[]; unavailable: boolean; changed: (hooks: WebhookRepoView[]) => void }; + /** Tells the list its setup column is out of date. */ + onChanged: () => void; +} + +interface Loaded { sources: sourcesApi.WorkSource[]; profiles: policyApi.Profile[]; policy: policyApi.Policy } + +/** + * Everything that lets a ticket start work on one repository, in the order it has to exist. Each part + * used to be its own screen, and an operator could set all of them without seeing how they combine; + * here the combination is stated first and every part is changed where it is shown. + */ +export default function RepositoryFactory({ repository, accounts, webhooks, onChanged }: Props) { + const [data, setData] = useState(null); + const [error, setError] = useState(''), [notice, setNotice] = useState(''); + const [refresh, setRefresh] = useState(0); + const [open, setOpen] = useState(null); + useEffect(() => { + let active = true; + Promise.all([sourcesApi.fetchWorkSources(), policyApi.profiles(), policyApi.policy(repository.id)]).then(([sources, profiles, policy]) => { + if (!active) return; + setData({ sources: sources.filter(source => source.repositoryId === repository.id), profiles, policy }); setError(''); + }).catch(failure => { if (active) setError(String(failure)); }); + return () => { active = false; }; + }, [repository.id, refresh]); + + const reload = () => setRefresh(value => value + 1); + const changed = (message?: string) => { setOpen(null); setNotice(message ?? ''); reload(); onChanged(); }; + const openStep = (next: string | null) => { setNotice(''); setOpen(next); }; + if (error) return

    {error}

    ; + if (!data) return

    Loading factory setup…

    ; + + const shared = { open, setOpen: openStep, changed }; + // Keyed by revision: a form opened after a save must start from what was saved, not what was typed before. + const policyKey = `${data.policy.revision}:${data.profiles.length}`; + return
    + + {notice &&

    {notice}

    } +
      + + + + +
    +
    ; +} diff --git a/spire-ui/src/components/repositories/factory/SourceForms.tsx b/spire-ui/src/components/repositories/factory/SourceForms.tsx new file mode 100644 index 00000000..e3e2e5a8 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/SourceForms.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import type { ProviderView } from '../../../api'; +import type { Repository } from '../repositoriesApi'; +import * as api from '../../work-items/workSourcesApi'; +import { accountOptionLabel } from '../../accounts'; +import SettingField from '../../SettingField'; + +export const trackerNames: Record = { GITHUB: 'GitHub', GITLAB: 'GitLab', JIRA: 'Jira' }; +export function origin(base: string) { try { return new URL(base).origin; } catch { return ''; } } + +/** + * The trackers this repository can take tickets from. A forge's own issues only come from that forge; + * Jira is polled separately and may feed a repository on any forge. + */ +export function trackersFor(repository: Repository): api.WorkSourceType[] { + const own = repository.scmType === 'github' ? 'GITHUB' : repository.scmType === 'gitlab' ? 'GITLAB' : null; + return own ? [own, 'JIRA'] : ['JIRA']; +} + +/** An account can read this tracker for this repository: enabled, the right kind, supported sign-in and — for a forge — the same origin. */ +export function compatibleAccount(type: api.WorkSourceType, account: ProviderView, repository: Repository) { + if (!account.enabled) return false; + if (type === 'JIRA') return account.type === 'atlassian' && (account.authKind === 'bearer' || account.authKind === 'basic'); + return account.type === type.toLowerCase() && account.authKind === 'bearer' && origin(account.baseUrl) === repository.forgeOrigin; +} + +interface CreateProps { repository: Repository; accounts: ProviderView[]; saved: (source: api.WorkSource) => void; cancelled: () => void } + +export function CreateSourceForm({ repository, accounts, saved, cancelled }: CreateProps) { + const choices = trackersFor(repository); + const [type, setType] = useState(choices[0]); + const [name, setName] = useState(`${repository.slug} issues`), [accountId, setAccount] = useState(''), [projectKey, setProjectKey] = useState(''); + const [busy, setBusy] = useState(false), [error, setError] = useState(''); + const offered = accounts.filter(value => compatibleAccount(type, value, repository)); + const account = offered.find(value => value.id === accountId); + // A forge tracks issues in the repository itself, so its scope is the repository and is not asked for. + const scope = type === 'JIRA' ? projectKey.trim() : `${repository.workspace}/${repository.slug}`; + const ready = !!account && !!name.trim() && !!scope; + async function submit() { + if (!account) return; + setBusy(true); setError(''); + try { saved(await api.createWorkSource({ name: name.trim(), type, origin: origin(account.baseUrl), scope, repositoryId: repository.id, accountId: account.id, enabled: true })); } + catch (failure) { setError(String(failure)); } finally { setBusy(false); } + } + return
    + + setName(event.target.value)} /> + + + + + {offered.length === 0 &&

    No enabled {trackerNames[type]} account {type === 'JIRA' ? 'exists' : `on ${repository.forgeOrigin}`}. Add an account first.

    } + {type === 'JIRA' + ? + setProjectKey(event.target.value)} /> + : + } + {type === 'JIRA' &&

    Jira is polled. The Jira credential stays on the tracker. Unconfirmed label authors select no profile.

    } + {error &&

    {error}

    } +
    + +
    +
    ; +} + +interface EditProps { source: api.WorkSource; repository: Repository; accounts: ProviderView[]; saved: () => void; cancelled: () => void } + +export function EditSourceForm({ source, repository, accounts, saved, cancelled }: EditProps) { + const [name, setName] = useState(source.name), [accountId, setAccount] = useState(source.accountId), [enabled, setEnabled] = useState(source.configuredEnabled); + const [capabilities, setCapabilities] = useState(null); + const [busy, setBusy] = useState(false), [error, setError] = useState(''); + async function run(action: () => Promise) { + setBusy(true); setError(''); + try { await action(); } catch (failure) { setError(String(failure)); } finally { setBusy(false); } + } + // The current account stays listed even when it no longer qualifies, so the form shows what is saved. + const offered = accounts.filter(account => compatibleAccount(source.type, account, repository) && origin(account.baseUrl) === source.origin || account.id === source.accountId); + return
    + + setName(event.target.value)} /> + + + + {!source.enabled && source.configuredEnabled &&

    This source is enabled, but its account or repository is unavailable.

    } + {capabilities &&

    {capabilities.operations.map(value => value.toLowerCase().split('_').join(' ')).join(', ')}. {capabilities.detail}

    } + {error &&

    {error}

    } +
    + + + +
    +
    ; +} diff --git a/spire-ui/src/components/repositories/factory/SourceStep.test.tsx b/spire-ui/src/components/repositories/factory/SourceStep.test.tsx new file mode 100644 index 00000000..8bbbc99b --- /dev/null +++ b/spire-ui/src/components/repositories/factory/SourceStep.test.tsx @@ -0,0 +1,164 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, expect, it, vi } from 'vitest'; +import * as gateway from '../../../api'; +import * as sources from '../../work-items/workSourcesApi'; +import * as policies from '../../work-items/workPolicyApi'; +import RepositoryFactory from './RepositoryFactory'; +import { account, issueHook, policy, profile, repository, source } from './factoryFixtures'; + +const field = { selector: 'input,select,textarea' }; +let changedHooks = vi.fn(); +function renderFactory(options: { accounts?: gateway.ProviderView[]; hooks?: gateway.WebhookRepoView[]; unavailable?: boolean; repo?: typeof repository } = {}) { + changedHooks = vi.fn(); + return render(); +} +const step = (number: number) => screen.findByRole('listitem', { name: new RegExp(`^Step ${number}:`) }); +beforeEach(() => { + vi.spyOn(sources, 'fetchWorkSources').mockResolvedValue([source()]); + vi.spyOn(sources, 'createWorkSource').mockResolvedValue(source()); + vi.spyOn(sources, 'editWorkSource').mockResolvedValue(source()); + vi.spyOn(sources, 'rescanWorkSource').mockResolvedValue(); + vi.spyOn(policies, 'profiles').mockResolvedValue([profile]); + vi.spyOn(policies, 'policy').mockResolvedValue(policy()); + vi.spyOn(gateway, 'fetchWebhookRepos').mockResolvedValue([]); +}); +async function addSource(options?: Parameters[0]) { + vi.mocked(sources.fetchWorkSources).mockResolvedValue([]); + renderFactory(options); + fireEvent.click(within(await step(1)).getByRole('button', { name: 'Add source' })); + return screen.getByRole('group', { name: 'Add where tickets come from' }); +} + +it('offers only the trackers this repository can take tickets from', async () => { + const form = await addSource(); + expect(within(within(form).getByLabelText('Tracker', field)).getAllByRole('option').map(option => option.textContent)).toEqual(['GitHub', 'Jira']); +}); +it('offers Jira alone to a repository whose forge has no issue source', async () => { + const form = await addSource({ repo: { ...repository, scmType: 'bitbucket-cloud', forgeOrigin: 'https://bitbucket.example.test' } }); + expect(within(within(form).getByLabelText('Tracker', field)).getAllByRole('option').map(option => option.textContent)).toEqual(['Jira']); +}); +it('offers only enabled bearer accounts of the tracker kind on this repository origin', async () => { + const form = await addSource({ accounts: [account('github', { id: 'TEST-good', name: 'TEST-good' }), account('github', { id: 'TEST-disabled', name: 'TEST-disabled', enabled: false }), + account('github', { id: 'TEST-elsewhere', name: 'TEST-elsewhere', baseUrl: 'https://TEST-other.invalid' }), account('github', { id: 'TEST-basic', name: 'TEST-basic', authKind: 'basic' }), + account('gitlab', { id: 'TEST-gitlab', name: 'TEST-gitlab' })] }); + const options = within(within(form).getByLabelText('Tracker account', field)).getAllByRole('option').map(option => option.getAttribute('value')); + expect(options).toEqual(['', 'TEST-good']); +}); +it('explains a missing account and links to Accounts instead of an empty picker', async () => { + const form = await addSource({ accounts: [account('github', { baseUrl: 'https://TEST-other.invalid' })] }); + expect(within(form).getByText(/No enabled GitHub account on https:\/\/github.example.test/)).toBeInTheDocument(); + expect(within(form).getByRole('link', { name: 'Add an account' })).toHaveAttribute('href', '#/settings/accounts'); + expect(within(form).getByRole('button', { name: 'Register work source' })).toBeDisabled(); +}); +it('distinguishes duplicate credential names in the create and edit account pickers', async () => { + const accounts = [account('github', { name: 'TEST-shared name' }), account('github', { id: 'TEST-reviewer', name: 'TEST-shared name', role: 'REVIEWER' })]; + renderFactory({ accounts }); + const expectDistinct = (select: HTMLElement) => { + expect(within(select).getByRole('option', { name: 'TEST-shared name · github · Factory' })).toHaveValue('TEST-github'); + expect(within(select).getByRole('option', { name: 'TEST-shared name · github · Reviewer' })).toHaveValue('TEST-reviewer'); + }; + fireEvent.click(within(await step(1)).getByRole('button', { name: 'Add another source' })); + expectDistinct(within(screen.getByRole('group', { name: 'Add where tickets come from' })).getByLabelText('Tracker account', field)); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.click(screen.getByRole('button', { name: 'Edit TEST-source name' })); + expectDistinct(within(screen.getByRole('group', { name: 'Edit TEST-source name' })).getByLabelText('Source account', field)); +}); +it('registers a forge source whose scope is the repository itself', async () => { + const form = await addSource(); + expect(within(form).getByLabelText('Tracker repository', field)).toHaveAttribute('readonly'); + expect(within(form).getByLabelText('Source name', field)).toHaveValue('TEST-repo issues'); + fireEvent.change(within(form).getByLabelText('Tracker account', field), { target: { value: 'TEST-github' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Register work source' })); + await waitFor(() => expect(sources.createWorkSource).toHaveBeenCalledWith({ name: 'TEST-repo issues', type: 'GITHUB', origin: 'https://github.example.test', + scope: 'TEST-owner/TEST-repo', repositoryId: repository.id, accountId: 'TEST-github', enabled: true })); + expect(await screen.findByText('Work source TEST-source name registered.')).toBeInTheDocument(); +}); +it('maps a Jira project to this repository even though it lives on another forge', async () => { + const form = await addSource(); + fireEvent.change(within(form).getByLabelText('Tracker', field), { target: { value: 'JIRA' } }); + fireEvent.change(within(form).getByLabelText('Tracker account', field), { target: { value: 'TEST-atlassian' } }); + fireEvent.change(within(form).getByLabelText('Jira project key', field), { target: { value: 'TEST' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Register work source' })); + await waitFor(() => expect(sources.createWorkSource).toHaveBeenCalledWith(expect.objectContaining({ type: 'JIRA', origin: 'https://atlassian.example.test', scope: 'TEST', repositoryId: repository.id }))); +}); +it('keeps every control locked while a source is saved', async () => { + let finish!: (value: sources.WorkSource) => void; + vi.mocked(sources.createWorkSource).mockReturnValue(new Promise(done => { finish = done; })); + const form = await addSource(); + fireEvent.change(within(form).getByLabelText('Tracker account', field), { target: { value: 'TEST-github' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Register work source' })); + for (const control of form.querySelectorAll('input,select,button')) expect(control).toBeDisabled(); + expect(sources.createWorkSource).toHaveBeenCalledTimes(1); + await act(async () => finish(source())); +}); +it('does not let a disabled repository take a new source', async () => { + vi.mocked(sources.fetchWorkSources).mockResolvedValue([]); + renderFactory({ repo: { ...repository, enabled: false } }); + expect(within(await step(1)).getByRole('button', { name: 'Add source' })).toBeDisabled(); + expect(screen.getByText(/This repository is disabled/)).toBeInTheDocument(); +}); +it('preserves the configured enabled flag when the account is unavailable', async () => { + vi.mocked(sources.fetchWorkSources).mockResolvedValue([source({ enabled: false, configuredEnabled: true })]); + renderFactory({ accounts: [account('github', { enabled: false })] }); + expect(within(await step(1)).getByRole('button', { name: 'Scan TEST-source name now' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Edit TEST-source name' })); + const form = screen.getByRole('group', { name: 'Edit TEST-source name' }); + expect(within(form).getByLabelText('Source enabled', field)).toBeChecked(); + expect(within(form).getByText(/enabled, but its account or repository is unavailable/)).toBeInTheDocument(); +}); +it('saves an edited source with its revision and requests a scan', async () => { + vi.spyOn(sources, 'workCapabilities').mockResolvedValue({ operations: ['CANDIDATES', 'COMMENT'], detail: 'TEST-this deployment cannot attribute labels.' }); + renderFactory(); + fireEvent.click(within(await step(1)).getByRole('button', { name: 'Scan TEST-source name now' })); + await waitFor(() => expect(sources.rescanWorkSource).toHaveBeenCalledWith('TEST-source')); + fireEvent.click(await screen.findByRole('button', { name: 'Edit TEST-source name' })); + const form = screen.getByRole('group', { name: 'Edit TEST-source name' }); + fireEvent.click(within(form).getByRole('button', { name: 'Check supported operations' })); + expect(await within(form).findByText(/TEST-this deployment cannot attribute labels/)).toHaveTextContent('candidates, comment'); + fireEvent.change(within(form).getByLabelText('Edit source name', field), { target: { value: 'TEST-renamed' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Save source' })); + await waitFor(() => expect(sources.editWorkSource).toHaveBeenCalledWith(source(), { name: 'TEST-renamed', accountId: 'TEST-github', enabled: true })); +}); +it('turns on instant updates with an issue webhook bound to the source and reveals its secret once', async () => { + vi.spyOn(gateway, 'createWebhookRepo').mockResolvedValue({ repo: issueHook, secret: 'TEST-secret' }); + renderFactory(); + fireEvent.click(within(await step(1)).getByRole('button', { name: 'Turn on instant updates' })); + await waitFor(() => expect(gateway.createWebhookRepo).toHaveBeenCalledWith({ providerType: 'github', forgeOrigin: repository.forgeOrigin, + repositoryId: repository.id, eventKind: 'ISSUE', sourceId: 'TEST-source', scope: 'repo', target: 'TEST-owner/TEST-repo', enabled: true })); + const reveal = await screen.findByRole('dialog', { name: 'Webhook secret' }); + expect(within(reveal).getByText(/subscribe to Issues and Issue comments/)).toBeInTheDocument(); + expect(changedHooks).toHaveBeenCalledWith([issueHook]); +}); +it('does not create a second issue webhook when the first response was lost', async () => { + vi.mocked(gateway.fetchWebhookRepos).mockResolvedValue([issueHook]); + const create = vi.spyOn(gateway, 'createWebhookRepo'); + renderFactory(); + fireEvent.click(within(await step(1)).getByRole('button', { name: 'Turn on instant updates' })); + expect(await screen.findByRole('alert')).toHaveTextContent('already turned on'); + expect(create).not.toHaveBeenCalled(); +}); +it('shows instant updates as on when the issue webhook exists, and explains Jira has none', async () => { + vi.mocked(sources.fetchWorkSources).mockResolvedValue([source(), source({ id: 'TEST-jira', name: 'TEST-jira source', type: 'JIRA', scope: 'TEST' })]); + renderFactory({ hooks: [issueHook] }); + const first = await step(1); + expect(within(first).getByText('instant updates on')).toBeInTheDocument(); + expect(within(first).getByText(/Jira is polled/)).toBeInTheDocument(); + expect(within(first).queryByRole('button', { name: 'Turn on instant updates' })).toBeNull(); +}); +it('cannot turn on instant updates while webhooks are unavailable', async () => { + renderFactory({ unavailable: true }); + expect(within(await step(1)).getByRole('button', { name: 'Turn on instant updates' })).toBeDisabled(); +}); + +// The request only sets a flag. The button must report the request, not a finished scan. +it('reports a scan request while it is in flight and says when the scanner reads it', async () => { + let release!: () => void; + vi.mocked(sources.rescanWorkSource).mockReturnValue(new Promise(resolve => { release = resolve; })); + renderFactory(); + fireEvent.click(within(await step(1)).getByRole('button', { name: 'Scan TEST-source name now' })); + expect(await screen.findByRole('button', { name: 'Scan TEST-source name now' })).toHaveTextContent('Asking…'); + expect(screen.getByRole('button', { name: 'Scan TEST-source name now' })).toBeDisabled(); + await act(async () => { release(); }); + expect(await screen.findByText(/within about 30 seconds/)).toBeInTheDocument(); +}); diff --git a/spire-ui/src/components/repositories/factory/SourceStep.tsx b/spire-ui/src/components/repositories/factory/SourceStep.tsx new file mode 100644 index 00000000..b0dbcf99 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/SourceStep.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import type { ProviderView, WebhookRepoView } from '../../../api'; +import type { Repository } from '../repositoriesApi'; +import * as api from '../../work-items/workSourcesApi'; +import FactoryStep from './FactoryStep'; +import InstantUpdates from './InstantUpdates'; +import { CreateSourceForm, EditSourceForm, trackerNames } from './SourceForms'; + +export interface SourceStepProps { + repository: Repository; + sources: api.WorkSource[]; + accounts: ProviderView[]; + webhooks: { hooks: WebhookRepoView[]; unavailable: boolean; changed: (hooks: WebhookRepoView[]) => void }; + /** Which form is open anywhere in the tab; only one part changes at a time. */ + open: string | null; + setOpen: (open: string | null) => void; + changed: (notice?: string) => void; +} + +export default function SourceStep({ repository, sources, accounts, webhooks, open, setOpen, changed }: SourceStepProps) { + const [scanError, setScanError] = useState(''); + const [scanning, setScanning] = useState(null); + const reading = sources.some(source => source.enabled); + const adding = open === 'source:new'; + const accountName = (id: string) => accounts.find(account => account.id === id)?.name ?? 'an unavailable account'; + // The request only sets a flag; the scanner picks it up on its next sweep, about half a minute + // later. So the button reports the request, and never claims the scan itself has finished. + async function rescan(source: api.WorkSource) { + setScanError(''); setScanning(source.id); + try { await api.rescanWorkSource(source.id); changed(`Scan requested for ${source.name}. The scanner reads it within about 30 seconds.`); } + catch (failure) { setScanError(String(failure)); } + finally { setScanning(null); } + } + return setOpen('source:new')}>{sources.length ? 'Add another source' : 'Add source'}}> + {!repository.enabled &&

    This repository is disabled. Enable it on the Details tab before it can take tickets.

    } + {sources.length === 0 && !adding &&

    Nothing reads tickets for this repository. Add the tracker whose labels should start work.

    } +
      {sources.map(source =>
    • + {open === `source:${source.id}` + ? changed(`Work source ${source.name} saved.`)} cancelled={() => setOpen(null)} /> + : <> +
      + {source.name} — {trackerNames[source.type] ?? 'Unknown tracker'} {source.type === 'JIRA' ? 'project' : 'issues in'} {source.scope}, + read with {accountName(source.accountId)} + + {source.enabled ? source.health.split('_').join(' ') : 'unavailable'} + + +
      + {!source.enabled && source.configuredEnabled &&

      This source is enabled, but its account or repository is unavailable.

      } + + } +
    • )}
    + {scanError &&

    {scanError}

    } + {adding && setOpen(null)} + saved={created => changed(`Work source ${created.name} registered.`)} />} +
    ; +} diff --git a/spire-ui/src/components/repositories/factory/factoryFixtures.ts b/spire-ui/src/components/repositories/factory/factoryFixtures.ts new file mode 100644 index 00000000..39641755 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/factoryFixtures.ts @@ -0,0 +1,26 @@ +import type { ProviderView, WebhookRepoView } from '../../../api'; +import type { Repository } from '../repositoriesApi'; +import type { Policy, Profile, Phase } from '../../work-items/workPolicyApi'; +import type { WorkSource } from '../../work-items/workSourcesApi'; + +/** Test fixtures only. Every identifier is TEST-prefixed and every host is under example.test. */ +export function account(type = 'github', overrides: Partial = {}): ProviderView { + return { id: `TEST-${type}`, name: `TEST-${type} account`, type, baseUrl: `https://${type}.example.test`, authKind: type === 'atlassian' ? 'basic' : 'bearer', + authUsername: null, hasSecret: true, botAccountId: '900001', enabled: true, authors: [], conversationLevel: null, + role: type === 'atlassian' ? 'CONTEXT' : 'FACTORY', botUsername: 'TEST-bot', createdAt: '2026-09-13T12:00:00Z', lastCheckAt: null, lastCheckOk: null, lastCheckError: null, ...overrides }; +} +export const repository: Repository = { id: 'TEST-repository', scmType: 'github', forgeOrigin: 'https://github.example.test', workspace: 'TEST-owner', slug: 'TEST-repo', + enabled: true, revision: 1, reviewer: null, factory: null }; +export function source(overrides: Partial = {}): WorkSource { + return { id: 'TEST-source', name: 'TEST-source name', type: 'GITHUB', origin: 'https://github.example.test', projectId: '10001', scope: 'TEST-owner/TEST-repo', + repositoryId: repository.id, accountId: 'TEST-github', enabled: true, configuredEnabled: true, version: { source: 4, repository: 1, account: 1 }, cursor: null, + health: 'healthy', allowedPeople: [{ providerUserId: '900123', handle: 'TEST-person', displayName: 'TEST-person name' }], + repository: { workspace: 'TEST-owner', slug: 'TEST-repo' }, ...overrides }; +} +const off: Record = { INTAKE: 'off', SPEC: 'off', PLAN: 'off', BUILD: 'off', VERIFY: 'off', DELIVER: 'off', REVIEW: 'off', LAND: 'off' }; +export const profile: Profile = { id: 'TEST-profile', name: 'TEST-assisted', version: 3, precedence: 10, + modes: { ...off, INTAKE: 'auto', SPEC: 'auto', PLAN: 'approve', BUILD: 'auto' }, + limits: { gateTtlSeconds: 3600, maxRunsPerItem: 5, maxStepsPerPlan: 20, maxWallClockSeconds: 7200, maxCostMillicents: 2_000_000, maxCallsPerItem: 40, protectedPaths: [] } }; +export const policy = (overrides: Partial = {}): Policy => ({ revision: 7, ceiling: profile, mappings: { 'TEST-work': profile }, ...overrides }); +export const issueHook: WebhookRepoView = { id: 'TEST-hook', repositoryId: repository.id, eventKind: 'ISSUE', sourceId: 'TEST-source', forgeOrigin: repository.forgeOrigin, + providerType: 'github', scope: 'repo', target: 'TEST-owner/TEST-repo', webhookKey: 'TEST-key', hasSecret: true, enabled: true, createdAt: '2026-09-15T00:00:00Z' }; diff --git a/spire-ui/src/components/repositories/factory/factoryModel.test.ts b/spire-ui/src/components/repositories/factory/factoryModel.test.ts new file mode 100644 index 00000000..a06d0867 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/factoryModel.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import type { Policy, Profile, Phase } from '../../work-items/workPolicyApi'; +import type { WorkSource } from '../../work-items/workSourcesApi'; +import { clamped, effectiveModes, readiness } from './factoryModel'; + +const off: Record = { INTAKE: 'off', SPEC: 'off', PLAN: 'off', BUILD: 'off', VERIFY: 'off', DELIVER: 'off', REVIEW: 'off', LAND: 'off' }; +const profile = (name: string, modes: Partial>): Profile => ({ id: `TEST-${name}`, name, version: 1, precedence: 1, + modes: { ...off, ...modes }, limits: { gateTtlSeconds: 1, maxRunsPerItem: 0, maxStepsPerPlan: 0, maxWallClockSeconds: 0, maxCostMillicents: 0, maxCallsPerItem: 0, protectedPaths: [] } }); +const source = (enabled: boolean, people: number): WorkSource => ({ id: `TEST-source-${enabled}-${people}`, name: 'TEST-source', type: 'GITHUB', + origin: 'https://github.example.test', projectId: '1', scope: 'TEST-owner/TEST-repo', repositoryId: 'TEST-repository', accountId: 'TEST-account', + enabled, configuredEnabled: enabled, version: { source: 1, repository: 1, account: 1 }, cursor: null, health: 'healthy', + allowedPeople: Array.from({ length: people }, (_, index) => ({ providerUserId: `90000${index}`, handle: `TEST-person-${index}`, displayName: null })), + repository: { workspace: 'TEST-owner', slug: 'TEST-repo' } }); +const assisted = profile('assisted', { INTAKE: 'auto', PLAN: 'approve', BUILD: 'auto', DELIVER: 'pr', LAND: 'auto_if_green' }); +const policy = (ceiling: Profile | null, labels: number): Policy => ({ revision: 1, ceiling, + mappings: Object.fromEntries(Array.from({ length: labels }, (_, index) => [`TEST-label-${index}`, assisted])) }); + +describe('readiness', () => { + it('is ready only when all four parts are set', () => { + expect(readiness([source(true, 1)], policy(assisted, 1))).toMatchObject({ done: 4, ready: true }); + expect(readiness([], null)).toMatchObject({ source: false, people: false, ceiling: false, labels: false, done: 0, ready: false }); + }); + it('does not count people on a source that reads nothing', () => { + // An allowlist on a disabled source admits no ticket, so it must not tick the people step. + expect(readiness([source(false, 2)], policy(assisted, 1))).toMatchObject({ source: false, people: false, done: 2 }); + }); + it('counts people on any reading source when several exist', () => { + expect(readiness([source(true, 0), source(true, 1)], policy(assisted, 1)).people).toBe(true); + }); +}); + +describe('effectiveModes', () => { + it('takes the stricter mode of the label profile and the ceiling in every phase', () => { + const ceiling = profile('ceiling', { INTAKE: 'auto', PLAN: 'auto', BUILD: 'approve', DELIVER: 'draft_pr', LAND: 'approve' }); + expect(effectiveModes(assisted, ceiling)).toEqual({ ...off, INTAKE: 'auto', PLAN: 'approve', BUILD: 'approve', DELIVER: 'draft_pr', LAND: 'approve' }); + expect(clamped(assisted, ceiling)).toBe(true); + }); + it('leaves a profile inside its ceiling unchanged', () => { + expect(effectiveModes(assisted, assisted)).toEqual(assisted.modes); + expect(clamped(assisted, assisted)).toBe(false); + }); + it('never renders an unknown mode as one that runs', () => { + const odd = profile('odd', { INTAKE: 'TEST-unknown' }); + expect(effectiveModes(odd, null).INTAKE).toBe('off'); + }); +}); diff --git a/spire-ui/src/components/repositories/factory/factoryModel.ts b/spire-ui/src/components/repositories/factory/factoryModel.ts new file mode 100644 index 00000000..d33b6f13 --- /dev/null +++ b/spire-ui/src/components/repositories/factory/factoryModel.ts @@ -0,0 +1,56 @@ +import * as policyApi from '../../work-items/workPolicyApi'; +import type { WorkSource } from '../../work-items/workSourcesApi'; + +export interface Readiness { + source: boolean; + people: boolean; + ceiling: boolean; + labels: boolean; + /** How many of the four parts are set, for the list column. */ + done: number; + ready: boolean; +} + +/** + * Whether each part of a repository's setup can actually let work start. A disabled source reads + * nothing, and people only count on a source that reads, so a source switched off takes the people + * step down with it rather than leaving a green tick that admits nothing. + */ +export function readiness(sources: WorkSource[], policy: policyApi.Policy | null): Readiness { + const reading = sources.filter(source => source.enabled); + const parts = { + source: reading.length > 0, + people: reading.some(source => source.allowedPeople.length > 0), + ceiling: !!policy?.ceiling, + labels: !!policy && Object.keys(policy.mappings).length > 0, + }; + const done = Object.values(parts).filter(Boolean).length; + return { ...parts, done, ready: done === 4 }; +} + +function vocabulary(phase: policyApi.Phase): string[] { + if (phase === 'DELIVER') return ['off', 'draft_pr', 'pr']; + if (phase === 'LAND') return ['off', 'approve', 'auto_if_green']; + return ['off', 'approve', 'auto']; +} + +/** + * What a label actually runs once the ceiling has cut it back: per phase, the stricter of the two. + * This mirrors WorkPolicy.select for display only — the server decides. A mode this screen does not + * know ranks as off, so an unrecognised value can never render as a phase that runs. + */ +export function effectiveModes(profile: policyApi.Profile, ceiling: policyApi.Profile | null): Record { + const result = {} as Record; + for (const phase of policyApi.phases) { + const words = vocabulary(phase); + const rank = (modes: Record | undefined) => Math.max(0, words.indexOf(modes?.[phase] ?? 'off')); + result[phase] = words[ceiling ? Math.min(rank(profile.modes), rank(ceiling.modes)) : rank(profile.modes)]; + } + return result; +} + +/** True when the ceiling takes anything away from what the label asked for. */ +export function clamped(profile: policyApi.Profile, ceiling: policyApi.Profile | null): boolean { + const effective = effectiveModes(profile, ceiling); + return policyApi.phases.some(phase => effective[phase] !== (vocabulary(phase).includes(profile.modes[phase]) ? profile.modes[phase] : 'off')); +} diff --git a/spire-ui/src/components/repositories/factory/presets.test.ts b/spire-ui/src/components/repositories/factory/presets.test.ts new file mode 100644 index 00000000..21e91c0b --- /dev/null +++ b/spire-ui/src/components/repositories/factory/presets.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as api from '../../work-items/workPolicyApi'; +import { applyPresets, planPresets, PRESET_LIMITS } from './presets'; + +const off: Record = { INTAKE: 'off', SPEC: 'off', PLAN: 'off', BUILD: 'off', VERIFY: 'off', DELIVER: 'off', REVIEW: 'off', LAND: 'off' }; +const existing = (name: string, precedence: number, version = 1): api.Profile => ({ id: `TEST-${name}`, name, version, precedence, modes: off, limits: PRESET_LIMITS }); +const empty: api.Policy = { revision: 3, ceiling: null, mappings: {} }; +let ids = 0; +const newId = () => `TEST-new-${++ids}`; +beforeEach(() => { ids = 0; }); + +describe('planPresets', () => { + it('creates the three documented profiles in ascending precedence', () => { + const plan = planPresets([], empty, newId); + expect(plan.map(step => [step.preset.label, step.created?.name, step.created?.precedence])).toEqual([ + ['spire:suggest', 'suggest', 10], ['spire:assisted', 'assisted', 20], ['spire:auto', 'autonomous', 30]]); + // Phases the document omits are off, never auto. + expect(plan[0].created!.modes).toEqual({ ...off, INTAKE: 'auto', SPEC: 'auto', PLAN: 'auto' }); + expect(plan[1].created!.modes.PLAN).toBe('approve'); + }); + it('reuses a profile that already has the name, at its newest version, unchanged', () => { + const plan = planPresets([existing('assisted', 20, 1), existing('assisted', 20, 4)], empty, newId); + expect(plan[1].existing?.version).toBe(4); + expect(plan[1].created).toBeNull(); + }); + it('steps past a precedence another profile already holds', () => { + // Precedence is unique on the server; a collision would refuse the whole save. + const plan = planPresets([existing('TEST-other', 10), existing('TEST-another', 11)], empty, newId); + expect(plan.map(step => step.created!.precedence)).toEqual([12, 20, 30]); + }); + it('keeps a label another profile already answers to and reports it', () => { + const other = existing('TEST-other', 5); + const plan = planPresets([other], { ...empty, mappings: { 'spire:auto': other } }, newId); + expect(plan[2].conflict).toEqual(other); + expect(plan[0].conflict).toBeNull(); + }); +}); + +describe('applyPresets', () => { + beforeEach(() => { + vi.spyOn(api, 'saveProfile').mockImplementation(async profile => profile); + vi.spyOn(api, 'savePolicy').mockImplementation(async (_id, input) => ({ revision: input.revision + 1, ceiling: null, mappings: {} })); + }); + it('creates only what is missing and pins the chosen ceiling and the labels', async () => { + const suggest = existing('suggest', 10, 2); + const plan = planPresets([suggest], empty, newId); + await applyPresets('TEST-repository', empty, plan, 'suggest'); + expect(api.saveProfile).toHaveBeenCalledTimes(2); + expect(api.savePolicy).toHaveBeenCalledWith('TEST-repository', { revision: 3, ceiling: { id: 'TEST-suggest', version: 2 }, mappings: { + 'spire:suggest': { id: 'TEST-suggest', version: 2 }, 'spire:assisted': { id: 'TEST-new-1', version: 1 }, 'spire:auto': { id: 'TEST-new-2', version: 1 } } }); + }); + it('does not overwrite a label that already maps to something else', async () => { + const other = existing('TEST-other', 5); + const policy = { ...empty, mappings: { 'spire:auto': other } }; + await applyPresets('TEST-repository', policy, planPresets([other], policy, newId), 'assisted'); + expect(vi.mocked(api.savePolicy).mock.calls[0][1].mappings['spire:auto']).toEqual({ id: other.id, version: 1 }); + }); +}); diff --git a/spire-ui/src/components/repositories/factory/presets.ts b/spire-ui/src/components/repositories/factory/presets.ts new file mode 100644 index 00000000..0bf8455e --- /dev/null +++ b/spire-ui/src/components/repositories/factory/presets.ts @@ -0,0 +1,87 @@ +import * as policyApi from '../../work-items/workPolicyApi'; +import { pin } from '../../work-items/policyInput'; + +export interface Preset { + label: string; + name: string; + modes: Partial>; +} + +/** + * The three profiles and labels docs/factory/AUTONOMY.md §2 defines, in order from most to least + * restrictive. A phase the document omits is off, as it is everywhere else in the policy model. + */ +export const PRESETS: Preset[] = [ + { label: 'spire:suggest', name: 'suggest', modes: { INTAKE: 'auto', SPEC: 'auto', PLAN: 'auto' } }, + { label: 'spire:assisted', name: 'assisted', modes: { + INTAKE: 'auto', SPEC: 'auto', PLAN: 'approve', BUILD: 'auto', VERIFY: 'auto', DELIVER: 'draft_pr', REVIEW: 'auto', LAND: 'approve' } }, + { label: 'spire:auto', name: 'autonomous', modes: { + INTAKE: 'auto', SPEC: 'auto', PLAN: 'auto', BUILD: 'auto', VERIFY: 'auto', DELIVER: 'pr', REVIEW: 'auto', LAND: 'auto_if_green' } }, +]; + +/** + * The caps from the same section. The document names no approval lifetime, so this uses the one a + * new profile already starts with on the Profiles screen. + */ +export const PRESET_LIMITS: policyApi.Limits = { + gateTtlSeconds: 86400, maxRunsPerItem: 5, maxStepsPerPlan: 20, maxWallClockSeconds: 7200, + maxCostMillicents: 2_000_000, maxCallsPerItem: 40, protectedPaths: ['**/security/**', '.github/**', 'deploy/**'], +}; + +export interface PresetPlan { + preset: Preset; + /** The current version of a profile that already has this name. It is used as it is, never changed. */ + existing: policyApi.Profile | null; + /** The profile that will be created when none has this name. */ + created: policyApi.Profile | null; + /** What the label already maps to on this repository, when it is not this preset. That mapping stays. */ + conflict: policyApi.Profile | null; +} + +function current(profiles: policyApi.Profile[], name: string) { + return profiles.filter(profile => profile.name === name).sort((a, b) => b.version - a.version)[0] ?? null; +} + +/** + * What applying the presets would do, before anything is written. Names and precedences are unique + * on the server, so an existing name is reused and a new profile takes the first free precedence at or + * above its slot — kept ascending, because when two labels apply the lowest precedence wins, and the + * most restrictive preset should be the one that wins. + */ +export function planPresets(profiles: policyApi.Profile[], policy: policyApi.Policy, newId: () => string): PresetPlan[] { + const taken = new Set(profiles.map(profile => profile.precedence)); + let floor = 0; + return PRESETS.map((preset, index) => { + const existing = current(profiles, preset.name); + const mapped = policy.mappings[preset.label] ?? null; + let created: policyApi.Profile | null = null; + if (existing) floor = Math.max(floor, existing.precedence + 1); + else { + let precedence = Math.max(floor, (index + 1) * 10); + while (taken.has(precedence)) precedence++; + taken.add(precedence); + floor = precedence + 1; + const modes = Object.fromEntries(policyApi.phases.map(phase => [phase, preset.modes[phase] ?? 'off'])) as Record; + created = { id: newId(), name: preset.name, version: 1, precedence, modes, limits: PRESET_LIMITS }; + } + const target = existing ?? created!; + const conflict = mapped && !(mapped.id === target.id) ? mapped : null; + return { preset, existing, created, conflict }; + }); +} + +/** + * Creates the missing profiles, then saves the repository policy with the chosen ceiling and every + * preset label that is not already mapped to something else. Safe to repeat after a partial failure: + * a profile created on the first attempt is found by name on the second. + */ +export async function applyPresets(repositoryId: string, policy: policyApi.Policy, plan: PresetPlan[], ceilingName: string) { + const resolved = new Map(); + for (const step of plan) resolved.set(step.preset.name, step.existing ?? await policyApi.saveProfile(step.created!)); + const ceiling = resolved.get(ceilingName); + if (!ceiling) throw new Error('Choose one of the presets as the ceiling.'); + const mappings: Record = Object.fromEntries(Object.entries(policy.mappings).map(([label, profile]) => [label, pin(profile)])); + for (const step of plan) if (!step.conflict) mappings[step.preset.label] = pin(resolved.get(step.preset.name)!); + return policyApi.savePolicy(repositoryId, { revision: policy.revision, ceiling: pin(ceiling), mappings }); +} + diff --git a/spire-ui/src/components/repositories/repositoriesApi.ts b/spire-ui/src/components/repositories/repositoriesApi.ts new file mode 100644 index 00000000..e80a0113 --- /dev/null +++ b/spire-ui/src/components/repositories/repositoriesApi.ts @@ -0,0 +1,63 @@ +import { apiFetch } from '../../auth'; + +export interface RepositoryAccount { + id: string; + name: string; + role: string; + handle: string | null; + state: string; +} +export interface Repository { + id: string; + scmType: string; + forgeOrigin: string; + workspace: string; + slug: string; + enabled: boolean; + revision: number; + reviewer: RepositoryAccount | null; + factory: RepositoryAccount | null; +} +export interface RepositoryInput { + scmType: string; + forgeOrigin: string; + workspace: string; + slug: string; + enabled: boolean; + reviewerAccountId: string | null; + factoryAccountId: string | null; +} +export interface PendingMapping { + registrationId: string; revision: number; scmType: string; + forgeOrigin: string | null; target: string; problem: string; +} +export async function fetchPendingMappings(): Promise { + const response = await apiFetch('/api/repositories/pending'); + if (!response.ok) throw new Error('Could not load pending mappings'); + return response.json(); +} +export async function linkMapping(pending: PendingMapping, repositoryId: string): Promise { + const response = await apiFetch(`/api/repositories/pending/${pending.registrationId}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repositoryId, revision: pending.revision }), + }); + if (!response.ok) throw new Error(await response.text() || 'Could not link registration'); +} +export async function fetchRepositories(): Promise { + const response = await apiFetch('/api/repositories'); + if (!response.ok) throw new Error('Could not load registered repositories'); + return response.json(); +} +export async function fetchRepositoryKinds(): Promise { + const response = await apiFetch('/api/repositories/kinds'); + if (!response.ok) throw new Error('Could not load supported forge kinds'); + return response.json(); +} +export async function saveRepository(input: RepositoryInput, initial: Repository | null): Promise { + const path = initial ? `/api/repositories/${initial.id}?revision=${initial.revision}` : '/api/repositories'; + const response = await apiFetch(path, { + method: initial ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), + }); + if (!response.ok) throw new Error(await response.text() || 'Could not save repository'); + return response.json(); +} diff --git a/spire-ui/src/components/work-items/DecisionEvidence.tsx b/spire-ui/src/components/work-items/DecisionEvidence.tsx new file mode 100644 index 00000000..0257d7fa --- /dev/null +++ b/spire-ui/src/components/work-items/DecisionEvidence.tsx @@ -0,0 +1,71 @@ +import type { WorkItemDetail } from '../../api'; +import { formatEventTime } from '../../format'; +import { formatCost } from '../../money'; +import CopyField from '../CopyField'; +import type { Approval } from './approvalsApi'; +import { workRefusal } from './workReasons'; +import type { PreparationEvidence } from './workPreparationApi'; + +interface Props { + item: WorkItemDetail; + approval: Approval; + evidence: PreparationEvidence | null; + evidenceError: string; +} + +const HOUR_MILLISECONDS = 3_600_000; + +function hours(seconds: number) { + const value = seconds / 3600; + return value === 1 ? '1 hour' : `${Number.isInteger(value) ? value : value.toFixed(1)} hours`; +} + +/** "in 23 h", or "expired" — relative, because an absolute timestamp does not say whether to hurry. */ +function expiresIn(iso: string, now: number) { + const left = new Date(iso).getTime() - now; + if (left <= 0) return 'expired'; + return left < HOUR_MILLISECONDS ? `in ${Math.max(1, Math.round(left / 60_000))} min` : `in ${Math.round(left / HOUR_MILLISECONDS)} h`; +} + +/** + * The parts of a decision an approver has to read before answering: what is bound, what approving + * starts, how long it stays open and where else it can be answered. Every value is the recorded one; + * a ticket that moved since registration is named instead of shown, so its unapproved text never + * reads as the evidence. + */ +export default function DecisionEvidence({ item, approval, evidence, evidenceError }: Props) { + const { gate } = approval; + const preparation = item.preparation; + const limits = item.effectiveLimits; + const expiry = expiresIn(gate.expiresAt, Date.now()); + return
    + {preparation &&
    +

    What you approve

    + {evidenceError &&

    The tickets could not be read: {evidenceError}

    } + {evidence?.reason &&

    {workRefusal(evidence.reason, evidence.detail)}

    } +
    + +
    Starts from
    {preparation.baseBranch} @ {preparation.baseCommit.slice(0, 7)}
    +
    Agent
    {preparation.harness} · {preparation.model}
    + +
    + {evidence?.specification &&
    {evidence.specification}
    } + {evidence?.instruction &&
    {evidence.instruction}
    } +
    } +
    +

    If you approve

    + {gate.phase === 'plan' && limits + ?

    One build starts. It stops at {formatCost(limits.maxCostMillicents)} or after {hours(limits.maxWallClockSeconds)}, and this item may use up to {limits.maxRunsPerItem} runs.

    + :

    The {gate.phase} phase continues within this item's limits.

    } +
    +
    +

    Time

    +

    Opened {formatEventTime(gate.openedAt)} · {expiry === 'expired' ? 'expired' : `expires ${expiry}`}

    +
    + {approval.trackerCommand &&
    +

    Or answer on the ticket

    + + {gate.phase === 'land' &&

    {approval.prReviewAvailable ? 'Approving the pull request review also answers this decision.' : approval.prReviewDetail}

    } +
    } +
    ; +} diff --git a/spire-ui/src/components/work-items/DecisionPanel.test.tsx b/spire-ui/src/components/work-items/DecisionPanel.test.tsx new file mode 100644 index 00000000..8aeaca1d --- /dev/null +++ b/spire-ui/src/components/work-items/DecisionPanel.test.tsx @@ -0,0 +1,210 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import * as gateway from '../../api'; +import * as auth from '../../auth'; +import * as api from './approvalsApi'; +import * as preparation from './workPreparationApi'; +import DecisionPanel from './DecisionPanel'; +import PastDecisions from './PastDecisions'; + +const artifact = (key: string): preparation.Artifact => ({ sha256: key.repeat(64).slice(0, 64), + location: { ref: { type: 'GITHUB', origin: 'https://TEST.example', projectId: 'TEST-project', issueId: `TEST-${key}` }, issueKey: key, link: `https://TEST.example/issues/${key}` } }); +const row: api.Approval = { workItemId: 'TEST-item', issueKey: 'TEST-42', trackerCommand: '/approve TEST-gate 2 TEST-artifact', gate: { id: 'TEST-gate', version: 7, state: 'OPEN', phase: 'plan', generation: 2, + itemRevision: 11, policyRevision: 3, artifact: 'TEST-sha256', openedAt: '2026-09-13T12:00:00Z', expiresAt: '2999-09-14T12:00:00Z', resolver: null, channel: null, note: null } }; +const item = { id: 'TEST-item', sourceId: 'TEST-source', repositoryId: 'TEST-repository', repository: 'TEST-owner/TEST-repo', issueKey: 'TEST-42', + trackerUrl: 'https://TEST.example/issues/42', generation: 2, phase: 'plan', workflowStatus: 'waiting_approval', reason: 'approval_required', profile: null, + revision: 11, updatedAt: '2026-09-13T12:00:00Z', effectiveModes: {}, admittedModes: {}, policyReason: 'policy_selected', ceiling: null, appliedLabels: [], ignoredLabels: [], events: [], + effectiveLimits: { gateTtlSeconds: 86400, maxRunsPerItem: 5, maxStepsPerPlan: 20, maxWallClockSeconds: 7200, maxCostMillicents: 2_000_000, maxCallsPerItem: 40, protectedPaths: [] }, + preparation: { specification: artifact('71'), plan: artifact('72'), baseBranch: 'main', baseCommit: 'a0f8a41'.padEnd(40, '0'), harness: 'TEST-harness', model: 'TEST-model', registeredBy: 'TEST-operator' }, +} satisfies gateway.WorkItemDetail; +const evidence: preparation.PreparationEvidence = { reason: null, detail: null, specification: 'TEST-specification text', instruction: 'TEST-the one step', binding: row.gate.artifact! }; +const decided = vi.fn(); + +afterEach(cleanup); +beforeEach(() => { + decided.mockReset(); + vi.spyOn(auth, 'fetchMe').mockResolvedValue({ authEnabled: true, authenticated: true, user: 'TEST-admin', roles: ['spire-admin'] }); + vi.spyOn(api, 'approvals').mockResolvedValue([row]); + vi.spyOn(api, 'answer').mockResolvedValue(); + vi.spyOn(gateway, 'getWorkItem').mockResolvedValue(item); + vi.spyOn(preparation, 'preparationEvidence').mockResolvedValue(evidence); +}); +/** Approve is offered only once the bound texts are on screen; a test that approves waits for that. */ +async function approvable() { + const button = await screen.findByRole('button', { name: 'Approve' }); + await waitFor(() => expect(button).toBeEnabled()); + return button; +} +function show() { return render(); } + +// The old card showed a digest and a generation number. An approver has to see what they approve. +it('shows what the gate binds before offering an answer', async () => { + show(); + expect(await screen.findByText('TEST-the one step')).toBeInTheDocument(); + expect(screen.getByText('TEST-specification text')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '#71' })).toHaveAttribute('href', 'https://TEST.example/issues/71'); + expect(screen.getByText('main @ a0f8a41')).toBeInTheDocument(); + expect(screen.getByText('TEST-harness · TEST-model')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'If you approve' })).toHaveTextContent('One build starts. It stops at $20.000 or after 2 hours, and this item may use up to 5 runs.'); + expect(screen.getByText('TEST-ticket title · TEST-42 · TEST-owner/TEST-repo')).toBeInTheDocument(); +}); +it('names a moved ticket instead of showing its unapproved text', async () => { + vi.mocked(preparation.preparationEvidence).mockResolvedValue({ reason: 'artifacts_changed', detail: 'plan_changed', specification: null, instruction: null }); + show(); + expect(await screen.findByRole('alert')).toHaveTextContent('The plan ticket changed after it was checked.'); + expect(screen.queryByLabelText('The step the build runs')).toBeNull(); +}); +it('says so when the item has no open decision', async () => { + vi.mocked(api.approvals).mockResolvedValue([]); + show(); + expect(await screen.findByText(/has no open decision/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull(); +}); +it('submits the displayed gate version and reports the decision', async () => { + show(); fireEvent.change(await screen.findByLabelText('Decision note', { selector: 'textarea' }), { target: { value: 'TEST-reviewed' } }); + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + await waitFor(() => expect(decided).toHaveBeenCalledWith('Approved the plan decision. The item continues.')); + expect(api.answer).toHaveBeenCalledWith(row.gate, expect.any(String), true, 'TEST-reviewed'); +}); +it('keeps the decision open after a failed answer', async () => { + vi.mocked(api.answer).mockRejectedValue(new Error('TEST-409 decision changed')); + show(); fireEvent.click(await approvable()); + expect(await screen.findByRole('alert')).toHaveTextContent('TEST-409 decision changed'); + expect(screen.getByRole('button', { name: 'Approve' })).toBeEnabled(); + expect(decided).not.toHaveBeenCalled(); +}); +it('reuses an answer identity after transport failure but replaces it for a different decision', async () => { + vi.mocked(api.answer).mockRejectedValue(new Error('TEST-timeout')); + show(); fireEvent.click(await approvable()); await screen.findByRole('alert'); + const first = vi.mocked(api.answer).mock.calls[0][1]; + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); await waitFor(() => expect(api.answer).toHaveBeenCalledTimes(2)); + await screen.findByRole('alert'); + expect(vi.mocked(api.answer).mock.calls[1][1]).toBe(first); + fireEvent.click(screen.getByRole('button', { name: 'Reject' })); await waitFor(() => expect(api.answer).toHaveBeenCalledTimes(3)); + expect(vi.mocked(api.answer).mock.calls[2][1]).not.toBe(first); +}); +it('gives an edited note a new answer identity', async () => { + vi.mocked(api.answer).mockRejectedValue(new Error('TEST-timeout')); + show(); fireEvent.click(await approvable()); await screen.findByRole('alert'); + const first = vi.mocked(api.answer).mock.calls[0][1]; + fireEvent.change(screen.getByLabelText('Decision note', { selector: 'textarea' }), { target: { value: 'TEST-revised note' } }); + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); await waitFor(() => expect(api.answer).toHaveBeenCalledTimes(2)); + expect(vi.mocked(api.answer).mock.calls[1][1]).not.toBe(first); +}); +// A locked panel whose buttons keep their names looks like a click that did nothing. +it('names the answer it is recording and locks every control until the server replies', async () => { + let release!: () => void; + vi.mocked(api.answer).mockReturnValue(new Promise(resolve => { release = resolve; })); + show(); fireEvent.click(await approvable()); + expect(await screen.findByRole('button', { name: 'Approving…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reject' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + // The header close sits outside the panel's fieldset; closing mid-answer hides an answer still landing. + expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled(); + expect(screen.getByText(/Recording your decision/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Approving…' })); + expect(api.answer).toHaveBeenCalledTimes(1); + await act(async () => { release(); }); +}); +it('names a rejection separately from an approval', async () => { + let release!: () => void; + vi.mocked(api.answer).mockReturnValue(new Promise(resolve => { release = resolve; })); + show(); fireEvent.click(await screen.findByRole('button', { name: 'Reject' })); + expect(await screen.findByRole('button', { name: 'Rejecting…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); + await act(async () => { release(); }); +}); +it('never offers answers or reads ticket bodies for a viewer', async () => { + vi.mocked(auth.fetchMe).mockResolvedValue({ authEnabled: true, authenticated: true, user: 'TEST-viewer', roles: ['spire-viewer'] }); + show(); + expect(await screen.findByText('Only an administrator can answer this decision.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull(); + expect(preparation.preparationEvidence).not.toHaveBeenCalled(); +}); +it('offers the tracker command, and mentions pull request reviews only for a land decision', async () => { + vi.mocked(api.approvals).mockResolvedValue([{ ...row, prReviewAvailable: false, prReviewDetail: 'TEST-forge cannot prove current approval' }]); + show(); + expect(await screen.findByText('/approve TEST-gate 2 TEST-artifact')).toBeInTheDocument(); + expect(screen.queryByText('TEST-forge cannot prove current approval')).toBeNull(); + cleanup(); + vi.mocked(api.approvals).mockResolvedValue([{ ...row, gate: { ...row.gate, phase: 'land' }, prReviewAvailable: false, prReviewDetail: 'TEST-forge cannot prove current approval' }]); + show(); + expect(await screen.findByText('TEST-forge cannot prove current approval')).toBeInTheDocument(); +}); +// A superseded plan decision is answered with the ticket that moved; the approver re-reads that one. +it('names the ticket that moved when a decision is superseded', async () => { + vi.mocked(api.answer).mockRestore(); + vi.spyOn(auth, 'apiFetch').mockResolvedValue(new Response(JSON.stringify({ reason: 'artifacts_changed_requires_new_decision', detail: 'specification_changed' }), { status: 409 })); + show(); fireEvent.click(await approvable()); + expect(await screen.findByRole('alert')).toHaveTextContent('The decision changed. The specification ticket changed after it was checked.'); +}); + +it('lists past decisions with who answered them', async () => { + vi.mocked(api.approvals).mockResolvedValue([{ ...row, gate: { ...row.gate, state: 'APPROVED', resolver: 'TEST-admin', channel: 'dashboard' } }]); + render(); + expect(await screen.findByRole('link', { name: 'TEST-42' })).toHaveAttribute('href', '/work-items/TEST-item'); + expect(screen.getByText(/by TEST-admin via dashboard/)).toBeInTheDocument(); + expect(api.approvals).toHaveBeenCalledWith(true); +}); +it('says when no decision is closed yet', async () => { + vi.mocked(api.approvals).mockResolvedValue([]); + render(); + expect(await screen.findByText(/No decision has been answered/)).toBeInTheDocument(); +}); + +// Review finding: Approve was offered before, and without, the texts it promises to show. +it('offers Approve only once the bound texts are on screen, and keeps Reject', async () => { + let release!: (value: preparation.PreparationEvidence) => void; + vi.mocked(preparation.preparationEvidence).mockReturnValue(new Promise(resolve => { release = resolve; })); + show(); + expect(await screen.findByRole('button', { name: 'Approve' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reject' })).toBeEnabled(); + expect(screen.getByText(/Reading the tickets/)).toBeInTheDocument(); + await act(async () => release(evidence)); + expect(screen.getByRole('button', { name: 'Approve' })).toBeEnabled(); +}); +it('keeps Approve unavailable when the tickets cannot be read or have moved', async () => { + vi.mocked(preparation.preparationEvidence).mockRejectedValue(new Error('TEST-tracker down')); + show(); + expect(await screen.findByRole('alert')).toHaveTextContent('TEST-tracker down'); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); + cleanup(); + vi.mocked(preparation.preparationEvidence).mockResolvedValue({ ...evidence, reason: 'artifacts_changed', detail: 'plan_changed', specification: null, instruction: null }); + show(); + expect(await screen.findByRole('alert')).toHaveTextContent('The plan ticket changed'); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); +}); +it('refuses to show texts read for another preparation than the decision binds', async () => { + vi.mocked(preparation.preparationEvidence).mockResolvedValue({ ...evidence, binding: 'TEST-a newer binding', instruction: 'TEST-a newer step' }); + show(); + expect(await screen.findByRole('alert')).toHaveTextContent('The prepared task changed after this decision was opened'); + expect(screen.queryByText('TEST-a newer step')).toBeNull(); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); +}); + +// Review finding: a server that does not report the binding left Approve off with a message that did not say why. +it('says the server must report the binding when it does not', async () => { + vi.mocked(preparation.preparationEvidence).mockResolvedValue({ ...evidence, binding: undefined }); + show(); + expect(await screen.findByRole('alert')).toHaveTextContent('The server does not say which prepared version'); + expect(screen.queryByText('TEST-the one step')).toBeNull(); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); +}); +// A land decision binds a built commit, not the task texts, so it must not wait for them. +it('does not hold a land decision back on the task texts', async () => { + vi.mocked(api.approvals).mockResolvedValue([{ ...row, gate: { ...row.gate, phase: 'land', artifact: 'b'.repeat(40) } }]); + vi.mocked(preparation.preparationEvidence).mockReturnValue(new Promise(() => {})); + show(); + await approvable(); + expect(screen.queryByText(/Reading the tickets/)).toBeNull(); +}); + +// Review finding: a decision bound to a preparation the item no longer has skipped the text check entirely. +it('offers no Approve for a decision whose prepared task the item no longer has', async () => { + vi.mocked(gateway.getWorkItem).mockResolvedValue({ ...item, preparation: null }); + show(); + expect(await screen.findByRole('alert')).toHaveTextContent('binds a prepared task the item no longer has'); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reject' })).toBeEnabled(); +}); diff --git a/spire-ui/src/components/work-items/DecisionPanel.tsx b/spire-ui/src/components/work-items/DecisionPanel.tsx new file mode 100644 index 00000000..6711b3e0 --- /dev/null +++ b/spire-ui/src/components/work-items/DecisionPanel.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react'; +import { getWorkItem, type WorkItemDetail } from '../../api'; +import { canAdminister } from '../../auth'; +import { useMe } from '../../hooks/useMe'; +import SidePanel from '../SidePanel'; +import SettingField from '../SettingField'; +import DecisionEvidence from './DecisionEvidence'; +import * as approvalsApi from './approvalsApi'; +import { preparationEvidence, type PreparationEvidence } from './workPreparationApi'; + +interface Props { + itemId: string; + /** The ticket title the list already read, so the panel does not read the tracker a second time. */ + title: string | null; + onClose: () => void; + onDecided: (notice: string) => void; +} + +interface Loaded { approval: approvalsApi.Approval | null; item: WorkItemDetail } + +/** + * One open decision, beside the list it came from. It shows what the gate binds — the specification, + * the step, the tree it starts from, the agent and the limits — before it offers Approve, because an + * approval is a promise to spend within those limits and the old card showed none of it. + */ +export default function DecisionPanel({ itemId, title, onClose, onDecided }: Props) { + const { me } = useMe(); + const admin = canAdminister(me); + const [loaded, setLoaded] = useState(null), [error, setError] = useState(''); + const [note, setNote] = useState(''), [answering, setAnswering] = useState(null); + const [evidence, setEvidence] = useState<{ value: PreparationEvidence | null; error: string }>({ value: null, error: '' }); + // Reuse an answer identity after a transport failure, but never attach it to a different gate, answer or note. + const attempt = useRef<{ key: string; gate: string; approve: boolean; note: string } | null>(null); + const live = useRef(true); + useEffect(() => { live.current = true; return () => { live.current = false; }; }, []); + + useEffect(() => { + let current = true; + setLoaded(null); setError(''); + Promise.all([approvalsApi.approvals(false), getWorkItem(itemId)]).then(([open, item]) => { + if (current) setLoaded({ approval: open.find(row => row.workItemId === itemId) ?? null, item }); + }).catch(failure => { if (current) setError(String(failure)); }); + return () => { current = false; }; + }, [itemId]); + + // The ticket texts are an admin read, loaded beside the decision rather than before it: the session + // answers after the panel opens, and waiting for it must not blank a decision already on screen. + const gateKey = loaded?.approval ? `${loaded.approval.gate.id}:${loaded.approval.gate.version}` : null; + const preparation = loaded?.item.preparation ?? null; + const prepared = preparation ? `${preparation.specification.sha256}:${preparation.plan.sha256}` : null; + useEffect(() => { + let current = true; + setEvidence({ value: null, error: '' }); + if (!admin || !gateKey || !prepared) return; + preparationEvidence(itemId).then(value => { if (current) setEvidence({ value, error: '' }); }) + .catch(failure => { if (current) setEvidence({ value: null, error: String(failure) }); }); + return () => { current = false; }; + }, [itemId, admin, gateKey, prepared]); + + async function decide(approve: boolean) { + const gate = loaded?.approval?.gate; + if (!gate) return; + const previous = attempt.current, bound = `${gate.id}:${gate.version}`; + const decision = previous && previous.gate === bound && previous.approve === approve && previous.note === note + ? previous : { key: crypto.randomUUID(), gate: bound, approve, note }; + attempt.current = decision; setAnswering(approve); setError(''); + try { + await approvalsApi.answer(gate, decision.key, approve, note); + if (live.current) onDecided(approve ? `Approved the ${gate.phase} decision. The item continues.` : `Rejected the ${gate.phase} decision. The item stops here.`); + } catch (failure) { if (live.current) setError(String(failure)); } + finally { if (live.current) setAnswering(null); } + } + + const gate = loaded?.approval?.gate; + const answeringNow = answering !== null; + // Approving a plan is a promise to build what the panel shows, so Approve waits until the texts on + // screen were read against the very binding the decision stores. A land decision binds a commit, + // not these texts, so it does not wait for them. Rejecting needs no evidence and stays available. + const needsTexts = !!preparation && !!gate && gate.phase !== 'land'; + // The decision and the item are read separately. A decision that binds a preparation the item no + // longer has cannot be matched to any texts, so it is not approvable either. + const orphaned = !preparation && !!gate && gate.phase !== 'land' && gate.artifact !== null; + const readable = !!evidence.value && !evidence.value.reason; + const reported = evidence.value?.binding; + const unreported = needsTexts && readable && reported === undefined; + const mismatched = needsTexts && readable && reported !== undefined && reported !== gate?.artifact; + const bound = !orphaned && (!needsTexts || readable && reported !== undefined && reported === gate?.artifact); + const heading = gate ? `Approve the ${gate.phase}` : 'Decision'; + const subtitle = !loaded ? undefined : title ? `${title} · ${loaded.item.issueKey} · ${loaded.item.repository}` : `${loaded.item.issueKey} · ${loaded.item.repository}`; + return + {/* The footer sits outside the panel's fieldset, so the lock is repeated here. */} + {admin && gate && } + {admin && gate && } + + }> + {!loaded && !error &&

    Loading the decision…

    } + {loaded && !gate &&

    This item has no open decision. It may have been answered, expired or replaced.

    } + {loaded && gate && <> + + {mismatched &&

    The prepared task changed after this decision was opened, so these tickets are not what it binds. Approve is not offered; answering only closes this decision. Register the current versions to get a new one.

    } + {orphaned &&

    This decision binds a prepared task the item no longer has. Approve is not offered; answering only closes this decision.

    } + {unreported &&

    The server does not say which prepared version these tickets belong to, so Approve is not offered. Update the orchestrator to the version this dashboard expects.

    } + {admin && needsTexts && !evidence.value && !evidence.error &&

    Reading the tickets. Approve is offered once they are on screen.

    } + {admin ? +