@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
|
@@ -319,4 +319,39 @@ describe('WorkflowEventBus', () => {
|
|
|
319
319
|
expect(bus.size()).toBe(0);
|
|
320
320
|
});
|
|
321
321
|
});
|
|
322
|
+
|
|
323
|
+
describe('onEmit — the server-side tap', () => {
|
|
324
|
+
it('sees every event, unfiltered by SSE scope, and unsubscribes cleanly', () => {
|
|
325
|
+
const bus = new WorkflowEventBus();
|
|
326
|
+
const seen: string[] = [];
|
|
327
|
+
const off = bus.onEmit((e) => seen.push(e.kind));
|
|
328
|
+
// A workspace-scoped event with NO matching SSE session still reaches
|
|
329
|
+
// the tap — scope filtering exists for sessions, not in-process
|
|
330
|
+
// consumers (write-time cache invalidation must never miss one).
|
|
331
|
+
bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' });
|
|
332
|
+
expect(seen).toEqual(['fs-tree-changed']);
|
|
333
|
+
off();
|
|
334
|
+
bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' });
|
|
335
|
+
expect(seen).toEqual(['fs-tree-changed']);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('a throwing listener is contained — emit never propagates it', () => {
|
|
339
|
+
const bus = new WorkflowEventBus();
|
|
340
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
341
|
+
bus.onEmit(() => {
|
|
342
|
+
throw new Error('bad listener');
|
|
343
|
+
});
|
|
344
|
+
const after: string[] = [];
|
|
345
|
+
bus.onEmit((e) => after.push(e.kind));
|
|
346
|
+
expect(() =>
|
|
347
|
+
bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' }),
|
|
348
|
+
).not.toThrow();
|
|
349
|
+
// …and the listeners after the bad one still ran.
|
|
350
|
+
expect(after).toEqual(['fs-tree-changed']);
|
|
351
|
+
// The containment is LOGGED, not silent — a listener dying quietly
|
|
352
|
+
// would be undebuggable.
|
|
353
|
+
expect(warn).toHaveBeenCalledWith('[event-bus] onEmit listener threw:', 'bad listener');
|
|
354
|
+
warn.mockRestore();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
322
357
|
});
|
|
@@ -15,10 +15,17 @@ import { WorkflowService } from '../workflow.service.js';
|
|
|
15
15
|
import { PullRebaseConflictError } from '../workflow.errors.js';
|
|
16
16
|
import type { Database } from '../../database/connection.js';
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// `deleteBranch`'s open-request guard is the only DB touch these tests
|
|
19
|
+
// exercise: a chainable select→from→where→limit stub resolving `rows` covers
|
|
20
|
+
// it (rows default to "no open request").
|
|
21
|
+
function makeDb(rows: unknown[] = []): Database {
|
|
22
|
+
const chain = {
|
|
23
|
+
select: vi.fn(() => chain),
|
|
24
|
+
from: vi.fn(() => chain),
|
|
25
|
+
where: vi.fn(() => chain),
|
|
26
|
+
limit: vi.fn(async () => rows),
|
|
27
|
+
};
|
|
28
|
+
return chain as unknown as Database;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
function makeWorkspaceService(): WorkspaceService {
|
|
@@ -29,6 +36,9 @@ function makeWorkspaceService(): WorkspaceService {
|
|
|
29
36
|
getWorkspacePath: vi.fn().mockResolvedValue('/tmp/ws'),
|
|
30
37
|
sweepOrphanedWorkspaces: vi.fn().mockResolvedValue({ removed: [] }),
|
|
31
38
|
getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: encodeURIComponent(branch) })),
|
|
39
|
+
ensureRemotesFetched: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
hasBootstrappedWorkspace: vi.fn().mockResolvedValue(false),
|
|
41
|
+
deleteWorkspace: vi.fn().mockResolvedValue(undefined),
|
|
32
42
|
} as unknown as WorkspaceService;
|
|
33
43
|
}
|
|
34
44
|
|
|
@@ -130,7 +140,6 @@ function makeGit(): GitService {
|
|
|
130
140
|
diffStat: vi.fn(),
|
|
131
141
|
pendingChanges: vi.fn(),
|
|
132
142
|
resolveForkBase: vi.fn(),
|
|
133
|
-
revertCommit: vi.fn(),
|
|
134
143
|
logForFile: vi.fn(),
|
|
135
144
|
diffFileAtCommit: vi.fn(),
|
|
136
145
|
diffFileBetweenBranches: vi.fn(),
|
|
@@ -412,6 +421,48 @@ describe('WorkflowService — file lock delegation', () => {
|
|
|
412
421
|
expect(fileLocks.release).toHaveBeenCalledWith('w1', 'feat', 'Foo.md', expect.any(Object));
|
|
413
422
|
});
|
|
414
423
|
|
|
424
|
+
it('deleteBranch refuses while the branch carries an open change request', async () => {
|
|
425
|
+
const git = makeGit();
|
|
426
|
+
// One open request rides the branch — deleting it would strand the request.
|
|
427
|
+
const svc = new WorkflowService(makeDb([{ number: 7 }]), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
428
|
+
await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).rejects.toThrow(/open change request \(#7\)/);
|
|
429
|
+
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('deleteBranch deletes when the branch has no open change request', async () => {
|
|
433
|
+
const git = makeGit();
|
|
434
|
+
const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
435
|
+
await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).resolves.toBeUndefined();
|
|
436
|
+
expect(git.deleteBranch).toHaveBeenCalledWith('w1', 'feat/x', expect.objectContaining({ email: 'alice@example.com' }), undefined);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The lifecycle lock is what keeps merge-time retirement from deleting a
|
|
441
|
+
* branch mid-`openChangeRequest`. The full interleaving needs a real git
|
|
442
|
+
* repo + DB; what IS unit-testable is the lock's contract — two operations
|
|
443
|
+
* keyed on the same branch never overlap, the second fully waiting out the
|
|
444
|
+
* first.
|
|
445
|
+
*/
|
|
446
|
+
it('serialises same-branch lifecycle operations', async () => {
|
|
447
|
+
const order: string[] = [];
|
|
448
|
+
const git = makeGit();
|
|
449
|
+
let releaseFirst!: () => void;
|
|
450
|
+
const gate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
|
451
|
+
vi.mocked(git.deleteBranch)
|
|
452
|
+
.mockImplementationOnce(async () => { order.push('first:start'); await gate; order.push('first:end'); })
|
|
453
|
+
.mockImplementationOnce(async () => { order.push('second'); });
|
|
454
|
+
const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
455
|
+
const first = svc.deleteBranch('w1', 'feat/x', makeUser());
|
|
456
|
+
const second = svc.deleteBranch('w1', 'feat/x', makeUser());
|
|
457
|
+
// Only release the gate once the first operation is provably inside its
|
|
458
|
+
// critical section — otherwise a non-serialised second could sneak
|
|
459
|
+
// through before 'first:start' and the assertion would not distinguish.
|
|
460
|
+
await vi.waitFor(() => { expect(order).toContain('first:start'); });
|
|
461
|
+
releaseFirst();
|
|
462
|
+
await Promise.all([first, second]);
|
|
463
|
+
expect(order).toEqual(['first:start', 'first:end', 'second']);
|
|
464
|
+
});
|
|
465
|
+
|
|
415
466
|
it('releaseLock refuses when the caller does not hold the lock', async () => {
|
|
416
467
|
const git = makeGit();
|
|
417
468
|
const fileLocks = makeFileLockService(); // default: get → null
|
|
@@ -422,3 +473,198 @@ describe('WorkflowService — file lock delegation', () => {
|
|
|
422
473
|
expect(git.commitFile).not.toHaveBeenCalled();
|
|
423
474
|
});
|
|
424
475
|
});
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The reviewer's per-file scalpel, and the empty-close it can trigger. All
|
|
479
|
+
* collaborators stubbed — what's under test is the orchestration: the auth
|
|
480
|
+
* predicate, the restore→commit→push order, the lock discipline, and the
|
|
481
|
+
* authoritative emptiness re-check before anything closes.
|
|
482
|
+
*/
|
|
483
|
+
describe('WorkflowService — revertChangeRequestFile / closeEmptyChangeRequest', () => {
|
|
484
|
+
const SUMMARY = { number: 7, base: 'main', branch: 'ali/x', state: 'open' };
|
|
485
|
+
|
|
486
|
+
function makeRevertGit(overrides: Record<string, unknown> = {}): GitService {
|
|
487
|
+
return Object.assign(makeGit(), {
|
|
488
|
+
pull: vi.fn().mockResolvedValue(undefined),
|
|
489
|
+
changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md', 'Docs/b.md']),
|
|
490
|
+
mergeBaseForPr: vi.fn().mockResolvedValue('mb-sha'),
|
|
491
|
+
restorePathFromRef: vi.fn().mockResolvedValue(undefined),
|
|
492
|
+
commitFile: vi.fn().mockResolvedValue({}),
|
|
493
|
+
push: vi.fn().mockResolvedValue(undefined),
|
|
494
|
+
...overrides,
|
|
495
|
+
}) as unknown as GitService;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function makeHarness(opts: {
|
|
499
|
+
git?: GitService;
|
|
500
|
+
canWriteAtRef?: boolean | null;
|
|
501
|
+
prState?: string;
|
|
502
|
+
updateRows?: unknown[];
|
|
503
|
+
} = {}) {
|
|
504
|
+
const git = opts.git ?? makeRevertGit();
|
|
505
|
+
const prs = makePrs();
|
|
506
|
+
(prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
507
|
+
...SUMMARY,
|
|
508
|
+
state: opts.prState ?? 'open',
|
|
509
|
+
});
|
|
510
|
+
const access = makeAccessControl();
|
|
511
|
+
(access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
512
|
+
opts.canWriteAtRef ?? true,
|
|
513
|
+
);
|
|
514
|
+
const fileLocks = makeFileLockService();
|
|
515
|
+
// The chainable stub answers SELECTs with [] (no open request rows) and
|
|
516
|
+
// UPDATE...returning with `updateRows` (default: one row = the close won).
|
|
517
|
+
const updateRows = opts.updateRows ?? [{ id: 1 }];
|
|
518
|
+
const chain: Record<string, unknown> = {};
|
|
519
|
+
Object.assign(chain, {
|
|
520
|
+
select: vi.fn(() => chain),
|
|
521
|
+
from: vi.fn(() => chain),
|
|
522
|
+
where: vi.fn(() => chain),
|
|
523
|
+
limit: vi.fn(async () => []),
|
|
524
|
+
update: vi.fn(() => chain),
|
|
525
|
+
set: vi.fn(() => chain),
|
|
526
|
+
returning: vi.fn(async () => updateRows),
|
|
527
|
+
});
|
|
528
|
+
const db = chain as unknown as Database;
|
|
529
|
+
const svc = new WorkflowService(db, git, prs, makeReviewWorkflow(), makeWorkspaceService(), access, fileLocks, makePendingCommits(), 'knowledge-base');
|
|
530
|
+
return { svc, git, prs, access, fileLocks, db: chain };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
it('restores the merge-base copy on the source branch: restore, commit (validator skipped), push — under the file lock', async () => {
|
|
534
|
+
const { svc, git, prs, fileLocks } = makeHarness();
|
|
535
|
+
const result = await svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md');
|
|
536
|
+
|
|
537
|
+
expect(git.restorePathFromRef).toHaveBeenCalledWith('ali%2Fx', 'mb-sha', 'Docs/a.md');
|
|
538
|
+
expect(git.commitFile).toHaveBeenCalledWith(
|
|
539
|
+
'ali%2Fx',
|
|
540
|
+
expect.objectContaining({ email: 'alice@example.com' }),
|
|
541
|
+
'Docs/a.md',
|
|
542
|
+
expect.stringContaining('change request #7'),
|
|
543
|
+
true,
|
|
544
|
+
);
|
|
545
|
+
expect(git.push).toHaveBeenCalled();
|
|
546
|
+
expect(fileLocks.acquire).toHaveBeenCalledWith(
|
|
547
|
+
'ali%2Fx',
|
|
548
|
+
'ali/x',
|
|
549
|
+
'knowledge-base/Docs/a.md',
|
|
550
|
+
expect.anything(),
|
|
551
|
+
);
|
|
552
|
+
expect(fileLocks.release).toHaveBeenCalled();
|
|
553
|
+
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(7);
|
|
554
|
+
// Both diffs answered two paths → one revert leaves one.
|
|
555
|
+
expect(result).toEqual({ closed: false, remainingPaths: ['Docs/a.md', 'Docs/b.md'] });
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('refuses a caller who could not approve the file — same permission, both verbs', async () => {
|
|
559
|
+
const { svc, git } = makeHarness({ canWriteAtRef: false });
|
|
560
|
+
await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md')).rejects.toMatchObject({
|
|
561
|
+
status: 403,
|
|
562
|
+
});
|
|
563
|
+
expect(git.restorePathFromRef).not.toHaveBeenCalled();
|
|
564
|
+
expect(git.push).not.toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('refuses a path the request does not touch', async () => {
|
|
568
|
+
const { svc, git } = makeHarness();
|
|
569
|
+
await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/elsewhere.md')).rejects.toMatchObject({
|
|
570
|
+
status: 422,
|
|
571
|
+
});
|
|
572
|
+
expect(git.restorePathFromRef).not.toHaveBeenCalled();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('refuses a request that is no longer open', async () => {
|
|
576
|
+
const { svc } = makeHarness({ prState: 'closed' });
|
|
577
|
+
await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md')).rejects.toMatchObject({
|
|
578
|
+
status: 422,
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('closes the request when the LAST file is reverted', async () => {
|
|
583
|
+
const git = makeRevertGit({
|
|
584
|
+
changedPathsForPr: vi
|
|
585
|
+
.fn()
|
|
586
|
+
// in order: the pre-revert list, the post-revert remainder, and the
|
|
587
|
+
// close path's own authoritative re-check.
|
|
588
|
+
.mockResolvedValueOnce(['Docs/a.md'])
|
|
589
|
+
.mockResolvedValueOnce([])
|
|
590
|
+
.mockResolvedValueOnce([]),
|
|
591
|
+
});
|
|
592
|
+
const { svc, db } = makeHarness({ git });
|
|
593
|
+
const result = await svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md');
|
|
594
|
+
expect(result).toEqual({ closed: true, remainingPaths: [] });
|
|
595
|
+
expect(db.update).toHaveBeenCalled();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('closeEmptyChangeRequest never closes on a FAILED diff', async () => {
|
|
599
|
+
const git = makeRevertGit({
|
|
600
|
+
changedPathsForPr: vi.fn().mockRejectedValue(new Error('git down')),
|
|
601
|
+
});
|
|
602
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
603
|
+
const { svc, db } = makeHarness({ git });
|
|
604
|
+
await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(false);
|
|
605
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
606
|
+
warn.mockRestore();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('closeEmptyChangeRequest leaves a request with real changes alone', async () => {
|
|
610
|
+
const { svc, db } = makeHarness();
|
|
611
|
+
await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(false);
|
|
612
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('closeEmptyChangeRequest closes an emptied request', async () => {
|
|
616
|
+
const git = makeRevertGit({ changedPathsForPr: vi.fn().mockResolvedValue([]) });
|
|
617
|
+
const { svc, db, prs } = makeHarness({ git });
|
|
618
|
+
await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(true);
|
|
619
|
+
expect(db.update).toHaveBeenCalled();
|
|
620
|
+
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(7);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () => {
|
|
625
|
+
function makeDeleteHarness(opts: { isAdmin?: boolean; prState?: string } = {}) {
|
|
626
|
+
const git = Object.assign(makeGit(), {
|
|
627
|
+
changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md']),
|
|
628
|
+
}) as unknown as GitService;
|
|
629
|
+
const prs = makePrs();
|
|
630
|
+
(prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
631
|
+
number: 9,
|
|
632
|
+
base: 'main',
|
|
633
|
+
branch: 'mallory/spam',
|
|
634
|
+
state: opts.prState ?? 'open',
|
|
635
|
+
});
|
|
636
|
+
const access = makeAccessControl();
|
|
637
|
+
(access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(opts.isAdmin ?? false);
|
|
638
|
+
const chain: Record<string, unknown> = {};
|
|
639
|
+
Object.assign(chain, {
|
|
640
|
+
select: vi.fn(() => chain),
|
|
641
|
+
from: vi.fn(() => chain),
|
|
642
|
+
where: vi.fn(() => chain),
|
|
643
|
+
limit: vi.fn(async () => []),
|
|
644
|
+
update: vi.fn(() => chain),
|
|
645
|
+
set: vi.fn(() => chain),
|
|
646
|
+
returning: vi.fn(async () => [{ id: 1 }]),
|
|
647
|
+
});
|
|
648
|
+
const svc = new WorkflowService(chain as unknown as Database, git, prs, makeReviewWorkflow(), makeWorkspaceService(), access, makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
649
|
+
return { svc, prs, db: chain, access };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
it('refuses a non-admin with 403 and touches nothing', async () => {
|
|
653
|
+
const { svc, db } = makeDeleteHarness({ isAdmin: false });
|
|
654
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 403 });
|
|
655
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('an admin closes the request (row flipped, cache dropped)', async () => {
|
|
659
|
+
const { svc, prs, db } = makeDeleteHarness({ isAdmin: true });
|
|
660
|
+
await svc.deleteChangeRequest(9, makeUser());
|
|
661
|
+
expect(db.update).toHaveBeenCalled();
|
|
662
|
+
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(9);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('refuses a merged request — applied history is not deletable', async () => {
|
|
666
|
+
const { svc, db } = makeDeleteHarness({ isAdmin: true, prState: 'merged' });
|
|
667
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 422 });
|
|
668
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
669
|
+
});
|
|
670
|
+
});
|
|
@@ -7,7 +7,9 @@ import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
|
7
7
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
8
|
import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// A function, not a constant: the branch model is applied during boot, and a
|
|
11
|
+
// module-scope capture would freeze this at the empty set that exists before it.
|
|
12
|
+
const protectedInline = () => [...PROTECTED_BRANCHES].map((b) => `\`${b}\``).join(' / ');
|
|
11
13
|
|
|
12
14
|
// Output sub-schemas for the git-aliased workflow payloads (Branch, Change,
|
|
13
15
|
// ChangeRequestComment, …). Defined once and referenced by the individual tool
|
|
@@ -268,7 +270,7 @@ export function registerWorkflowTools(
|
|
|
268
270
|
name: 'create_branch',
|
|
269
271
|
description:
|
|
270
272
|
'Create a new unprotected draft named `name`, forked from the existing branch `branch`. The ' +
|
|
271
|
-
`backend rejects protected names (${
|
|
273
|
+
`backend rejects protected names (${protectedInline()}) and filesystem-invalid names — surface ` +
|
|
272
274
|
'those errors verbatim. Convention: `<email-localpart>/<kebab-slug>`. Does NOT switch the ' +
|
|
273
275
|
'agent onto the new draft.',
|
|
274
276
|
// `branch` here is the fork BASE — its own meaning — so skip the generic
|
|
@@ -91,6 +91,21 @@ export class WorkflowEventBus {
|
|
|
91
91
|
private nextId = 1;
|
|
92
92
|
private readonly buffer: WorkflowEvent[] = [];
|
|
93
93
|
private readonly subscribers = new Map<string, Subscriber>();
|
|
94
|
+
private readonly emitListeners = new Set<(event: WorkflowEvent) => void>();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Server-side tap: called for EVERY emitted event, before the per-session
|
|
98
|
+
* scope filtering (which exists for SSE fan-out, not for in-process
|
|
99
|
+
* consumers). The composition root uses this for write-time cache
|
|
100
|
+
* invalidation — the routes emit `fs-tree-changed` the moment bytes hit a
|
|
101
|
+
* working tree, long before the async commit reaches `FileChangeNotifier`.
|
|
102
|
+
* Best-effort like SSE dispatch: a throwing listener is logged, never
|
|
103
|
+
* propagated to the emitter. Returns the unsubscribe.
|
|
104
|
+
*/
|
|
105
|
+
onEmit(listener: (event: WorkflowEvent) => void): () => void {
|
|
106
|
+
this.emitListeners.add(listener);
|
|
107
|
+
return () => this.emitListeners.delete(listener);
|
|
108
|
+
}
|
|
94
109
|
|
|
95
110
|
/**
|
|
96
111
|
* Stamp `payload` with `id` + `ts`, append to the ring buffer, then fan
|
|
@@ -108,6 +123,16 @@ export class WorkflowEventBus {
|
|
|
108
123
|
if (this.buffer.length > BUFFER_CAPACITY) {
|
|
109
124
|
this.buffer.shift();
|
|
110
125
|
}
|
|
126
|
+
for (const listener of this.emitListeners) {
|
|
127
|
+
try {
|
|
128
|
+
listener(event);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn(
|
|
131
|
+
'[event-bus] onEmit listener threw:',
|
|
132
|
+
err instanceof Error ? err.message : err,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
111
136
|
// Single-line emit log with all the fields most useful for tracing a
|
|
112
137
|
// failed delivery: which event, which scope it'll filter on, and the
|
|
113
138
|
// total subscriber pool size before fan-out. Per-subscriber matched/
|
|
@@ -266,4 +266,21 @@ export class FileLockService {
|
|
|
266
266
|
}
|
|
267
267
|
return rowToFileLock(row);
|
|
268
268
|
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Is ANY unexpired lock held anywhere in this workspace? Feeds the git
|
|
272
|
+
* status sanity check: a held lock means a mutation is mid-flight — the
|
|
273
|
+
* file may already be changed (or deleted) on disk while its commit is only
|
|
274
|
+
* queued at RELEASE — so a dirty tree during that window is expected, not
|
|
275
|
+
* the "missed lock-release commit" the loud warning is for. Expired rows
|
|
276
|
+
* don't count (their holder is gone; they explain nothing).
|
|
277
|
+
*/
|
|
278
|
+
async hasAnyActive(workspaceId: string): Promise<boolean> {
|
|
279
|
+
const rows = await this.db
|
|
280
|
+
.select({ path: fileLocks.path })
|
|
281
|
+
.from(fileLocks)
|
|
282
|
+
.where(and(eq(fileLocks.workspaceId, workspaceId), gt(fileLocks.expiresAt, new Date())))
|
|
283
|
+
.limit(1);
|
|
284
|
+
return rows.length > 0;
|
|
285
|
+
}
|
|
269
286
|
}
|
|
@@ -232,55 +232,6 @@ describe('GitService — commit gate uses HEAD (not working tree)', () => {
|
|
|
232
232
|
});
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
-
describe('GitService — revert gate uses HEAD', () => {
|
|
236
|
-
let root: string;
|
|
237
|
-
const workspaceId = 'ws-revert-gate';
|
|
238
|
-
|
|
239
|
-
beforeEach(async () => {
|
|
240
|
-
root = await mkTmpRoot();
|
|
241
|
-
});
|
|
242
|
-
afterEach(async () => {
|
|
243
|
-
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('passes ref=HEAD when gating a revert', async () => {
|
|
247
|
-
const { ac, calls } = recordingAccessControl();
|
|
248
|
-
const { svc, repo } = await makeSvc(root, workspaceId, ac);
|
|
249
|
-
await commitFile(repo, 'Knowledge/Foo.md', 'a\n', 'seed');
|
|
250
|
-
await commitFile(repo, 'Knowledge/Foo.md', 'b\n', 'second');
|
|
251
|
-
const { stdout: sha } = await execFileAsync('git', ['-C', repo, 'rev-parse', 'HEAD'], {
|
|
252
|
-
encoding: 'utf-8',
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// We only care that the gate is invoked with `HEAD`; downstream
|
|
256
|
-
// behaviour of `git revert` (which may or may not succeed for unrelated
|
|
257
|
-
// environmental reasons — author parsing, signing config, etc.) is out
|
|
258
|
-
// of scope for this test.
|
|
259
|
-
await svc.revertCommit(workspaceId, USER, sha.trim()).catch(() => undefined);
|
|
260
|
-
|
|
261
|
-
const gateCalls = calls.filter((c) => c.method === 'canWriteBatchAtRef');
|
|
262
|
-
expect(gateCalls).toHaveLength(1);
|
|
263
|
-
expect(gateCalls[0].ref).toBe('HEAD');
|
|
264
|
-
expect(gateCalls[0].paths).toContain('Knowledge/Foo.md');
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it('refuses with AccessDeniedError when the gate denies', async () => {
|
|
268
|
-
const { ac } = recordingAccessControl({
|
|
269
|
-
canWriteBatchAtRef: (_ref, _email, paths) => new Map(paths.map((p) => [p, false])),
|
|
270
|
-
});
|
|
271
|
-
const { svc, repo } = await makeSvc(root, workspaceId, ac);
|
|
272
|
-
await commitFile(repo, 'Knowledge/Foo.md', 'a\n', 'seed');
|
|
273
|
-
await commitFile(repo, 'Knowledge/Foo.md', 'b\n', 'second');
|
|
274
|
-
const { stdout: sha } = await execFileAsync('git', ['-C', repo, 'rev-parse', 'HEAD'], {
|
|
275
|
-
encoding: 'utf-8',
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
await expect(svc.revertCommit(workspaceId, USER, sha.trim())).rejects.toBeInstanceOf(
|
|
279
|
-
AccessDeniedError,
|
|
280
|
-
);
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
235
|
describe('GitService — push gate uses origin/<branch> (not HEAD or working tree)', () => {
|
|
285
236
|
let root: string;
|
|
286
237
|
const workspaceId = 'ws-push-gate';
|
|
@@ -327,6 +278,15 @@ describe('GitService — push gate uses origin/<branch> (not HEAD or working tre
|
|
|
327
278
|
expect(gateCalls[0].paths).toContain('Knowledge/Foo.md');
|
|
328
279
|
});
|
|
329
280
|
|
|
281
|
+
it('systemAuthorized skips the gate entirely, and the push still lands', async () => {
|
|
282
|
+
// The group-provisioning path: its endpoint IS the authorization (any
|
|
283
|
+
// signed-in user may claim an unused name under Groups/), and the gate
|
|
284
|
+
// could only read the new folder's chain at origin as `write: Admin`.
|
|
285
|
+
const { svc, calls } = await setupWithOrigin();
|
|
286
|
+
await svc.push(workspaceId, USER, { systemAuthorized: true });
|
|
287
|
+
expect(calls.filter((c) => c.method === 'canWriteBatchAtRef')).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
330
290
|
it('refuses the push when origin/<branch> rules deny the caller', async () => {
|
|
331
291
|
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
332
292
|
const originDir = path.join(root, 'origin.git');
|
|
@@ -38,6 +38,14 @@ async function seedWorkspace(root: string, workspaceId: string) {
|
|
|
38
38
|
const repo = path.join(workspaceDir, 'knowledge-base');
|
|
39
39
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
40
40
|
await runGit(root, ['clone', upstream, repo]);
|
|
41
|
+
// Repo-local identity, because `GitService` spawns its own git children
|
|
42
|
+
// with the AMBIENT env — `runGit`'s `GIT_COMMITTER_*` vars never reach
|
|
43
|
+
// them. A dev machine hides this behind a global git config; a CI runner
|
|
44
|
+
// has none, so anything committing through the service (here:
|
|
45
|
+
// `commitFile`) fails with "Committer identity unknown". Same reason
|
|
46
|
+
// `git.service.commitFile.test.ts` configures its fixture this way.
|
|
47
|
+
await runGit(repo, ['config', 'user.email', 'test@bevel.local']);
|
|
48
|
+
await runGit(repo, ['config', 'user.name', 'Test Runner']);
|
|
41
49
|
return { upstream, repo };
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -121,4 +129,97 @@ describe('GitService.changedFilesForPr / resolvePrShas', () => {
|
|
|
121
129
|
expect(headSha).toMatch(/^[0-9a-f]{40}$/);
|
|
122
130
|
expect(baseSha).not.toBe(headSha);
|
|
123
131
|
});
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* roles.yaml can never change through a merge — `preserveBaseRolesYaml`
|
|
135
|
+
* restores the base copy onto the source before every merge — so the review
|
|
136
|
+
* surface must not list it as changed: the claim would be false, the empty
|
|
137
|
+
* diff reads as a bug, and its per-file approval would gate the merge on a
|
|
138
|
+
* change that cannot land. Counts stay aligned with the surviving files.
|
|
139
|
+
*/
|
|
140
|
+
it('excludes roles.yaml from the changed-file list and the touched paths', async () => {
|
|
141
|
+
const { repo } = await seedWorkspace(root, workspaceId);
|
|
142
|
+
await runGit(repo, ['checkout', '-b', 'mallory/self-promote']);
|
|
143
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - mallory@x.com\n');
|
|
144
|
+
await fs.writeFile(path.join(repo, 'honest.md'), 'real change\nsecond line\n');
|
|
145
|
+
await runGit(repo, ['add', '-A']);
|
|
146
|
+
await runGit(repo, ['commit', '-m', 'work + attempted escalation']);
|
|
147
|
+
await runGit(repo, ['push', '-u', 'origin', 'mallory/self-promote']);
|
|
148
|
+
|
|
149
|
+
const git = new GitService(
|
|
150
|
+
stubWorkspaceService(workspaceId, repo),
|
|
151
|
+
new WorkflowHooks(),
|
|
152
|
+
'knowledge-base',
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const files = await git.changedFilesForPr(
|
|
156
|
+
workspaceId,
|
|
157
|
+
'current-company-state',
|
|
158
|
+
'mallory/self-promote',
|
|
159
|
+
);
|
|
160
|
+
expect(files.map((f) => f.path)).toEqual(['honest.md']);
|
|
161
|
+
// The counts filter moved in step with the statuses filter.
|
|
162
|
+
expect(files[0].additions).toBe(2);
|
|
163
|
+
|
|
164
|
+
const paths = await git.changedPathsForPr(
|
|
165
|
+
workspaceId,
|
|
166
|
+
'current-company-state',
|
|
167
|
+
'mallory/self-promote',
|
|
168
|
+
);
|
|
169
|
+
expect(paths).toEqual(['honest.md']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The per-file revert's two primitives: the merge-base a revert restores
|
|
174
|
+
* from, and the restore itself — byte-exact via git, with "absent at the
|
|
175
|
+
* merge-base" meaning deletion (the revert of an added file).
|
|
176
|
+
*/
|
|
177
|
+
it('mergeBaseForPr + restorePathFromRef restore a modified file and delete an added one', async () => {
|
|
178
|
+
const { repo } = await seedWorkspace(root, workspaceId);
|
|
179
|
+
await runGit(repo, ['checkout', '-b', 'alice/feature']);
|
|
180
|
+
await fs.writeFile(path.join(repo, 'base.md'), 'rewritten\n');
|
|
181
|
+
await fs.writeFile(path.join(repo, 'added.md'), 'brand new\n');
|
|
182
|
+
await runGit(repo, ['add', '-A']);
|
|
183
|
+
await runGit(repo, ['commit', '-m', 'feature work']);
|
|
184
|
+
await runGit(repo, ['push', '-u', 'origin', 'alice/feature']);
|
|
185
|
+
|
|
186
|
+
const git = new GitService(
|
|
187
|
+
stubWorkspaceService(workspaceId, repo),
|
|
188
|
+
new WorkflowHooks(),
|
|
189
|
+
'knowledge-base',
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const mergeBase = await git.mergeBaseForPr(workspaceId, 'current-company-state', 'alice/feature');
|
|
193
|
+
expect(mergeBase).toMatch(/^[0-9a-f]{40}$/);
|
|
194
|
+
|
|
195
|
+
await git.restorePathFromRef(workspaceId, mergeBase!, 'base.md');
|
|
196
|
+
// Normalized: a Windows dev box with core.autocrlf smudges the checkout
|
|
197
|
+
// to CRLF; the blob git commits back is what matters, not the smudge.
|
|
198
|
+
const restored = await fs.readFile(path.join(repo, 'base.md'), 'utf8');
|
|
199
|
+
expect(restored.replace(/\r\n/g, '\n')).toBe('base\n');
|
|
200
|
+
|
|
201
|
+
// Simulate the wreckage a previously-failed attempt leaves behind: a
|
|
202
|
+
// STAGED deletion (index entry gone). The restore must self-heal it —
|
|
203
|
+
// without the index realignment, the commit below fatals on `git add`.
|
|
204
|
+
await runGit(repo, ['rm', '--force', '--quiet', '--', 'added.md']);
|
|
205
|
+
|
|
206
|
+
await git.restorePathFromRef(workspaceId, mergeBase!, 'added.md');
|
|
207
|
+
await expect(fs.access(path.join(repo, 'added.md'))).rejects.toThrow();
|
|
208
|
+
|
|
209
|
+
// The chain that actually failed in production: committing the DELETION
|
|
210
|
+
// through commitFile, whose `git add -- <path>` can only stage it while
|
|
211
|
+
// the index still tracks the path — a `git rm`-based restore broke here
|
|
212
|
+
// with "pathspec did not match any files".
|
|
213
|
+
const committed = await git.commitFile(
|
|
214
|
+
workspaceId,
|
|
215
|
+
{ id: 'u1', email: 'reviewer@x.com', name: 'Reviewer' },
|
|
216
|
+
'added.md',
|
|
217
|
+
'Revert added.md (declined in change request #1)',
|
|
218
|
+
true,
|
|
219
|
+
);
|
|
220
|
+
expect(committed).not.toBeNull();
|
|
221
|
+
await expect(
|
|
222
|
+
execFileAsync('git', ['-C', repo, 'cat-file', '-e', 'HEAD:added.md']),
|
|
223
|
+
).rejects.toThrow();
|
|
224
|
+
});
|
|
124
225
|
});
|