@bevel-software/platform-core-backend 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +6118 -0
- package/dist/core/core-ports.d.ts +15 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +40 -6
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +17 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +153 -29
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +70 -21
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +134 -58
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +6 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +74 -5
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +116 -15
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +93 -0
- package/dist/modules/access/access-declarations.d.ts.map +1 -0
- package/dist/modules/access/access-declarations.js +168 -0
- package/dist/modules/access/access-declarations.js.map +1 -0
- package/dist/modules/access/access-mutation.service.js +4 -4
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +16 -1
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -21
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +51 -0
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +8 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +21 -2
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +3 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +50 -1
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +39 -10
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +38 -16
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +124 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +45 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/groups/group-provision.service.d.ts +130 -0
- package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
- package/dist/modules/groups/group-provision.service.js +288 -0
- package/dist/modules/groups/group-provision.service.js.map +1 -0
- package/dist/modules/groups/groups.contract.d.ts +106 -0
- package/dist/modules/groups/groups.contract.d.ts.map +1 -0
- package/dist/modules/groups/groups.contract.js +36 -0
- package/dist/modules/groups/groups.contract.js.map +1 -0
- package/dist/modules/groups/groups.routes.d.ts +42 -0
- package/dist/modules/groups/groups.routes.d.ts.map +1 -0
- package/dist/modules/groups/groups.routes.js +379 -0
- package/dist/modules/groups/groups.routes.js.map +1 -0
- package/dist/modules/groups/groups.service.d.ts +60 -0
- package/dist/modules/groups/groups.service.d.ts.map +1 -0
- package/dist/modules/groups/groups.service.js +172 -0
- package/dist/modules/groups/groups.service.js.map +1 -0
- package/dist/modules/groups/index.d.ts +7 -0
- package/dist/modules/groups/index.d.ts.map +1 -0
- package/dist/modules/groups/index.js +6 -0
- package/dist/modules/groups/index.js.map +1 -0
- package/dist/modules/groups/join-proposals.d.ts +53 -0
- package/dist/modules/groups/join-proposals.d.ts.map +1 -0
- package/dist/modules/groups/join-proposals.js +67 -0
- package/dist/modules/groups/join-proposals.js.map +1 -0
- package/dist/modules/groups/join-requests.service.d.ts +81 -0
- package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
- package/dist/modules/groups/join-requests.service.js +135 -0
- package/dist/modules/groups/join-requests.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
- package/dist/modules/settings/deployment-settings.service.js +368 -0
- package/dist/modules/settings/deployment-settings.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +49 -0
- package/dist/modules/settings/setup.routes.d.ts.map +1 -0
- package/dist/modules/settings/setup.routes.js +292 -0
- package/dist/modules/settings/setup.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +2 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +40 -0
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
- package/dist/modules/skills/pending-skills.service.js +147 -0
- package/dist/modules/skills/pending-skills.service.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +32 -3
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.routes.d.ts +3 -2
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +18 -1
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +22 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +24 -12
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +1 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +11 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +22 -0
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +9 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +16 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
- package/dist/modules/workflow/git/branch-name.js +1 -1
- package/dist/modules/workflow/git/branch-name.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +31 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +144 -52
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +19 -9
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +95 -14
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +92 -3
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +381 -108
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
- package/dist/modules/workspace/bevel-ignore.js +5 -1
- package/dist/modules/workspace/bevel-ignore.js.map +1 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +228 -51
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +2 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +51 -18
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +16 -4
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +36 -9
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +8 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/kb-layout.test.js +2 -2
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +18 -0
- package/dist/test-setup.js.map +1 -0
- package/kb-template/.bevelignore +6 -2
- package/kb-template/AGENTS.md +272 -0
- package/kb-template/KnowledgeBase/How to get started.md +65 -0
- package/kb-template/access.md +36 -36
- package/migrations/0002_user_onboarding_done.sql +1 -0
- package/migrations/0003_deployment_settings.sql +9 -0
- package/migrations/meta/0001_snapshot.json +1486 -0
- package/migrations/meta/0002_snapshot.json +1493 -0
- package/migrations/meta/0003_snapshot.json +1552 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +5 -4
- package/src/__tests__/branch-model.test.ts +79 -0
- package/src/__tests__/core-config.admin.test.ts +114 -0
- package/src/__tests__/database-url.test.ts +70 -0
- package/src/core/core-ports.ts +15 -0
- package/src/core/create-core-server.ts +55 -6
- package/src/core/create-core-services.ts +201 -36
- package/src/core-config.ts +139 -59
- package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
- package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
- package/src/modules/access/__tests__/access-splice.test.ts +21 -0
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
- package/src/modules/access/access-control.interface.ts +11 -0
- package/src/modules/access/access-control.service.ts +113 -15
- package/src/modules/access/access-declarations.ts +251 -0
- package/src/modules/access/access-mutation.service.ts +4 -4
- package/src/modules/access/access-splice.ts +64 -21
- package/src/modules/access/access.routes.ts +61 -0
- package/src/modules/access/creator-access.ts +22 -2
- package/src/modules/admin/admin-access.service.ts +4 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
- package/src/modules/auth/auth.routes.ts +50 -1
- package/src/modules/auth/auth.service.ts +55 -17
- package/src/modules/database/core-schema.ts +46 -0
- package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
- package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
- package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
- package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
- package/src/modules/groups/group-provision.service.ts +328 -0
- package/src/modules/groups/groups.contract.ts +107 -0
- package/src/modules/groups/groups.routes.ts +433 -0
- package/src/modules/groups/groups.service.ts +193 -0
- package/src/modules/groups/index.ts +17 -0
- package/src/modules/groups/join-proposals.ts +114 -0
- package/src/modules/groups/join-requests.service.ts +185 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
- package/src/modules/settings/deployment-settings.service.ts +419 -0
- package/src/modules/settings/setup.routes.ts +317 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/index.ts +3 -0
- package/src/modules/skills/pending-skills.service.ts +168 -0
- package/src/modules/skills/skills.contract.ts +34 -3
- package/src/modules/skills/skills.routes.ts +23 -2
- package/src/modules/skills/skills.service.ts +24 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
- package/src/modules/tool-manuals/index.ts +2 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
- package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
- package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
- package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
- package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
- package/src/modules/workflow/event-bus.ts +25 -0
- package/src/modules/workflow/file-lock.service.ts +17 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
- package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
- package/src/modules/workflow/git/branch-name.ts +5 -1
- package/src/modules/workflow/git/git.service.ts +159 -62
- package/src/modules/workflow/git/pull-request.service.ts +22 -11
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.routes.ts +91 -13
- package/src/modules/workflow/workflow.service.ts +353 -13
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
- package/src/modules/workspace/bevel-ignore.ts +66 -62
- package/src/modules/workspace/kb-seed.interface.ts +1 -1
- package/src/modules/workspace/kb-seed.service.ts +247 -53
- package/src/modules/workspace/workspace.routes.ts +54 -18
- package/src/modules/workspace/workspace.service.ts +45 -7
- package/src/modules/workspace/workspace.tools.ts +922 -917
- package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
- package/src/shared/__tests__/join-request.test.ts +96 -0
- package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +2 -2
- package/src/test-setup.ts +18 -0
- package/kb-template/Agents/README.md +0 -38
- package/kb-template/CLAUDE.md +0 -419
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +0 -39
- package/kb-template/KnowledgeBase/README.md +0 -37
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +0 -38
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +0 -32
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +0 -42
- /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
|
@@ -23,9 +23,11 @@ import { promisify } from 'node:util';
|
|
|
23
23
|
import { isProtectedBranch } from '@bevel-software/platform-shared';
|
|
24
24
|
import { and, eq } from 'drizzle-orm';
|
|
25
25
|
import { changeRequests } from '../database/schema.js';
|
|
26
|
+
import { workspaceIdForBranch } from '../workspace/workspace.service.js';
|
|
26
27
|
import { WorkflowHooks } from './workflow-hooks.js';
|
|
28
|
+
import { WorkspaceMutex } from './git/mutex.js';
|
|
27
29
|
import { hashEmail } from '../../shared/hash-email.js';
|
|
28
|
-
import { ChangeRequestConflictsError, DuplicateChangeRequestError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, WorkflowValidationError, } from './workflow.errors.js';
|
|
30
|
+
import { ChangeRequestConflictsError, DuplicateChangeRequestError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, WorkflowDomainError, WorkflowValidationError, } from './workflow.errors.js';
|
|
29
31
|
import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
|
|
30
32
|
import { AccessDeniedError } from '../access/access-errors.js';
|
|
31
33
|
const execFileAsync = promisify(execFile);
|
|
@@ -221,8 +223,53 @@ export class WorkflowService {
|
|
|
221
223
|
createBranch(workspaceId, name, fromBase) {
|
|
222
224
|
return this.git.createBranch(workspaceId, name, fromBase);
|
|
223
225
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Serialises everything that decides a branch's fate — opening a change
|
|
228
|
+
* request from it, deleting it, retiring it after a merge — by branch
|
|
229
|
+
* name. Without it, `retireMergedSourceBranch`'s check-then-delete could
|
|
230
|
+
* interleave with `openChangeRequest`'s check-then-insert (which spans a
|
|
231
|
+
* multi-second auto-merge and push) and delete a branch a request was
|
|
232
|
+
* being opened from. In-process only, which matches the deployment model:
|
|
233
|
+
* one server process owns the workspaces directory.
|
|
234
|
+
*/
|
|
235
|
+
branchLifecycle = new WorkspaceMutex();
|
|
236
|
+
async deleteBranch(workspaceId, name, user, opts) {
|
|
237
|
+
return this.branchLifecycle.run(`branch:${name}`, async () => {
|
|
238
|
+
// A branch backing an OPEN change request is load-bearing: deleting it
|
|
239
|
+
// strands the request (and, in the propose flow's reset-on-reuse, would
|
|
240
|
+
// let one tab wipe the branch another tab just proposed from). The
|
|
241
|
+
// request has to be withdrawn or declined first. `systemCleanup` does
|
|
242
|
+
// its own open-request check under this same lock, and the legacy
|
|
243
|
+
// `onlyIfNoRemote` prune only fires when origin no longer has the ref.
|
|
244
|
+
if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
|
|
245
|
+
const open = await this.db
|
|
246
|
+
.select({ number: changeRequests.number })
|
|
247
|
+
.from(changeRequests)
|
|
248
|
+
.where(and(eq(changeRequests.sourceBranch, name), eq(changeRequests.state, 'open')))
|
|
249
|
+
.limit(1);
|
|
250
|
+
if (open.length > 0) {
|
|
251
|
+
throw new WorkflowValidationError(`Branch "${name}" has an open change request (#${open[0].number}) — withdraw or decline it before deleting the branch.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
await this.deleteBranchUnlocked(workspaceId, name, user, opts);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** The deletion itself — callers must hold the branch-lifecycle lock. */
|
|
258
|
+
async deleteBranchUnlocked(workspaceId, name, user, opts) {
|
|
259
|
+
await this.git.deleteBranch(workspaceId, name, user, opts);
|
|
260
|
+
// Retire the deleted branch's own workspace clone, best-effort. A stale
|
|
261
|
+
// clone left on disk would be silently REUSED if the branch name is ever
|
|
262
|
+
// recreated (the workspace bootstrap short-circuits on an existing
|
|
263
|
+
// `.git`), resurrecting the deleted branch's content under a fresh ref.
|
|
264
|
+
try {
|
|
265
|
+
const branchWorkspaceId = workspaceIdForBranch(name);
|
|
266
|
+
if (await this.workspaceService.hasBootstrappedWorkspace(branchWorkspaceId)) {
|
|
267
|
+
await this.workspaceService.deleteWorkspace(branchWorkspaceId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
console.warn(`[workflow] could not retire workspace clone of deleted branch "${name}":`, err);
|
|
272
|
+
}
|
|
226
273
|
}
|
|
227
274
|
// No `switchBranch` here on purpose. Under the per-branch workspace model,
|
|
228
275
|
// switching branches is a frontend navigation that triggers a workspace
|
|
@@ -340,15 +387,20 @@ export class WorkflowService {
|
|
|
340
387
|
listChangesForFile(workspaceId, path, limit) {
|
|
341
388
|
return this.git.logForFile(workspaceId, path, limit);
|
|
342
389
|
}
|
|
343
|
-
revertChange(workspaceId, user, sha) {
|
|
344
|
-
return this.git.revertCommit(workspaceId, user, sha);
|
|
345
|
-
}
|
|
346
390
|
compareFile(workspaceId, path, fromBranch, toBranch) {
|
|
347
391
|
return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch);
|
|
348
392
|
}
|
|
349
393
|
showFileAtChange(workspaceId, path, sha) {
|
|
350
394
|
return this.git.diffFileAtCommit(workspaceId, path, sha);
|
|
351
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Before/after file contents for one commit (`<sha>^` vs `<sha>`). Powers
|
|
398
|
+
* the rendered-markdown history view; `showFileAtChange` keeps serving the
|
|
399
|
+
* raw-patch view for non-markdown files.
|
|
400
|
+
*/
|
|
401
|
+
fileAtChange(workspaceId, path, sha) {
|
|
402
|
+
return this.git.fileContentsAtCommit(workspaceId, path, sha);
|
|
403
|
+
}
|
|
352
404
|
/**
|
|
353
405
|
* Resolve write permission for `targetPath` against the access tree at
|
|
354
406
|
* `HEAD` of `branch`. Throws `AccessDeniedError` on denial with the
|
|
@@ -584,7 +636,7 @@ export class WorkflowService {
|
|
|
584
636
|
* retry, hand off to the recovery agent, or escalate. Emits
|
|
585
637
|
* `file-changed` on success so frontends see the new sha.
|
|
586
638
|
*/
|
|
587
|
-
async runPendingCommit(workspaceId, branch, targetPath, user) {
|
|
639
|
+
async runPendingCommit(workspaceId, branch, targetPath, user, opts) {
|
|
588
640
|
const change = await this.git.commitFile(workspaceId, user, targetPath);
|
|
589
641
|
if (!change) {
|
|
590
642
|
// No-op commit — the path was already clean. Usually a double-enqueue
|
|
@@ -600,11 +652,11 @@ export class WorkflowService {
|
|
|
600
652
|
// and the worker's ladder takes over). Nothing to emit either way —
|
|
601
653
|
// there's no new sha.
|
|
602
654
|
if (await this.git.hasUnpushedCommits(workspaceId)) {
|
|
603
|
-
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
655
|
+
await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
|
|
604
656
|
}
|
|
605
657
|
return;
|
|
606
658
|
}
|
|
607
|
-
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
659
|
+
await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
|
|
608
660
|
this.events?.emit({
|
|
609
661
|
kind: 'file-changed',
|
|
610
662
|
workspaceId,
|
|
@@ -624,9 +676,9 @@ export class WorkflowService {
|
|
|
624
676
|
* still fails." The full rationale lives at the call site in
|
|
625
677
|
* `releaseLock`; this method is the de-duplicated body.
|
|
626
678
|
*/
|
|
627
|
-
async pushWithRecovery(workspaceId, branch, targetPath, user) {
|
|
679
|
+
async pushWithRecovery(workspaceId, branch, targetPath, user, opts) {
|
|
628
680
|
try {
|
|
629
|
-
await this.git.push(workspaceId, user);
|
|
681
|
+
await this.git.push(workspaceId, user, opts);
|
|
630
682
|
}
|
|
631
683
|
catch (firstPushErr) {
|
|
632
684
|
const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
|
|
@@ -636,7 +688,7 @@ export class WorkflowService {
|
|
|
636
688
|
if (looksLikeNonFastForward) {
|
|
637
689
|
try {
|
|
638
690
|
await this.git.pull(workspaceId);
|
|
639
|
-
await this.git.push(workspaceId, user);
|
|
691
|
+
await this.git.push(workspaceId, user, opts);
|
|
640
692
|
recovered = true;
|
|
641
693
|
console.log(`[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`);
|
|
642
694
|
}
|
|
@@ -717,8 +769,8 @@ export class WorkflowService {
|
|
|
717
769
|
listChangeRequests(opts) {
|
|
718
770
|
return this.prs.listOpenPrs(opts);
|
|
719
771
|
}
|
|
720
|
-
listChangeRequestsAuthoredBy(emailOrLogin) {
|
|
721
|
-
return this.prs.listPrsAuthoredBy(emailOrLogin);
|
|
772
|
+
listChangeRequestsAuthoredBy(emailOrLogin, opts) {
|
|
773
|
+
return this.prs.listPrsAuthoredBy(emailOrLogin, opts);
|
|
722
774
|
}
|
|
723
775
|
listChangeRequestsForUser(workspaceId, email, opts) {
|
|
724
776
|
return this.prs.listPrsForOwnerEmail(workspaceId, email, opts);
|
|
@@ -761,103 +813,111 @@ export class WorkflowService {
|
|
|
761
813
|
if (isProtectedBranch(input.sourceBranch)) {
|
|
762
814
|
throw new WorkflowValidationError(`Cannot open a change request *from* a protected branch ("${input.sourceBranch}").`, { kind: 'source-is-protected', sourceBranch: input.sourceBranch });
|
|
763
815
|
}
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
816
|
+
// The whole open sequence — uniqueness check through row insert — runs
|
|
817
|
+
// under the source branch's lifecycle lock. `retireMergedSourceBranch`
|
|
818
|
+
// takes the same lock around its own check-then-delete, so a merge
|
|
819
|
+
// cleanup can never race into the multi-second gap between this
|
|
820
|
+
// method's "no open request" answer and its row landing, and delete the
|
|
821
|
+
// branch out from under a request being opened.
|
|
822
|
+
return this.branchLifecycle.run(`branch:${input.sourceBranch}`, async () => {
|
|
823
|
+
// Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
|
|
824
|
+
// parallel is still allowed). `listOpenPrs` is cached for 30s so we
|
|
825
|
+
// force-fresh to catch a CR that opened just now.
|
|
826
|
+
const openCrs = await this.prs.listOpenPrs({ fresh: true });
|
|
827
|
+
const duplicate = openCrs.find((cr) => cr.branch === input.sourceBranch && cr.base === input.targetBranch);
|
|
828
|
+
if (duplicate) {
|
|
829
|
+
throw new DuplicateChangeRequestError(input.sourceBranch, input.targetBranch, duplicate.number);
|
|
830
|
+
}
|
|
831
|
+
// Auto-merge target → source. `mergeFromOrigin` enforces "current
|
|
832
|
+
// branch == source" and "clean working tree" before attempting; both
|
|
833
|
+
// failures surface as structured workflow errors that pass straight
|
|
834
|
+
// through to the caller.
|
|
835
|
+
const mergeOutcome = await this.git.mergeFromOrigin(workspaceId, input.sourceBranch, input.targetBranch, user);
|
|
836
|
+
if (mergeOutcome.kind === 'conflicts') {
|
|
837
|
+
throw new ChangeRequestConflictsError(input.sourceBranch, input.targetBranch, mergeOutcome.paths);
|
|
838
|
+
}
|
|
839
|
+
// Push the source — `gh pr create` against an unpushed branch returns
|
|
840
|
+
// "head branch does not exist on remote". Push *after* the auto-merge
|
|
841
|
+
// so the remote sees the merge commit too.
|
|
842
|
+
await this.git.push(workspaceId, user);
|
|
843
|
+
const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
844
|
+
const cwd = path.join(workspacePath, this.kbDirName);
|
|
845
|
+
// Touched paths between target and source — basis for the
|
|
846
|
+
// affected-owners block we append to the CR body. We compute it
|
|
847
|
+
// ourselves rather than waiting for GitHub to expose `pr.files`
|
|
848
|
+
// (which only populates after the PR exists). Three-dot range
|
|
849
|
+
// matches what `gh pr create` will eventually show as the diff.
|
|
850
|
+
const touchedPaths = await this.listChangedPathsBetweenBranches(cwd, input.targetBranch, input.sourceBranch);
|
|
851
|
+
const affectedOwnersBlock = await this.buildAffectedOwnersBlock(workspaceId, input.targetBranch, touchedPaths);
|
|
852
|
+
// The change request is a plain DB row now — no provider PR. Author is a
|
|
853
|
+
// real column (no hidden body marker needed, since nothing runs as a shared
|
|
854
|
+
// service account anymore).
|
|
855
|
+
const description = input.description?.trim() ?? '';
|
|
856
|
+
const body = [description, affectedOwnersBlock].filter((s) => s.length > 0).join('\n\n');
|
|
857
|
+
let prNumber;
|
|
858
|
+
try {
|
|
859
|
+
const [row] = await this.db
|
|
860
|
+
.insert(changeRequests)
|
|
861
|
+
.values({
|
|
862
|
+
sourceBranch: input.sourceBranch,
|
|
863
|
+
targetBranch: input.targetBranch,
|
|
864
|
+
title: input.title.trim(),
|
|
865
|
+
body,
|
|
866
|
+
authorEmail: user.email.trim().toLowerCase(),
|
|
867
|
+
authorName: user.name,
|
|
868
|
+
})
|
|
869
|
+
.returning({ number: changeRequests.number });
|
|
870
|
+
prNumber = row.number;
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
// The partial unique index (source, target WHERE state='open') is the
|
|
874
|
+
// race-proof backstop for the pre-check above: a concurrent open of the
|
|
875
|
+
// same pair lands here. Surface it as the same duplicate error, with the
|
|
876
|
+
// winning CR's number. Any OTHER unique violation (e.g. the identity
|
|
877
|
+
// `change_requests_number_unq`) is a genuine integrity fault and falls
|
|
878
|
+
// through to the generic path below rather than being mislabeled.
|
|
879
|
+
if (isOpenPairViolation(err)) {
|
|
880
|
+
const [existing] = await this.db
|
|
881
|
+
.select({ number: changeRequests.number })
|
|
882
|
+
.from(changeRequests)
|
|
883
|
+
.where(and(eq(changeRequests.sourceBranch, input.sourceBranch), eq(changeRequests.targetBranch, input.targetBranch), eq(changeRequests.state, 'open')))
|
|
884
|
+
.limit(1);
|
|
885
|
+
// Normally the conflicting open CR is right there. If it vanished (the
|
|
886
|
+
// rival was merged/closed in the microsecond between the conflict and
|
|
887
|
+
// this re-query), don't fabricate a #0 — report it as a transient race.
|
|
888
|
+
if (!existing) {
|
|
889
|
+
throw new WorkflowValidationError(`Change request for "${input.sourceBranch}" → "${input.targetBranch}" ` +
|
|
890
|
+
'raced with a concurrent open/close; retry.', { kind: 'open-change-request-failed' });
|
|
891
|
+
}
|
|
892
|
+
throw new DuplicateChangeRequestError(input.sourceBranch, input.targetBranch, existing.number);
|
|
832
893
|
}
|
|
833
|
-
|
|
894
|
+
// Non-uniqueness failure AFTER the auto-merge + push already advanced the
|
|
895
|
+
// source branch: no `change_requests` row was created, but the source now
|
|
896
|
+
// carries the auto-merge commit. This is safely retryable — re-running
|
|
897
|
+
// openChangeRequest finds no duplicate, re-runs the (idempotent, already
|
|
898
|
+
// up-to-date) auto-merge, and inserts the row.
|
|
899
|
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
|
900
|
+
throw new WorkflowValidationError(`Failed to open change request: ${redactTokens(rawMsg)}`, { kind: 'open-change-request-failed' });
|
|
834
901
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
kind: 'change-request-opened',
|
|
854
|
-
number: prNumber,
|
|
855
|
-
source: input.sourceBranch,
|
|
856
|
-
target: input.targetBranch,
|
|
857
|
-
authorIdHash: hashEmail(user.email),
|
|
858
|
-
title: input.title,
|
|
902
|
+
this.prs.invalidateDetailCache(prNumber);
|
|
903
|
+
const detail = await this.prs.getPrDetail(prNumber, {
|
|
904
|
+
fresh: true,
|
|
905
|
+
workspaceId,
|
|
906
|
+
viewerEmail: user.email,
|
|
907
|
+
});
|
|
908
|
+
if (!detail) {
|
|
909
|
+
throw new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`);
|
|
910
|
+
}
|
|
911
|
+
this.events?.emit({
|
|
912
|
+
kind: 'change-request-opened',
|
|
913
|
+
number: prNumber,
|
|
914
|
+
source: input.sourceBranch,
|
|
915
|
+
target: input.targetBranch,
|
|
916
|
+
authorIdHash: hashEmail(user.email),
|
|
917
|
+
title: input.title,
|
|
918
|
+
});
|
|
919
|
+
return detail;
|
|
859
920
|
});
|
|
860
|
-
return detail;
|
|
861
921
|
}
|
|
862
922
|
/**
|
|
863
923
|
* Refresh an existing change request from its target — re-runs the
|
|
@@ -940,6 +1000,164 @@ export class WorkflowService {
|
|
|
940
1000
|
});
|
|
941
1001
|
return approvals;
|
|
942
1002
|
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Decline ONE file of an open change request: restore its merge-base
|
|
1005
|
+
* version on the SOURCE branch (commit + push), so the file drops out of
|
|
1006
|
+
* the request's three-dot diff — the same mechanics
|
|
1007
|
+
* `preserveBaseRolesYaml` uses to neutralise roles.yaml, offered as a
|
|
1008
|
+
* reviewer's verb. Accept-or-revert per file is how a reviewer takes the
|
|
1009
|
+
* good half of a proposal without rejecting the whole thing.
|
|
1010
|
+
*
|
|
1011
|
+
* Authority: exactly the approval predicate — an eligible approver for the
|
|
1012
|
+
* file per the access tree on `origin/<base>`. Declining takes the same
|
|
1013
|
+
* permission as accepting.
|
|
1014
|
+
*
|
|
1015
|
+
* When the LAST file is reverted the request proposes nothing: it closes
|
|
1016
|
+
* itself and its source branch is retired, exactly as a merge would have
|
|
1017
|
+
* retired it — a shell of a request pointing at an empty diff serves
|
|
1018
|
+
* nobody (see the zero-files dialog state this replaces).
|
|
1019
|
+
*/
|
|
1020
|
+
async revertChangeRequestFile(number, user, repoRelPath) {
|
|
1021
|
+
const summary = await this.prs.getPr(number);
|
|
1022
|
+
if (!summary)
|
|
1023
|
+
throw new WorkflowDomainError('change request not found', 404);
|
|
1024
|
+
if (summary.state !== 'open') {
|
|
1025
|
+
throw new WorkflowDomainError('This change request is no longer open.', 422);
|
|
1026
|
+
}
|
|
1027
|
+
const baseBranch = summary.base;
|
|
1028
|
+
const headBranch = summary.branch;
|
|
1029
|
+
const ws = await this.workspaceService.getOrCreateForBranch(headBranch);
|
|
1030
|
+
// Best-effort freshen of the source checkout: the diff below reads origin
|
|
1031
|
+
// refs, but the restore commits from the working tree — a stale tree
|
|
1032
|
+
// would push non-fast-forward and fail loudly anyway; this just makes
|
|
1033
|
+
// that rare.
|
|
1034
|
+
await this.git.pull(ws.id).catch(() => undefined);
|
|
1035
|
+
// The verb acts on the request as it is NOW — never a cached file list.
|
|
1036
|
+
const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
|
|
1037
|
+
if (!paths.includes(repoRelPath)) {
|
|
1038
|
+
throw new WorkflowDomainError('That file is not part of this change request.', 422);
|
|
1039
|
+
}
|
|
1040
|
+
const canApprove = await this.accessControl.canWriteAtRef(ws.id, `origin/${baseBranch}`, user.email, repoRelPath);
|
|
1041
|
+
if (canApprove !== true) {
|
|
1042
|
+
throw new WorkflowDomainError('Only an eligible approver of this file can revert it — declining takes the same permission as accepting.', 403);
|
|
1043
|
+
}
|
|
1044
|
+
const mergeBase = await this.git.mergeBaseForPr(ws.id, baseBranch, headBranch);
|
|
1045
|
+
if (!mergeBase) {
|
|
1046
|
+
throw new WorkflowDomainError('These branches share no history to revert to.', 422);
|
|
1047
|
+
}
|
|
1048
|
+
// Same per-file lock every other editor of this path takes — a concurrent
|
|
1049
|
+
// save must not race the restore between write and commit.
|
|
1050
|
+
const lockPath = `${this.kbDirName}/${repoRelPath}`;
|
|
1051
|
+
const lock = await this.fileLocks.acquire(ws.id, headBranch, lockPath, user);
|
|
1052
|
+
if (!lock.acquired) {
|
|
1053
|
+
throw new WorkflowDomainError(`${repoRelPath} is being edited by ${lock.lock.holderName} — try again once the edit settles.`, 409);
|
|
1054
|
+
}
|
|
1055
|
+
try {
|
|
1056
|
+
await this.git.restorePathFromRef(ws.id, mergeBase, repoRelPath);
|
|
1057
|
+
await this.git.commitFile(ws.id, user, repoRelPath, `Revert ${repoRelPath} (declined in change request #${number})`, true);
|
|
1058
|
+
await this.git.push(ws.id, user);
|
|
1059
|
+
}
|
|
1060
|
+
finally {
|
|
1061
|
+
// Committed inline — drop the lock row directly rather than enqueueing
|
|
1062
|
+
// a duplicate commit through releaseLock.
|
|
1063
|
+
await this.fileLocks.release(ws.id, headBranch, lockPath, user);
|
|
1064
|
+
}
|
|
1065
|
+
this.prs.invalidateDetailCache(number);
|
|
1066
|
+
const remaining = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
|
|
1067
|
+
if (remaining.length > 0) {
|
|
1068
|
+
return { closed: false, remainingPaths: remaining };
|
|
1069
|
+
}
|
|
1070
|
+
// Every change declined → the request proposes nothing.
|
|
1071
|
+
await this.closeEmptyChangeRequest(number, user);
|
|
1072
|
+
return { closed: true, remainingPaths: [] };
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* DELETE a change request outright: close it (whatever its diff says) and
|
|
1076
|
+
* retire its source branch — the request, its proposal, and the branch that
|
|
1077
|
+
* carried it are gone in one verb. Admin-only, resolved the same way every
|
|
1078
|
+
* other admin check here is (write on `roles.yaml` at `origin/<base>`,
|
|
1079
|
+
* never a local ref): this is the moderation verb for a shared deployment,
|
|
1080
|
+
* stronger than reject (which the author or the files' owners can do, and
|
|
1081
|
+
* which leaves the branch for a second round).
|
|
1082
|
+
*/
|
|
1083
|
+
async deleteChangeRequest(number, user) {
|
|
1084
|
+
const summary = await this.prs.getPr(number);
|
|
1085
|
+
if (!summary)
|
|
1086
|
+
throw new WorkflowDomainError('change request not found', 404);
|
|
1087
|
+
if (summary.state === 'merged') {
|
|
1088
|
+
throw new WorkflowDomainError('This change request has already been applied.', 422);
|
|
1089
|
+
}
|
|
1090
|
+
const ws = await this.workspaceService.getOrCreateForBranch(summary.base);
|
|
1091
|
+
await this.workspaceService.ensureRemotesFetched(ws.id).catch(() => undefined);
|
|
1092
|
+
const isAdmin = await this.accessControl.canWriteAtRef(ws.id, `origin/${summary.base}`, user.email, 'roles.yaml');
|
|
1093
|
+
if (isAdmin !== true) {
|
|
1094
|
+
throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
|
|
1095
|
+
}
|
|
1096
|
+
// Close first (idempotent: an already-closed request just skips to the
|
|
1097
|
+
// branch retirement). Guarded on `open` so a concurrent merge wins.
|
|
1098
|
+
const now = new Date();
|
|
1099
|
+
const updated = await this.db
|
|
1100
|
+
.update(changeRequests)
|
|
1101
|
+
.set({ state: 'closed', closedAt: now, updatedAt: now })
|
|
1102
|
+
.where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
|
|
1103
|
+
.returning({ id: changeRequests.id });
|
|
1104
|
+
if (updated.length > 0) {
|
|
1105
|
+
this.prs.invalidateDetailCache(number);
|
|
1106
|
+
this.events?.emit({ kind: 'change-request-rejected', number });
|
|
1107
|
+
}
|
|
1108
|
+
else {
|
|
1109
|
+
const fresh = await this.prs.getPr(number);
|
|
1110
|
+
if (fresh?.state === 'merged') {
|
|
1111
|
+
throw new WorkflowDomainError('This change request has already been applied.', 422);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
await this.retireMergedSourceBranch(number, summary.base, user);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Close an open change request whose diff has EMPTIED — everything it
|
|
1118
|
+
* proposed has since landed on the target, been reverted, or been undone on
|
|
1119
|
+
* its own branch — and retire its source branch, exactly as a merge would
|
|
1120
|
+
* have. A request pointing at an empty diff serves nobody: reviewers never
|
|
1121
|
+
* see it (zero touched paths routes to no one) and the author sees a shell.
|
|
1122
|
+
*
|
|
1123
|
+
* Called after the last per-file revert, and as a lazy reconcile whenever a
|
|
1124
|
+
* detail read observes an open request with no files (the join-requests
|
|
1125
|
+
* pattern: derived state, settled on observation). The emptiness check here
|
|
1126
|
+
* is AUTHORITATIVE — recomputed, and a diff failure aborts rather than
|
|
1127
|
+
* closes, so a transient git error can never eat a live request.
|
|
1128
|
+
*
|
|
1129
|
+
* Returns true when THIS call closed it.
|
|
1130
|
+
*/
|
|
1131
|
+
async closeEmptyChangeRequest(number, user) {
|
|
1132
|
+
const summary = await this.prs.getPr(number);
|
|
1133
|
+
if (!summary || summary.state !== 'open')
|
|
1134
|
+
return false;
|
|
1135
|
+
let paths;
|
|
1136
|
+
try {
|
|
1137
|
+
const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
|
|
1138
|
+
paths = await this.git.changedPathsForPr(ws.id, summary.base, summary.branch);
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
console.warn(`[cr] empty-check for change request #${number} failed — leaving it open:`, err);
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
if (paths.length > 0)
|
|
1145
|
+
return false;
|
|
1146
|
+
// Guard on `state = 'open'` so a concurrent merge or withdraw wins the
|
|
1147
|
+
// race and this becomes a no-op.
|
|
1148
|
+
const now = new Date();
|
|
1149
|
+
const updated = await this.db
|
|
1150
|
+
.update(changeRequests)
|
|
1151
|
+
.set({ state: 'closed', closedAt: now, updatedAt: now })
|
|
1152
|
+
.where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
|
|
1153
|
+
.returning({ id: changeRequests.id });
|
|
1154
|
+
if (updated.length === 0)
|
|
1155
|
+
return false;
|
|
1156
|
+
this.prs.invalidateDetailCache(number);
|
|
1157
|
+
this.events?.emit({ kind: 'change-request-rejected', number });
|
|
1158
|
+
await this.retireMergedSourceBranch(number, summary.base, user);
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
943
1161
|
/**
|
|
944
1162
|
* Reject (close-without-merging) a change request. Per PLAN.md spec, the
|
|
945
1163
|
* authorized set is:
|
|
@@ -1073,8 +1291,63 @@ export class WorkflowService {
|
|
|
1073
1291
|
}
|
|
1074
1292
|
this.prs.invalidateDetailCache(number);
|
|
1075
1293
|
this.events?.emit({ kind: 'change-request-merged', number });
|
|
1294
|
+
// AFTER the event: the applying UI is waiting on `change-request-merged`,
|
|
1295
|
+
// and branch retirement is git IO it must never wait behind.
|
|
1296
|
+
await this.retireMergedSourceBranch(number, baseBranch, user);
|
|
1076
1297
|
return { kind: 'merged', result };
|
|
1077
1298
|
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Best-effort retirement of a merged change request's source branch — the
|
|
1301
|
+
* standard "delete branch on merge" a git host performs, done here because
|
|
1302
|
+
* this app IS the host. Everything the branch carried is on the target
|
|
1303
|
+
* now, and leaving it behind has a real cost: the propose flow REUSES an
|
|
1304
|
+
* existing branch by name, so a leftover base goes stale against the
|
|
1305
|
+
* target and seeds the next proposal with outdated text (or, worse,
|
|
1306
|
+
* re-proposes withdrawn commits).
|
|
1307
|
+
*
|
|
1308
|
+
* Refuses quietly when the branch is protected or still carries another
|
|
1309
|
+
* OPEN request. Runs from the TARGET's workspace (guaranteed not to be
|
|
1310
|
+
* checked out on the branch being deleted) with `systemCleanup`, since the
|
|
1311
|
+
* merger need not be the branch's author — the merge itself was the
|
|
1312
|
+
* authorization. Never throws: the merge has already succeeded, and a
|
|
1313
|
+
* cleanup failure only means the next proposal falls back to branch reuse.
|
|
1314
|
+
*/
|
|
1315
|
+
async retireMergedSourceBranch(number, baseBranch, user) {
|
|
1316
|
+
try {
|
|
1317
|
+
const rows = await this.db
|
|
1318
|
+
.select({ sourceBranch: changeRequests.sourceBranch })
|
|
1319
|
+
.from(changeRequests)
|
|
1320
|
+
.where(eq(changeRequests.number, number))
|
|
1321
|
+
.limit(1);
|
|
1322
|
+
const sourceBranch = rows[0]?.sourceBranch;
|
|
1323
|
+
if (!sourceBranch || isProtectedBranch(sourceBranch))
|
|
1324
|
+
return;
|
|
1325
|
+
// The open-check and the deletion hold the branch's lifecycle lock
|
|
1326
|
+
// TOGETHER — `openChangeRequest` holds the same lock across its own
|
|
1327
|
+
// check-then-insert, so a request being opened from this branch either
|
|
1328
|
+
// lands its row before the check below (retirement backs off) or waits
|
|
1329
|
+
// until retirement finishes (and fails loudly on the missing branch,
|
|
1330
|
+
// retryable, instead of losing the branch mid-open).
|
|
1331
|
+
await this.branchLifecycle.run(`branch:${sourceBranch}`, async () => {
|
|
1332
|
+
const stillOpen = await this.db
|
|
1333
|
+
.select({ number: changeRequests.number })
|
|
1334
|
+
.from(changeRequests)
|
|
1335
|
+
.where(and(eq(changeRequests.sourceBranch, sourceBranch), eq(changeRequests.state, 'open')))
|
|
1336
|
+
.limit(1);
|
|
1337
|
+
if (stillOpen.length > 0)
|
|
1338
|
+
return;
|
|
1339
|
+
const targetWs = await this.workspaceService.getOrCreateForBranch(baseBranch);
|
|
1340
|
+
// The remote-tracking ref must be current, or deleteBranch's
|
|
1341
|
+
// "does origin still have it?" probe skips the remote delete.
|
|
1342
|
+
await this.workspaceService.ensureRemotesFetched(targetWs.id).catch(() => { });
|
|
1343
|
+
await this.deleteBranchUnlocked(targetWs.id, sourceBranch, user, { systemCleanup: true });
|
|
1344
|
+
console.log(`[merge] retired merged source branch "${sourceBranch}"`);
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
catch (err) {
|
|
1348
|
+
console.warn(`[merge] could not retire the merged source branch of change request #${number} (non-fatal):`, err);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1078
1351
|
/**
|
|
1079
1352
|
* Guarantee a CR carries NO `roles.yaml` change across its merge: if the CR's
|
|
1080
1353
|
* `roles.yaml` differs from the base branch's, restore the base version on the
|