@bevel-software/platform-core-backend 0.4.0 → 0.5.0
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/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 +157 -54
- 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 +97 -5
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +393 -114
- 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 +3 -3
- 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__/pending-commits.enqueue-if-absent.test.ts +82 -72
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -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 +311 -306
- 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 +172 -64
- 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 +365 -19
- 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
|
@@ -266,17 +266,6 @@ export function createWorkflowRoutes(
|
|
|
266
266
|
}
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
-
router.post('/workspace/:id/workflow/changes/:sha/revert', async (req, res) => {
|
|
270
|
-
const user = await requireUser(req, res);
|
|
271
|
-
if (!user) return;
|
|
272
|
-
try {
|
|
273
|
-
res.json(await workflow.revertChange(req.params.id, user, req.params.sha));
|
|
274
|
-
} catch (err) {
|
|
275
|
-
const { status, body } = toHttpError(err);
|
|
276
|
-
res.status(status).json(body);
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
|
|
280
269
|
router.get('/workspace/:id/workflow/compare-file', async (req, res) => {
|
|
281
270
|
if (!(await requireUser(req, res))) return;
|
|
282
271
|
const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
|
|
@@ -312,6 +301,23 @@ export function createWorkflowRoutes(
|
|
|
312
301
|
}
|
|
313
302
|
});
|
|
314
303
|
|
|
304
|
+
router.get('/workspace/:id/workflow/file-at-change', async (req, res) => {
|
|
305
|
+
if (!(await requireUser(req, res))) return;
|
|
306
|
+
const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
|
|
307
|
+
const sha = typeof req.query.sha === 'string' ? req.query.sha : '';
|
|
308
|
+
if (!pathParam || !sha) {
|
|
309
|
+
res.status(400).json({ error: 'path and sha are required' });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const { baseline, current } = await workflow.fileAtChange(req.params.id, pathParam, sha);
|
|
314
|
+
res.json({ baseline, current });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
const { status, body } = toHttpError(err);
|
|
317
|
+
res.status(status).json(body);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
315
321
|
// ── File locks ────────────────────────────────────────────────────────────
|
|
316
322
|
|
|
317
323
|
router.post('/workspace/:id/workflow/locks', async (req, res) => {
|
|
@@ -431,8 +437,12 @@ export function createWorkflowRoutes(
|
|
|
431
437
|
router.get('/workflow/change-requests/mine', async (req, res) => {
|
|
432
438
|
const user = await requireUser(req, res);
|
|
433
439
|
if (!user) return;
|
|
440
|
+
// `?fresh=1` bypasses the 30s list cache — event-driven refreshes (a
|
|
441
|
+
// proposal or suggestion-routed upload just landed) ask because they KNOW
|
|
442
|
+
// the list changed, and a cached answer would hide their own change.
|
|
443
|
+
const fresh = isTruthyQuery(req.query.fresh);
|
|
434
444
|
try {
|
|
435
|
-
res.json(await workflow.listChangeRequestsAuthoredBy(user.email));
|
|
445
|
+
res.json(await workflow.listChangeRequestsAuthoredBy(user.email, { fresh }));
|
|
436
446
|
} catch (err) {
|
|
437
447
|
const { status, body } = toHttpError(err);
|
|
438
448
|
res.status(status).json(body);
|
|
@@ -497,7 +507,7 @@ export function createWorkflowRoutes(
|
|
|
497
507
|
// blip when the caller's workspace isn't yet provisioned.
|
|
498
508
|
}
|
|
499
509
|
}
|
|
500
|
-
|
|
510
|
+
let detail = await workflow.getChangeRequestDetail(num, {
|
|
501
511
|
fresh,
|
|
502
512
|
workspaceId,
|
|
503
513
|
viewerEmail,
|
|
@@ -506,6 +516,26 @@ export function createWorkflowRoutes(
|
|
|
506
516
|
res.status(404).json({ error: 'change request not found' });
|
|
507
517
|
return;
|
|
508
518
|
}
|
|
519
|
+
// Lazy reconcile (the join-requests pattern): an OPEN request whose
|
|
520
|
+
// diff has emptied — everything it proposed has since landed, been
|
|
521
|
+
// reverted, or been undone on its branch — closes itself on
|
|
522
|
+
// observation and its branch retires. The close re-verifies the
|
|
523
|
+
// emptiness authoritatively and never fires on a failed diff.
|
|
524
|
+
if (detail.state === 'open' && detail.files.length === 0 && req.userId) {
|
|
525
|
+
try {
|
|
526
|
+
const user = await authService.getUserById(req.userId);
|
|
527
|
+
if (user && (await workflow.closeEmptyChangeRequest(num, user))) {
|
|
528
|
+
detail =
|
|
529
|
+
(await workflow.getChangeRequestDetail(num, {
|
|
530
|
+
fresh: true,
|
|
531
|
+
workspaceId,
|
|
532
|
+
viewerEmail,
|
|
533
|
+
})) ?? detail;
|
|
534
|
+
}
|
|
535
|
+
} catch (err) {
|
|
536
|
+
console.warn(`[cr] lazy empty-close of #${num} failed (non-fatal):`, err);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
509
539
|
res.json(detail);
|
|
510
540
|
} catch (err) {
|
|
511
541
|
const { status, body } = toHttpError(err);
|
|
@@ -513,6 +543,54 @@ export function createWorkflowRoutes(
|
|
|
513
543
|
}
|
|
514
544
|
});
|
|
515
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Delete a change request outright — close it and retire its source
|
|
548
|
+
* branch. Admin-only; the moderation verb for a shared deployment.
|
|
549
|
+
*/
|
|
550
|
+
router.delete('/workflow/change-requests/:number', async (req, res) => {
|
|
551
|
+
const num = parsePrNumber(req.params.number);
|
|
552
|
+
if (num === null) {
|
|
553
|
+
res.status(400).json({ error: 'invalid change request number' });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const user = await requireUser(req, res);
|
|
557
|
+
if (!user) return;
|
|
558
|
+
try {
|
|
559
|
+
await workflow.deleteChangeRequest(num, user);
|
|
560
|
+
res.json({ deleted: true });
|
|
561
|
+
} catch (err) {
|
|
562
|
+
const { status, body } = toHttpError(err);
|
|
563
|
+
res.status(status).json(body);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Decline ONE file of a change request: restore its merge-base version on
|
|
569
|
+
* the source branch, dropping it from the request's diff. Takes the same
|
|
570
|
+
* permission as approving the file. When the last file is declined the
|
|
571
|
+
* request closes itself and its branch retires (`closed: true`).
|
|
572
|
+
*/
|
|
573
|
+
router.post('/workflow/change-requests/:number/files/revert', async (req, res) => {
|
|
574
|
+
const num = parsePrNumber(req.params.number);
|
|
575
|
+
if (num === null) {
|
|
576
|
+
res.status(400).json({ error: 'invalid change request number' });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const user = await requireUser(req, res);
|
|
580
|
+
if (!user) return;
|
|
581
|
+
const body = (req.body ?? {}) as { path?: string };
|
|
582
|
+
if (typeof body.path !== 'string' || !body.path) {
|
|
583
|
+
res.status(400).json({ error: 'path is required' });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
res.json(await workflow.revertChangeRequestFile(num, user, body.path));
|
|
588
|
+
} catch (err) {
|
|
589
|
+
const { status, body: errBody } = toHttpError(err);
|
|
590
|
+
res.status(status).json(errBody);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
516
594
|
router.post('/workflow/change-requests', async (req, res) => {
|
|
517
595
|
const user = await requireUser(req, res);
|
|
518
596
|
if (!user) return;
|
|
@@ -49,12 +49,14 @@ import type { GitService } from './git/git.service.js';
|
|
|
49
49
|
import type { PullRequestService } from './git/pull-request.service.js';
|
|
50
50
|
import type { IReviewWorkflowService } from './review-workflow/review-workflow.interface.js';
|
|
51
51
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
52
|
+
import { workspaceIdForBranch } from '../workspace/workspace.service.js';
|
|
52
53
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
53
54
|
import { FileLockService } from './file-lock.service.js';
|
|
54
55
|
import { PendingCommitsService } from './pending-commits.service.js';
|
|
55
56
|
import type { WorkflowEventBus } from './event-bus.js';
|
|
56
57
|
import type { FileChangeNotifier } from './file-change-notifier.js';
|
|
57
58
|
import { WorkflowHooks } from './workflow-hooks.js';
|
|
59
|
+
import { WorkspaceMutex } from './git/mutex.js';
|
|
58
60
|
import { hashEmail } from '../../shared/hash-email.js';
|
|
59
61
|
import {
|
|
60
62
|
ChangeRequestConflictsError,
|
|
@@ -62,6 +64,7 @@ import {
|
|
|
62
64
|
RolesYamlPreservationError,
|
|
63
65
|
PullRebaseConflictError,
|
|
64
66
|
PushNeedsAgentResolutionError,
|
|
67
|
+
WorkflowDomainError,
|
|
65
68
|
WorkflowValidationError,
|
|
66
69
|
} from './workflow.errors.js';
|
|
67
70
|
import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
|
|
@@ -267,13 +270,71 @@ export class WorkflowService implements IWorkflowService {
|
|
|
267
270
|
return this.git.createBranch(workspaceId, name, fromBase);
|
|
268
271
|
}
|
|
269
272
|
|
|
270
|
-
|
|
273
|
+
/**
|
|
274
|
+
* Serialises everything that decides a branch's fate — opening a change
|
|
275
|
+
* request from it, deleting it, retiring it after a merge — by branch
|
|
276
|
+
* name. Without it, `retireMergedSourceBranch`'s check-then-delete could
|
|
277
|
+
* interleave with `openChangeRequest`'s check-then-insert (which spans a
|
|
278
|
+
* multi-second auto-merge and push) and delete a branch a request was
|
|
279
|
+
* being opened from. In-process only, which matches the deployment model:
|
|
280
|
+
* one server process owns the workspaces directory.
|
|
281
|
+
*/
|
|
282
|
+
private readonly branchLifecycle = new WorkspaceMutex();
|
|
283
|
+
|
|
284
|
+
async deleteBranch(
|
|
271
285
|
workspaceId: string,
|
|
272
286
|
name: string,
|
|
273
287
|
user: AuthUser,
|
|
274
|
-
opts?: { onlyIfNoRemote?: boolean },
|
|
288
|
+
opts?: { onlyIfNoRemote?: boolean; systemCleanup?: boolean },
|
|
275
289
|
): Promise<void> {
|
|
276
|
-
return this.
|
|
290
|
+
return this.branchLifecycle.run(`branch:${name}`, async () => {
|
|
291
|
+
// A branch backing an OPEN change request is load-bearing: deleting it
|
|
292
|
+
// strands the request (and, in the propose flow's reset-on-reuse, would
|
|
293
|
+
// let one tab wipe the branch another tab just proposed from). The
|
|
294
|
+
// request has to be withdrawn or declined first. `systemCleanup` does
|
|
295
|
+
// its own open-request check under this same lock, and the legacy
|
|
296
|
+
// `onlyIfNoRemote` prune only fires when origin no longer has the ref.
|
|
297
|
+
if (!opts?.systemCleanup && !opts?.onlyIfNoRemote) {
|
|
298
|
+
const open = await this.db
|
|
299
|
+
.select({ number: changeRequests.number })
|
|
300
|
+
.from(changeRequests)
|
|
301
|
+
.where(
|
|
302
|
+
and(eq(changeRequests.sourceBranch, name), eq(changeRequests.state, 'open')),
|
|
303
|
+
)
|
|
304
|
+
.limit(1);
|
|
305
|
+
if (open.length > 0) {
|
|
306
|
+
throw new WorkflowValidationError(
|
|
307
|
+
`Branch "${name}" has an open change request (#${open[0].number}) — withdraw or decline it before deleting the branch.`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
await this.deleteBranchUnlocked(workspaceId, name, user, opts);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The deletion itself — callers must hold the branch-lifecycle lock. */
|
|
316
|
+
private async deleteBranchUnlocked(
|
|
317
|
+
workspaceId: string,
|
|
318
|
+
name: string,
|
|
319
|
+
user: AuthUser,
|
|
320
|
+
opts?: { onlyIfNoRemote?: boolean; systemCleanup?: boolean },
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
await this.git.deleteBranch(workspaceId, name, user, opts);
|
|
323
|
+
// Retire the deleted branch's own workspace clone, best-effort. A stale
|
|
324
|
+
// clone left on disk would be silently REUSED if the branch name is ever
|
|
325
|
+
// recreated (the workspace bootstrap short-circuits on an existing
|
|
326
|
+
// `.git`), resurrecting the deleted branch's content under a fresh ref.
|
|
327
|
+
try {
|
|
328
|
+
const branchWorkspaceId = workspaceIdForBranch(name);
|
|
329
|
+
if (await this.workspaceService.hasBootstrappedWorkspace(branchWorkspaceId)) {
|
|
330
|
+
await this.workspaceService.deleteWorkspace(branchWorkspaceId);
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
console.warn(
|
|
334
|
+
`[workflow] could not retire workspace clone of deleted branch "${name}":`,
|
|
335
|
+
err,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
277
338
|
}
|
|
278
339
|
|
|
279
340
|
// No `switchBranch` here on purpose. Under the per-branch workspace model,
|
|
@@ -327,10 +388,13 @@ export class WorkflowService implements IWorkflowService {
|
|
|
327
388
|
* `needs_attention` row: once the ladder has escalated to a human, more
|
|
328
389
|
* agent runs on the same divergence are just a loop.
|
|
329
390
|
*
|
|
330
|
-
* One row,
|
|
391
|
+
* One row, smallest conflicted path as the representative: the recovery
|
|
331
392
|
* agent's job is "bring the BRANCH into a pushable state", so one ladder
|
|
332
393
|
* per divergence — not one per conflicted file, which would multiply
|
|
333
|
-
* agent runs for a single underlying conflict.
|
|
394
|
+
* agent runs for a single underlying conflict. Sorted so the choice is
|
|
395
|
+
* stable across retries regardless of how the caller ordered the paths —
|
|
396
|
+
* a shifting representative would slip past `enqueueIfAbsent`'s
|
|
397
|
+
* per-(workspace, branch, path) dedup and start a second ladder.
|
|
334
398
|
*
|
|
335
399
|
* Best-effort by design — the caller is already surfacing the conflict
|
|
336
400
|
* error; a queue hiccup must not mask it.
|
|
@@ -344,7 +408,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
344
408
|
const queued = await this.pendingCommits.enqueueIfAbsent({
|
|
345
409
|
workspaceId,
|
|
346
410
|
branch: err.branch,
|
|
347
|
-
path: err.conflictedPaths[0],
|
|
411
|
+
path: [...err.conflictedPaths].sort()[0],
|
|
348
412
|
authorEmail: user?.email ?? RECOVERY_BOT_EMAIL,
|
|
349
413
|
authorName: user?.name ?? RECOVERY_BOT_NAME,
|
|
350
414
|
});
|
|
@@ -417,10 +481,6 @@ export class WorkflowService implements IWorkflowService {
|
|
|
417
481
|
return this.git.logForFile(workspaceId, path, limit);
|
|
418
482
|
}
|
|
419
483
|
|
|
420
|
-
revertChange(workspaceId: string, user: AuthUser, sha: string): Promise<Change> {
|
|
421
|
-
return this.git.revertCommit(workspaceId, user, sha);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
484
|
compareFile(
|
|
425
485
|
workspaceId: string,
|
|
426
486
|
path: string,
|
|
@@ -434,6 +494,19 @@ export class WorkflowService implements IWorkflowService {
|
|
|
434
494
|
return this.git.diffFileAtCommit(workspaceId, path, sha);
|
|
435
495
|
}
|
|
436
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Before/after file contents for one commit (`<sha>^` vs `<sha>`). Powers
|
|
499
|
+
* the rendered-markdown history view; `showFileAtChange` keeps serving the
|
|
500
|
+
* raw-patch view for non-markdown files.
|
|
501
|
+
*/
|
|
502
|
+
fileAtChange(
|
|
503
|
+
workspaceId: string,
|
|
504
|
+
path: string,
|
|
505
|
+
sha: string,
|
|
506
|
+
): Promise<{ baseline: string | null; current: string | null }> {
|
|
507
|
+
return this.git.fileContentsAtCommit(workspaceId, path, sha);
|
|
508
|
+
}
|
|
509
|
+
|
|
437
510
|
/**
|
|
438
511
|
* Resolve write permission for `targetPath` against the access tree at
|
|
439
512
|
* `HEAD` of `branch`. Throws `AccessDeniedError` on denial with the
|
|
@@ -735,6 +808,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
735
808
|
branch: string,
|
|
736
809
|
targetPath: string,
|
|
737
810
|
user: AuthUser,
|
|
811
|
+
opts?: { systemAuthorized?: boolean },
|
|
738
812
|
): Promise<void> {
|
|
739
813
|
const change = await this.git.commitFile(workspaceId, user, targetPath);
|
|
740
814
|
if (!change) {
|
|
@@ -751,11 +825,11 @@ export class WorkflowService implements IWorkflowService {
|
|
|
751
825
|
// and the worker's ladder takes over). Nothing to emit either way —
|
|
752
826
|
// there's no new sha.
|
|
753
827
|
if (await this.git.hasUnpushedCommits(workspaceId)) {
|
|
754
|
-
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
828
|
+
await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
|
|
755
829
|
}
|
|
756
830
|
return;
|
|
757
831
|
}
|
|
758
|
-
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
832
|
+
await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
|
|
759
833
|
this.events?.emit({
|
|
760
834
|
kind: 'file-changed',
|
|
761
835
|
workspaceId,
|
|
@@ -781,9 +855,10 @@ export class WorkflowService implements IWorkflowService {
|
|
|
781
855
|
branch: string,
|
|
782
856
|
targetPath: string,
|
|
783
857
|
user: AuthUser,
|
|
858
|
+
opts?: { systemAuthorized?: boolean },
|
|
784
859
|
): Promise<void> {
|
|
785
860
|
try {
|
|
786
|
-
await this.git.push(workspaceId, user);
|
|
861
|
+
await this.git.push(workspaceId, user, opts);
|
|
787
862
|
} catch (firstPushErr) {
|
|
788
863
|
const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
|
|
789
864
|
const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(firstDetail);
|
|
@@ -792,7 +867,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
792
867
|
if (looksLikeNonFastForward) {
|
|
793
868
|
try {
|
|
794
869
|
await this.git.pull(workspaceId);
|
|
795
|
-
await this.git.push(workspaceId, user);
|
|
870
|
+
await this.git.push(workspaceId, user, opts);
|
|
796
871
|
recovered = true;
|
|
797
872
|
console.log(
|
|
798
873
|
`[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
|
|
@@ -902,8 +977,11 @@ export class WorkflowService implements IWorkflowService {
|
|
|
902
977
|
return this.prs.listOpenPrs(opts);
|
|
903
978
|
}
|
|
904
979
|
|
|
905
|
-
listChangeRequestsAuthoredBy(
|
|
906
|
-
|
|
980
|
+
listChangeRequestsAuthoredBy(
|
|
981
|
+
emailOrLogin: string,
|
|
982
|
+
opts?: { fresh?: boolean },
|
|
983
|
+
): Promise<ChangeRequest[]> {
|
|
984
|
+
return this.prs.listPrsAuthoredBy(emailOrLogin, opts);
|
|
907
985
|
}
|
|
908
986
|
|
|
909
987
|
listChangeRequestsForUser(
|
|
@@ -962,6 +1040,13 @@ export class WorkflowService implements IWorkflowService {
|
|
|
962
1040
|
);
|
|
963
1041
|
}
|
|
964
1042
|
|
|
1043
|
+
// The whole open sequence — uniqueness check through row insert — runs
|
|
1044
|
+
// under the source branch's lifecycle lock. `retireMergedSourceBranch`
|
|
1045
|
+
// takes the same lock around its own check-then-delete, so a merge
|
|
1046
|
+
// cleanup can never race into the multi-second gap between this
|
|
1047
|
+
// method's "no open request" answer and its row landing, and delete the
|
|
1048
|
+
// branch out from under a request being opened.
|
|
1049
|
+
return this.branchLifecycle.run(`branch:${input.sourceBranch}`, async () => {
|
|
965
1050
|
// Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
|
|
966
1051
|
// parallel is still allowed). `listOpenPrs` is cached for 30s so we
|
|
967
1052
|
// force-fresh to catch a CR that opened just now.
|
|
@@ -1106,6 +1191,7 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1106
1191
|
title: input.title,
|
|
1107
1192
|
});
|
|
1108
1193
|
return detail;
|
|
1194
|
+
});
|
|
1109
1195
|
}
|
|
1110
1196
|
|
|
1111
1197
|
/**
|
|
@@ -1259,6 +1345,197 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1259
1345
|
return approvals;
|
|
1260
1346
|
}
|
|
1261
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* Decline ONE file of an open change request: restore its merge-base
|
|
1350
|
+
* version on the SOURCE branch (commit + push), so the file drops out of
|
|
1351
|
+
* the request's three-dot diff — the same mechanics
|
|
1352
|
+
* `preserveBaseRolesYaml` uses to neutralise roles.yaml, offered as a
|
|
1353
|
+
* reviewer's verb. Accept-or-revert per file is how a reviewer takes the
|
|
1354
|
+
* good half of a proposal without rejecting the whole thing.
|
|
1355
|
+
*
|
|
1356
|
+
* Authority: exactly the approval predicate — an eligible approver for the
|
|
1357
|
+
* file per the access tree on `origin/<base>`. Declining takes the same
|
|
1358
|
+
* permission as accepting.
|
|
1359
|
+
*
|
|
1360
|
+
* When the LAST file is reverted the request proposes nothing: it closes
|
|
1361
|
+
* itself and its source branch is retired, exactly as a merge would have
|
|
1362
|
+
* retired it — a shell of a request pointing at an empty diff serves
|
|
1363
|
+
* nobody (see the zero-files dialog state this replaces).
|
|
1364
|
+
*/
|
|
1365
|
+
async revertChangeRequestFile(
|
|
1366
|
+
number: number,
|
|
1367
|
+
user: AuthUser,
|
|
1368
|
+
repoRelPath: string,
|
|
1369
|
+
): Promise<{ closed: boolean; remainingPaths: string[] }> {
|
|
1370
|
+
const summary = await this.prs.getPr(number);
|
|
1371
|
+
if (!summary) throw new WorkflowDomainError('change request not found', 404);
|
|
1372
|
+
if (summary.state !== 'open') {
|
|
1373
|
+
throw new WorkflowDomainError('This change request is no longer open.', 422);
|
|
1374
|
+
}
|
|
1375
|
+
const baseBranch = summary.base;
|
|
1376
|
+
const headBranch = summary.branch;
|
|
1377
|
+
|
|
1378
|
+
const ws = await this.workspaceService.getOrCreateForBranch(headBranch);
|
|
1379
|
+
// Best-effort freshen of the source checkout: the diff below reads origin
|
|
1380
|
+
// refs, but the restore commits from the working tree — a stale tree
|
|
1381
|
+
// would push non-fast-forward and fail loudly anyway; this just makes
|
|
1382
|
+
// that rare.
|
|
1383
|
+
await this.git.pull(ws.id).catch(() => undefined);
|
|
1384
|
+
|
|
1385
|
+
// The verb acts on the request as it is NOW — never a cached file list.
|
|
1386
|
+
const paths = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
|
|
1387
|
+
if (!paths.includes(repoRelPath)) {
|
|
1388
|
+
throw new WorkflowDomainError('That file is not part of this change request.', 422);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const canApprove = await this.accessControl.canWriteAtRef(
|
|
1392
|
+
ws.id,
|
|
1393
|
+
`origin/${baseBranch}`,
|
|
1394
|
+
user.email,
|
|
1395
|
+
repoRelPath,
|
|
1396
|
+
);
|
|
1397
|
+
if (canApprove !== true) {
|
|
1398
|
+
throw new WorkflowDomainError(
|
|
1399
|
+
'Only an eligible approver of this file can revert it — declining takes the same permission as accepting.',
|
|
1400
|
+
403,
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const mergeBase = await this.git.mergeBaseForPr(ws.id, baseBranch, headBranch);
|
|
1405
|
+
if (!mergeBase) {
|
|
1406
|
+
throw new WorkflowDomainError('These branches share no history to revert to.', 422);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Same per-file lock every other editor of this path takes — a concurrent
|
|
1410
|
+
// save must not race the restore between write and commit.
|
|
1411
|
+
const lockPath = `${this.kbDirName}/${repoRelPath}`;
|
|
1412
|
+
const lock = await this.fileLocks.acquire(ws.id, headBranch, lockPath, user);
|
|
1413
|
+
if (!lock.acquired) {
|
|
1414
|
+
throw new WorkflowDomainError(
|
|
1415
|
+
`${repoRelPath} is being edited by ${lock.lock.holderName} — try again once the edit settles.`,
|
|
1416
|
+
409,
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
try {
|
|
1420
|
+
await this.git.restorePathFromRef(ws.id, mergeBase, repoRelPath);
|
|
1421
|
+
await this.git.commitFile(
|
|
1422
|
+
ws.id,
|
|
1423
|
+
user,
|
|
1424
|
+
repoRelPath,
|
|
1425
|
+
`Revert ${repoRelPath} (declined in change request #${number})`,
|
|
1426
|
+
true, // skipValidator — this restores an already-validated base version
|
|
1427
|
+
);
|
|
1428
|
+
await this.git.push(ws.id, user);
|
|
1429
|
+
} finally {
|
|
1430
|
+
// Committed inline — drop the lock row directly rather than enqueueing
|
|
1431
|
+
// a duplicate commit through releaseLock.
|
|
1432
|
+
await this.fileLocks.release(ws.id, headBranch, lockPath, user);
|
|
1433
|
+
}
|
|
1434
|
+
this.prs.invalidateDetailCache(number);
|
|
1435
|
+
|
|
1436
|
+
const remaining = await this.git.changedPathsForPr(ws.id, baseBranch, headBranch);
|
|
1437
|
+
if (remaining.length > 0) {
|
|
1438
|
+
return { closed: false, remainingPaths: remaining };
|
|
1439
|
+
}
|
|
1440
|
+
// Every change declined → the request proposes nothing.
|
|
1441
|
+
await this.closeEmptyChangeRequest(number, user);
|
|
1442
|
+
return { closed: true, remainingPaths: [] };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* DELETE a change request outright: close it (whatever its diff says) and
|
|
1447
|
+
* retire its source branch — the request, its proposal, and the branch that
|
|
1448
|
+
* carried it are gone in one verb. Admin-only, resolved the same way every
|
|
1449
|
+
* other admin check here is (write on `roles.yaml` at `origin/<base>`,
|
|
1450
|
+
* never a local ref): this is the moderation verb for a shared deployment,
|
|
1451
|
+
* stronger than reject (which the author or the files' owners can do, and
|
|
1452
|
+
* which leaves the branch for a second round).
|
|
1453
|
+
*/
|
|
1454
|
+
async deleteChangeRequest(number: number, user: AuthUser): Promise<void> {
|
|
1455
|
+
const summary = await this.prs.getPr(number);
|
|
1456
|
+
if (!summary) throw new WorkflowDomainError('change request not found', 404);
|
|
1457
|
+
if (summary.state === 'merged') {
|
|
1458
|
+
throw new WorkflowDomainError('This change request has already been applied.', 422);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const ws = await this.workspaceService.getOrCreateForBranch(summary.base);
|
|
1462
|
+
await this.workspaceService.ensureRemotesFetched(ws.id).catch(() => undefined);
|
|
1463
|
+
const isAdmin = await this.accessControl.canWriteAtRef(
|
|
1464
|
+
ws.id,
|
|
1465
|
+
`origin/${summary.base}`,
|
|
1466
|
+
user.email,
|
|
1467
|
+
'roles.yaml',
|
|
1468
|
+
);
|
|
1469
|
+
if (isAdmin !== true) {
|
|
1470
|
+
throw new WorkflowDomainError('Only an admin can delete a change request.', 403);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Close first (idempotent: an already-closed request just skips to the
|
|
1474
|
+
// branch retirement). Guarded on `open` so a concurrent merge wins.
|
|
1475
|
+
const now = new Date();
|
|
1476
|
+
const updated = await this.db
|
|
1477
|
+
.update(changeRequests)
|
|
1478
|
+
.set({ state: 'closed', closedAt: now, updatedAt: now })
|
|
1479
|
+
.where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
|
|
1480
|
+
.returning({ id: changeRequests.id });
|
|
1481
|
+
if (updated.length > 0) {
|
|
1482
|
+
this.prs.invalidateDetailCache(number);
|
|
1483
|
+
this.events?.emit({ kind: 'change-request-rejected', number });
|
|
1484
|
+
} else {
|
|
1485
|
+
const fresh = await this.prs.getPr(number);
|
|
1486
|
+
if (fresh?.state === 'merged') {
|
|
1487
|
+
throw new WorkflowDomainError('This change request has already been applied.', 422);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
await this.retireMergedSourceBranch(number, summary.base, user);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* Close an open change request whose diff has EMPTIED — everything it
|
|
1495
|
+
* proposed has since landed on the target, been reverted, or been undone on
|
|
1496
|
+
* its own branch — and retire its source branch, exactly as a merge would
|
|
1497
|
+
* have. A request pointing at an empty diff serves nobody: reviewers never
|
|
1498
|
+
* see it (zero touched paths routes to no one) and the author sees a shell.
|
|
1499
|
+
*
|
|
1500
|
+
* Called after the last per-file revert, and as a lazy reconcile whenever a
|
|
1501
|
+
* detail read observes an open request with no files (the join-requests
|
|
1502
|
+
* pattern: derived state, settled on observation). The emptiness check here
|
|
1503
|
+
* is AUTHORITATIVE — recomputed, and a diff failure aborts rather than
|
|
1504
|
+
* closes, so a transient git error can never eat a live request.
|
|
1505
|
+
*
|
|
1506
|
+
* Returns true when THIS call closed it.
|
|
1507
|
+
*/
|
|
1508
|
+
async closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean> {
|
|
1509
|
+
const summary = await this.prs.getPr(number);
|
|
1510
|
+
if (!summary || summary.state !== 'open') return false;
|
|
1511
|
+
let paths: string[];
|
|
1512
|
+
try {
|
|
1513
|
+
const ws = await this.workspaceService.getOrCreateForBranch(summary.branch);
|
|
1514
|
+
paths = await this.git.changedPathsForPr(ws.id, summary.base, summary.branch);
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
console.warn(
|
|
1517
|
+
`[cr] empty-check for change request #${number} failed — leaving it open:`,
|
|
1518
|
+
err,
|
|
1519
|
+
);
|
|
1520
|
+
return false;
|
|
1521
|
+
}
|
|
1522
|
+
if (paths.length > 0) return false;
|
|
1523
|
+
|
|
1524
|
+
// Guard on `state = 'open'` so a concurrent merge or withdraw wins the
|
|
1525
|
+
// race and this becomes a no-op.
|
|
1526
|
+
const now = new Date();
|
|
1527
|
+
const updated = await this.db
|
|
1528
|
+
.update(changeRequests)
|
|
1529
|
+
.set({ state: 'closed', closedAt: now, updatedAt: now })
|
|
1530
|
+
.where(and(eq(changeRequests.number, number), eq(changeRequests.state, 'open')))
|
|
1531
|
+
.returning({ id: changeRequests.id });
|
|
1532
|
+
if (updated.length === 0) return false;
|
|
1533
|
+
this.prs.invalidateDetailCache(number);
|
|
1534
|
+
this.events?.emit({ kind: 'change-request-rejected', number });
|
|
1535
|
+
await this.retireMergedSourceBranch(number, summary.base, user);
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1262
1539
|
/**
|
|
1263
1540
|
* Reject (close-without-merging) a change request. Per PLAN.md spec, the
|
|
1264
1541
|
* authorized set is:
|
|
@@ -1418,16 +1695,19 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1418
1695
|
// this a read right after a merge misses the just-merged change. Best-effort:
|
|
1419
1696
|
// the merge already succeeded on origin, so a pull hiccup must not fail the
|
|
1420
1697
|
// response (a later fetch/pull reconciles).
|
|
1698
|
+
let targetWorkspaceId: string | undefined;
|
|
1421
1699
|
try {
|
|
1422
1700
|
const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
|
|
1701
|
+
targetWorkspaceId = targetWorkspace.id;
|
|
1423
1702
|
await this.git.pull(targetWorkspace.id);
|
|
1424
1703
|
} catch (err) {
|
|
1425
1704
|
// Still best-effort for the merge response (the merge already landed
|
|
1426
1705
|
// on origin) — but a rebase CONFLICT here means the target workspace
|
|
1427
1706
|
// is stranded (local commits vs origin, never self-heals), so queue
|
|
1428
|
-
// the background recovery ladder before shrugging.
|
|
1429
|
-
|
|
1430
|
-
|
|
1707
|
+
// the background recovery ladder before shrugging. A conflict can
|
|
1708
|
+
// only have come from the pull, so the workspace id is always set on
|
|
1709
|
+
// this arm — the guard just satisfies the narrowing.
|
|
1710
|
+
if (err instanceof PullRebaseConflictError && targetWorkspaceId) {
|
|
1431
1711
|
await this.queuePullConflictRecovery(targetWorkspaceId, err, user);
|
|
1432
1712
|
}
|
|
1433
1713
|
console.warn(
|
|
@@ -1437,9 +1717,75 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1437
1717
|
}
|
|
1438
1718
|
this.prs.invalidateDetailCache(number);
|
|
1439
1719
|
this.events?.emit({ kind: 'change-request-merged', number });
|
|
1720
|
+
// AFTER the event: the applying UI is waiting on `change-request-merged`,
|
|
1721
|
+
// and branch retirement is git IO it must never wait behind.
|
|
1722
|
+
await this.retireMergedSourceBranch(number, baseBranch, user);
|
|
1440
1723
|
return { kind: 'merged', result };
|
|
1441
1724
|
}
|
|
1442
1725
|
|
|
1726
|
+
/**
|
|
1727
|
+
* Best-effort retirement of a merged change request's source branch — the
|
|
1728
|
+
* standard "delete branch on merge" a git host performs, done here because
|
|
1729
|
+
* this app IS the host. Everything the branch carried is on the target
|
|
1730
|
+
* now, and leaving it behind has a real cost: the propose flow REUSES an
|
|
1731
|
+
* existing branch by name, so a leftover base goes stale against the
|
|
1732
|
+
* target and seeds the next proposal with outdated text (or, worse,
|
|
1733
|
+
* re-proposes withdrawn commits).
|
|
1734
|
+
*
|
|
1735
|
+
* Refuses quietly when the branch is protected or still carries another
|
|
1736
|
+
* OPEN request. Runs from the TARGET's workspace (guaranteed not to be
|
|
1737
|
+
* checked out on the branch being deleted) with `systemCleanup`, since the
|
|
1738
|
+
* merger need not be the branch's author — the merge itself was the
|
|
1739
|
+
* authorization. Never throws: the merge has already succeeded, and a
|
|
1740
|
+
* cleanup failure only means the next proposal falls back to branch reuse.
|
|
1741
|
+
*/
|
|
1742
|
+
private async retireMergedSourceBranch(
|
|
1743
|
+
number: number,
|
|
1744
|
+
baseBranch: string,
|
|
1745
|
+
user: AuthUser,
|
|
1746
|
+
): Promise<void> {
|
|
1747
|
+
try {
|
|
1748
|
+
const rows = await this.db
|
|
1749
|
+
.select({ sourceBranch: changeRequests.sourceBranch })
|
|
1750
|
+
.from(changeRequests)
|
|
1751
|
+
.where(eq(changeRequests.number, number))
|
|
1752
|
+
.limit(1);
|
|
1753
|
+
const sourceBranch = rows[0]?.sourceBranch;
|
|
1754
|
+
if (!sourceBranch || isProtectedBranch(sourceBranch)) return;
|
|
1755
|
+
// The open-check and the deletion hold the branch's lifecycle lock
|
|
1756
|
+
// TOGETHER — `openChangeRequest` holds the same lock across its own
|
|
1757
|
+
// check-then-insert, so a request being opened from this branch either
|
|
1758
|
+
// lands its row before the check below (retirement backs off) or waits
|
|
1759
|
+
// until retirement finishes (and fails loudly on the missing branch,
|
|
1760
|
+
// retryable, instead of losing the branch mid-open).
|
|
1761
|
+
await this.branchLifecycle.run(`branch:${sourceBranch}`, async () => {
|
|
1762
|
+
const stillOpen = await this.db
|
|
1763
|
+
.select({ number: changeRequests.number })
|
|
1764
|
+
.from(changeRequests)
|
|
1765
|
+
.where(
|
|
1766
|
+
and(
|
|
1767
|
+
eq(changeRequests.sourceBranch, sourceBranch),
|
|
1768
|
+
eq(changeRequests.state, 'open'),
|
|
1769
|
+
),
|
|
1770
|
+
)
|
|
1771
|
+
.limit(1);
|
|
1772
|
+
if (stillOpen.length > 0) return;
|
|
1773
|
+
|
|
1774
|
+
const targetWs = await this.workspaceService.getOrCreateForBranch(baseBranch);
|
|
1775
|
+
// The remote-tracking ref must be current, or deleteBranch's
|
|
1776
|
+
// "does origin still have it?" probe skips the remote delete.
|
|
1777
|
+
await this.workspaceService.ensureRemotesFetched(targetWs.id).catch(() => {});
|
|
1778
|
+
await this.deleteBranchUnlocked(targetWs.id, sourceBranch, user, { systemCleanup: true });
|
|
1779
|
+
console.log(`[merge] retired merged source branch "${sourceBranch}"`);
|
|
1780
|
+
});
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
console.warn(
|
|
1783
|
+
`[merge] could not retire the merged source branch of change request #${number} (non-fatal):`,
|
|
1784
|
+
err,
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1443
1789
|
/**
|
|
1444
1790
|
* Guarantee a CR carries NO `roles.yaml` change across its merge: if the CR's
|
|
1445
1791
|
* `roles.yaml` differs from the base branch's, restore the base version on the
|