@bevel-software/platform-core-backend 0.30.0 → 0.32.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/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +16 -3
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access-model/index.d.ts +14 -0
- package/dist/modules/access-model/index.d.ts.map +1 -0
- package/dist/modules/access-model/index.js +13 -0
- package/dist/modules/access-model/index.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +4 -6
- package/dist/modules/auth/account-hooks.d.ts.map +1 -1
- package/dist/modules/auth/account-hooks.js +7 -17
- package/dist/modules/auth/account-hooks.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +9 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +19 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +43 -4
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +31 -7
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
- package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/change-request-hooks.js +15 -0
- package/dist/modules/workflow/change-request-hooks.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +9 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +124 -24
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +326 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/listener-seam.d.ts +34 -0
- package/dist/shared/listener-seam.d.ts.map +1 -0
- package/dist/shared/listener-seam.js +42 -0
- package/dist/shared/listener-seam.js.map +1 -0
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +21 -2
- package/src/index.ts +7 -0
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/access-model/index.ts +13 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
- package/src/modules/auth/account-hooks.ts +13 -21
- package/src/modules/auth/auth.service.ts +19 -18
- package/src/modules/database/core-schema.ts +31 -7
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +390 -55
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/listener-seam.ts +52 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
1
|
+
import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
2
6
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
3
7
|
import type {
|
|
4
8
|
AuthUser,
|
|
@@ -15,15 +19,19 @@ import { PendingCommitsService, type PendingCommit } from '../pending-commits.se
|
|
|
15
19
|
import { PendingCommitsWorker } from '../pending-commits.worker.js';
|
|
16
20
|
import { HttpCommunicationProtocol } from '@utcp/http';
|
|
17
21
|
import { WorkflowService } from '../workflow.service.js';
|
|
22
|
+
import type { ChangeRequestOpenedEvent, IChangeRequestHooks } from '../change-request-hooks.js';
|
|
18
23
|
import {
|
|
24
|
+
ChangeRequestConflictsError,
|
|
19
25
|
PullRebaseConflictError,
|
|
20
26
|
PushNeedsAgentResolutionError,
|
|
21
27
|
WorkflowDomainError,
|
|
28
|
+
WorkflowValidationError,
|
|
22
29
|
} from '../../../shared/domain-errors.js';
|
|
23
30
|
import type { WorkflowEventBus } from '../event-bus.js';
|
|
24
31
|
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
25
32
|
import type { Database } from '../../database/connection.js';
|
|
26
33
|
import { openChangeGate } from '../../../__tests__/open-change-gate.js';
|
|
34
|
+
import { ReadOnBranchDeniedError } from '../read-on-branch.js';
|
|
27
35
|
import { hashEmail } from '../../../shared/email-identity.js';
|
|
28
36
|
|
|
29
37
|
// `deleteBranch`'s open-request guard is the only DB touch these tests
|
|
@@ -224,6 +232,8 @@ function makePrs(): PullRequestService {
|
|
|
224
232
|
getPrDetail: vi.fn(),
|
|
225
233
|
invalidateDetailCache: vi.fn(),
|
|
226
234
|
setDetailEnricher: vi.fn(),
|
|
235
|
+
// No public address configured: the in-app path, with its note.
|
|
236
|
+
linkFor: vi.fn((number: number) => ({ url: `/change-requests/${number}`, urlNote: 'Set PUBLIC_FRONTEND_URL to get absolute links.' })),
|
|
227
237
|
} as unknown as PullRequestService;
|
|
228
238
|
}
|
|
229
239
|
|
|
@@ -274,6 +284,248 @@ describe('WorkflowService — branch delegation', () => {
|
|
|
274
284
|
});
|
|
275
285
|
});
|
|
276
286
|
|
|
287
|
+
describe('WorkflowService.updateBranch', () => {
|
|
288
|
+
const user = { id: 'u1', email: 'ada@x.io', name: 'Ada' };
|
|
289
|
+
/** A git double whose merge answers `outcome`, with the source on the remote. */
|
|
290
|
+
const gitThatMerges = (outcome: unknown) => {
|
|
291
|
+
const git = makeGit();
|
|
292
|
+
(git as unknown as { remoteBranchExists: unknown }).remoteBranchExists = vi.fn().mockResolvedValue(true);
|
|
293
|
+
(git as unknown as { mergeIntoBranch: unknown }).mergeIntoBranch = vi.fn().mockResolvedValue(outcome);
|
|
294
|
+
return git;
|
|
295
|
+
};
|
|
296
|
+
const build = (git: GitService, pending = makePendingCommits()) =>
|
|
297
|
+
new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), pending, testKbContext(), openChangeGate());
|
|
298
|
+
|
|
299
|
+
it('refuses a protected branch before asking git anything', async () => {
|
|
300
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'x', resolvedPaths: [] });
|
|
301
|
+
const err = await build(git).updateBranch('target-company-state', user).catch((e: unknown) => e);
|
|
302
|
+
expect(err).toBeInstanceOf(WorkflowDomainError);
|
|
303
|
+
expect((err as WorkflowDomainError).status).toBe(403);
|
|
304
|
+
expect((err as WorkflowDomainError).payload).toMatchObject({ kind: 'protected-update-target' });
|
|
305
|
+
expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
|
|
306
|
+
expect(git.push).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('refuses resolutions that come without a write gate, before asking git anything', async () => {
|
|
310
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: [] });
|
|
311
|
+
const err = await build(git)
|
|
312
|
+
.updateBranch('ada%2Fdraft', user, { from: 'target-company-state', resolutions: [{ path: 'A.md', content: 'x' }] } as never)
|
|
313
|
+
.catch((e: unknown) => e);
|
|
314
|
+
expect(err).toBeInstanceOf(Error);
|
|
315
|
+
expect((err as Error).message).toMatch(/validateWrite/);
|
|
316
|
+
expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('refuses an update that would land an open change request from `from` into the draft, as the merge tool refuses it', async () => {
|
|
320
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: [] });
|
|
321
|
+
const open = [{ number: 5, sourceBranch: 'target-company-state', targetBranch: 'ada/draft', state: 'open' }];
|
|
322
|
+
const svc = new WorkflowService(makeDb(open), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
|
|
323
|
+
const err = await svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' }).catch((e: unknown) => e);
|
|
324
|
+
expect(err).toMatchObject({ status: 409, payload: { kind: 'open-change-request-blocks-merge', number: 5 } });
|
|
325
|
+
expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
|
|
326
|
+
|
|
327
|
+
// The other direction — the draft's own request, open INTO `from` — is
|
|
328
|
+
// how a draft under review stays current: never blocked.
|
|
329
|
+
const reverse = [{ number: 6, sourceBranch: 'ada/draft', targetBranch: 'target-company-state', state: 'open' }];
|
|
330
|
+
const syncing = new WorkflowService(makeDb(reverse), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
|
|
331
|
+
expect(await syncing.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' })).toMatchObject({ kind: 'merged' });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A merge that signals when the git layer is entered and finishes only when
|
|
336
|
+
* released: what the lock tests hold the workspace with. No timing — the
|
|
337
|
+
* test waits for the signal, never for a clock.
|
|
338
|
+
*/
|
|
339
|
+
const heldMerge = (sha: string) => {
|
|
340
|
+
let enter: () => void = () => {};
|
|
341
|
+
let release: () => void = () => {};
|
|
342
|
+
const entered = new Promise<void>((resolve) => { enter = resolve; });
|
|
343
|
+
const merge = () =>
|
|
344
|
+
new Promise((resolve) => {
|
|
345
|
+
release = () => resolve({ kind: 'merged', sha, from: 'target-company-state', resolvedPaths: [] });
|
|
346
|
+
enter();
|
|
347
|
+
});
|
|
348
|
+
return { merge, entered, release: () => release() };
|
|
349
|
+
};
|
|
350
|
+
/** Every settled promise has run its continuations: a macrotask boundary, not a guess at a duration. */
|
|
351
|
+
const settled = () => new Promise<void>((r) => setImmediate(r));
|
|
352
|
+
|
|
353
|
+
it("holds the draft's lifecycle key around the merge, as the merge tool does, so nothing opens a request or recreates the branch mid-merge", async () => {
|
|
354
|
+
const git = gitThatMerges(null);
|
|
355
|
+
const held = heldMerge('m');
|
|
356
|
+
(git as unknown as { mergeIntoBranch: unknown }).mergeIntoBranch = vi.fn(held.merge);
|
|
357
|
+
(git.createBranch as ReturnType<typeof vi.fn>).mockResolvedValue({ name: 'ada/draft' });
|
|
358
|
+
const svc = build(git);
|
|
359
|
+
const order: string[] = [];
|
|
360
|
+
const updating = svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' }).then((o) => { order.push('merged'); return o; });
|
|
361
|
+
await held.entered;
|
|
362
|
+
const creating = svc.createBranch('ada%2Fdraft', 'ada/draft').then(() => { order.push('created'); });
|
|
363
|
+
// Without the key, the create (a resolved double) would be done by now.
|
|
364
|
+
await settled();
|
|
365
|
+
expect(order).toEqual([]);
|
|
366
|
+
held.release();
|
|
367
|
+
expect(await updating).toMatchObject({ kind: 'merged' });
|
|
368
|
+
await creating;
|
|
369
|
+
expect(order).toEqual(['merged', 'created']);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("takes no key on a protected `from`: two drafts refreshing from the default branch do not wait on each other", async () => {
|
|
373
|
+
const git = gitThatMerges(null);
|
|
374
|
+
const held = heldMerge('m');
|
|
375
|
+
const merges: string[] = [];
|
|
376
|
+
(git as unknown as { mergeIntoBranch: unknown }).mergeIntoBranch = vi.fn((workspaceId: string) => {
|
|
377
|
+
merges.push(workspaceId);
|
|
378
|
+
return workspaceId === 'ada%2Fdraft'
|
|
379
|
+
? held.merge()
|
|
380
|
+
: Promise.resolve({ kind: 'merged', sha: 'n', from: 'target-company-state', resolvedPaths: [] });
|
|
381
|
+
});
|
|
382
|
+
const svc = build(git);
|
|
383
|
+
const first = svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' });
|
|
384
|
+
await held.entered;
|
|
385
|
+
// The second draft's refresh from the same protected branch completes
|
|
386
|
+
// while the first still holds its merge (a key on `from` would hang it).
|
|
387
|
+
expect(await svc.updateBranch('bob%2Fdraft', user, { from: 'target-company-state' })).toMatchObject({ kind: 'merged', sha: 'n' });
|
|
388
|
+
expect(merges).toEqual(['ada%2Fdraft', 'bob%2Fdraft']);
|
|
389
|
+
held.release();
|
|
390
|
+
expect(await first).toMatchObject({ kind: 'merged', sha: 'm' });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('merges the named branch into the draft as the user, published inside the merge\'s own reservation', async () => {
|
|
394
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'merge-1', from: 'target-company-state', resolvedPaths: ['A.md'] });
|
|
395
|
+
const svc = build(git);
|
|
396
|
+
const resolutions = [{ path: 'A.md', content: 'resolved\n' }];
|
|
397
|
+
const outcome = await svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state', resolutions, validateWrite: async () => {} });
|
|
398
|
+
expect(outcome).toEqual({ kind: 'merged', sha: 'merge-1', from: 'target-company-state', resolvedPaths: ['A.md'] });
|
|
399
|
+
const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
|
|
400
|
+
expect(merge).toHaveBeenCalledWith(
|
|
401
|
+
'ada%2Fdraft',
|
|
402
|
+
'ada/draft',
|
|
403
|
+
'target-company-state',
|
|
404
|
+
user,
|
|
405
|
+
expect.objectContaining({ subject: 'Merge target-company-state into ada/draft', resolutions, publish: expect.any(Function) }),
|
|
406
|
+
);
|
|
407
|
+
expect((merge.mock.calls[0] as unknown[])[4]).toMatchObject({ body: expect.stringContaining('A.md') });
|
|
408
|
+
// The git layer publishes while it still holds the workspace; no second push from here.
|
|
409
|
+
expect(git.push).not.toHaveBeenCalled();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("publishes through the one push policy every workflow push takes, over the merge's own moves", async () => {
|
|
413
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: [] });
|
|
414
|
+
await build(git).updateBranch('ada%2Fdraft', user, { from: 'target-company-state' });
|
|
415
|
+
const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
|
|
416
|
+
type Ops = { push: () => Promise<void>; pullRebase: () => Promise<{ treeChanged: boolean }> };
|
|
417
|
+
const { publish } = (merge.mock.calls[0] as unknown[])[4] as { publish: (ops: Ops) => Promise<void> };
|
|
418
|
+
|
|
419
|
+
// A rejection: replayed onto the moved branch (the merge kept) and pushed again.
|
|
420
|
+
const push = vi.fn().mockRejectedValueOnce(new Error('! [rejected] non-fast-forward')).mockResolvedValue(undefined);
|
|
421
|
+
const pullRebase = vi.fn().mockResolvedValue({ treeChanged: true });
|
|
422
|
+
await publish({ push, pullRebase });
|
|
423
|
+
expect(push).toHaveBeenCalledTimes(2);
|
|
424
|
+
expect(pullRebase).toHaveBeenCalledTimes(1);
|
|
425
|
+
// The workspace's own reserved moves are never taken from here: the merge holds the workspace.
|
|
426
|
+
expect(git.push).not.toHaveBeenCalled();
|
|
427
|
+
expect(git.pull).not.toHaveBeenCalled();
|
|
428
|
+
|
|
429
|
+
// A replay that conflicts: the hand-off every refused share answers with,
|
|
430
|
+
// naming the branch — no single file is at issue — and never a conflicted
|
|
431
|
+
// path, which the caller may not be allowed to see.
|
|
432
|
+
const conflict = new PullRebaseConflictError('ada/draft', ['Secret/Plan.md'], 'replay conflicted');
|
|
433
|
+
const diverged = await publish({
|
|
434
|
+
push: vi.fn().mockRejectedValue(new Error('! [rejected] non-fast-forward')),
|
|
435
|
+
pullRebase: vi.fn().mockRejectedValue(conflict),
|
|
436
|
+
}).catch((e: unknown) => e);
|
|
437
|
+
expect(diverged).toBeInstanceOf(PushNeedsAgentResolutionError);
|
|
438
|
+
expect(diverged as PushNeedsAgentResolutionError).toMatchObject({ branch: 'ada/draft', path: 'ada/draft', cause: 'diverged' });
|
|
439
|
+
expect((diverged as Error).message).not.toContain('Secret/Plan.md');
|
|
440
|
+
expect(JSON.stringify((diverged as WorkflowDomainError).payload)).not.toContain('Secret/Plan.md');
|
|
441
|
+
|
|
442
|
+
// A host that refuses: the same hand-off, `refused`, no replay attempted.
|
|
443
|
+
const untouched = vi.fn();
|
|
444
|
+
const refused = await publish({ push: vi.fn().mockRejectedValue(new Error('remote: permission denied')), pullRebase: untouched }).catch(
|
|
445
|
+
(e: unknown) => e,
|
|
446
|
+
);
|
|
447
|
+
expect(refused).toBeInstanceOf(PushNeedsAgentResolutionError);
|
|
448
|
+
expect(refused as PushNeedsAgentResolutionError).toMatchObject({ branch: 'ada/draft', path: 'ada/draft', cause: 'refused' });
|
|
449
|
+
expect(untouched).not.toHaveBeenCalled();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('authorizes each resolution inside the merge: readable on both branches as fetched, and the draft write rule every save takes', async () => {
|
|
453
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'target-company-state', resolvedPaths: ['A.md'] });
|
|
454
|
+
const access = makeAccessControl();
|
|
455
|
+
(access.canReadAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
456
|
+
const gate = openChangeGate();
|
|
457
|
+
const asked: unknown[][] = [];
|
|
458
|
+
gate.assertMayChange = async (...args: unknown[]) => {
|
|
459
|
+
asked.push(args);
|
|
460
|
+
};
|
|
461
|
+
const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), access, makeFileLockService(), makePendingCommits(), testKbContext(), gate);
|
|
462
|
+
const validated: unknown[][] = [];
|
|
463
|
+
const validateWrite = vi.fn(async (...args: unknown[]) => { validated.push(args); });
|
|
464
|
+
await svc.updateBranch('ada%2Fdraft', user, {
|
|
465
|
+
from: 'target-company-state',
|
|
466
|
+
resolutions: [{ path: 'knowledge-base/A.md', content: 'x' }, { path: 'B.md', choose: 'theirs' }],
|
|
467
|
+
validateWrite,
|
|
468
|
+
});
|
|
469
|
+
const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
|
|
470
|
+
const opts = (merge.mock.calls[0] as unknown[])[4] as { authorizeResolutions: (m: unknown) => Promise<void> };
|
|
471
|
+
await opts.authorizeResolutions({ ours: 'o', theirs: 't' });
|
|
472
|
+
// The caller's pre-disk write gate is asked with the workspace path and
|
|
473
|
+
// the content a resolution brings; a side kept whole brings none.
|
|
474
|
+
expect(validated).toEqual([['knowledge-base/A.md', 'x']]);
|
|
475
|
+
// Read at the two COMMITS the merge is of — never at a branch ref — then the change gate.
|
|
476
|
+
expect(access.canReadAtRef).toHaveBeenCalledWith('ada%2Fdraft', 'o', 'ada@x.io', 'A.md');
|
|
477
|
+
expect(access.canReadAtRef).toHaveBeenCalledWith('ada%2Fdraft', 't', 'ada@x.io', 'A.md');
|
|
478
|
+
expect((access.canReadAtRef as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[1])).not.toContain('origin/target-company-state');
|
|
479
|
+
// The gate takes the workspace path, as every save hands it one — a
|
|
480
|
+
// repository-relative path would read as outside the knowledge base.
|
|
481
|
+
expect(asked).toEqual([['ada%2Fdraft', 'ada@x.io', 'knowledge-base/A.md', 'file'], ['ada%2Fdraft', 'ada@x.io', 'knowledge-base/B.md', 'file']]);
|
|
482
|
+
|
|
483
|
+
// The write gate's refusal refuses the update (a roles.yaml that would not parse, say).
|
|
484
|
+
validateWrite.mockRejectedValueOnce(new WorkflowDomainError('roles.yaml would be invalid', 422));
|
|
485
|
+
await expect(opts.authorizeResolutions({ ours: 'o', theirs: 't' })).rejects.toMatchObject({ status: 422 });
|
|
486
|
+
|
|
487
|
+
// Not readable at the draft's commit: refused before the change gate is asked.
|
|
488
|
+
(access.canReadAtRef as ReturnType<typeof vi.fn>).mockImplementation(async (_ws: string, ref: string) => ref !== 'o');
|
|
489
|
+
asked.length = 0;
|
|
490
|
+
await expect(opts.authorizeResolutions({ ours: 'o', theirs: 't' })).rejects.toBeInstanceOf(ReadOnBranchDeniedError);
|
|
491
|
+
expect(asked).toEqual([]);
|
|
492
|
+
// A commit whose rules cannot be read grants nothing.
|
|
493
|
+
(access.canReadAtRef as ReturnType<typeof vi.fn>).mockImplementation(async (_ws: string, ref: string) => (ref === 't' ? null : true));
|
|
494
|
+
await expect(opts.authorizeResolutions({ ours: 'o', theirs: 't' })).rejects.toBeInstanceOf(ReadOnBranchDeniedError);
|
|
495
|
+
expect(asked).toEqual([]);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('takes the draft\'s own shared state when no source is named, and publishes nothing on conflicts', async () => {
|
|
499
|
+
const conflicts = { kind: 'conflicts', from: 'ada/draft', mergeBase: 'b', ours: 'o', theirs: 't', files: [], unresolved: ['A.md'] };
|
|
500
|
+
const git = gitThatMerges(conflicts);
|
|
501
|
+
const outcome = await build(git).updateBranch('ada%2Fdraft', user);
|
|
502
|
+
expect(outcome).toBe(conflicts);
|
|
503
|
+
const merge = (git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch;
|
|
504
|
+
expect(merge).toHaveBeenCalledWith('ada%2Fdraft', 'ada/draft', 'ada/draft', user, expect.objectContaining({ resolutions: [] }));
|
|
505
|
+
expect(git.push).not.toHaveBeenCalled();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('answers pending-commits, merging nothing, while writes on the source are still being committed', async () => {
|
|
509
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'x', resolvedPaths: [] });
|
|
510
|
+
const pending = makePendingCommits();
|
|
511
|
+
(pending.queuedOnBranch as ReturnType<typeof vi.fn>).mockImplementation(async (branch: string) =>
|
|
512
|
+
branch === 'target-company-state' ? { queued: 2, stuck: 0, needsAttention: null } : { queued: 0, stuck: 0, needsAttention: null },
|
|
513
|
+
);
|
|
514
|
+
const svc = build(git, pending);
|
|
515
|
+
svc.mergeWaitForPendingCommits = { timeoutMs: 30, pollMs: 5 };
|
|
516
|
+
const outcome = await svc.updateBranch('ada%2Fdraft', user, { from: 'target-company-state' });
|
|
517
|
+
expect(outcome).toMatchObject({ kind: 'pending-commits', branch: 'target-company-state', pending: 2 });
|
|
518
|
+
expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('refuses a source the shared repository does not have', async () => {
|
|
522
|
+
const git = gitThatMerges({ kind: 'merged', sha: 'm', from: 'x', resolvedPaths: [] });
|
|
523
|
+
(git as unknown as { remoteBranchExists: unknown }).remoteBranchExists = vi.fn().mockResolvedValue(false);
|
|
524
|
+
await expect(build(git).updateBranch('ada%2Fdraft', user, { from: 'nobody/such' })).rejects.toThrow(/No branch named "nobody\/such"/);
|
|
525
|
+
expect((git as unknown as { mergeIntoBranch: ReturnType<typeof vi.fn> }).mergeIntoBranch).not.toHaveBeenCalled();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
277
529
|
describe('WorkflowService — change delegation', () => {
|
|
278
530
|
it('commitChange delegates to git.commit', async () => {
|
|
279
531
|
const git = makeGit();
|
|
@@ -298,12 +550,15 @@ describe('WorkflowService — change delegation', () => {
|
|
|
298
550
|
|
|
299
551
|
it('compareFile delegates to git.diffFileBetweenBranches', async () => {
|
|
300
552
|
const git = makeGit();
|
|
301
|
-
(git.diffFileBetweenBranches as ReturnType<typeof vi.fn>).mockResolvedValue('@@ diff');
|
|
553
|
+
(git.diffFileBetweenBranches as ReturnType<typeof vi.fn>).mockResolvedValue({ diff: '@@ diff', from: 'a1', to: 'b1' });
|
|
302
554
|
|
|
303
555
|
const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
556
|
+
// The caller's verdict travels down to the git layer, which asks it at
|
|
557
|
+
// the pinned commits; those commits travel back with the diff.
|
|
558
|
+
const authorize = vi.fn(async () => {});
|
|
559
|
+
const compared = await svc.compareFile('w1', 'Foo.md', 'a', 'b', authorize);
|
|
560
|
+
expect(compared).toEqual({ diff: '@@ diff', from: 'a1', to: 'b1' });
|
|
561
|
+
expect(git.diffFileBetweenBranches).toHaveBeenCalledWith('w1', 'Foo.md', 'a', 'b', authorize);
|
|
307
562
|
});
|
|
308
563
|
});
|
|
309
564
|
|
|
@@ -1394,6 +1649,254 @@ describe('WorkflowService.openChangeRequest — when the repository host refuses
|
|
|
1394
1649
|
});
|
|
1395
1650
|
});
|
|
1396
1651
|
|
|
1652
|
+
/**
|
|
1653
|
+
* Opening a request tells the deployment's change-request listeners — once,
|
|
1654
|
+
* here, whether a person or an agent opened it — and answers `notified`: the
|
|
1655
|
+
* channels the listeners said reached the approvers. The approvers are the
|
|
1656
|
+
* people the `## Affected owners` block lists, resolved to accounts and
|
|
1657
|
+
* handed over once each; who could not be is counted.
|
|
1658
|
+
*/
|
|
1659
|
+
describe('WorkflowService.openChangeRequest — tells the change-request listeners', () => {
|
|
1660
|
+
const INPUT = { sourceBranch: 'ali/x', targetBranch: 'main', title: ' Tidy the glossary ' };
|
|
1661
|
+
const PATHS = ['KnowledgeBase/Sales/Deal.md', 'KnowledgeBase/Legal/Terms.md'];
|
|
1662
|
+
const ACCOUNTS: Record<string, { id: string; email: string; name: string }> = {
|
|
1663
|
+
'dana@acme.test': { id: 'u-dana', email: 'dana@acme.test', name: 'Dana' },
|
|
1664
|
+
'lena@acme.test': { id: 'u-lena', email: 'Lena@acme.test', name: 'Lena' },
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
function eligibility(
|
|
1668
|
+
perPath: Record<string, { roles?: string[]; emails: string[] }>,
|
|
1669
|
+
): Map<string, { roles: string[]; users: { name: string; email: string }[]; emails: Set<string> }> {
|
|
1670
|
+
const out = new Map<string, { roles: string[]; users: { name: string; email: string }[]; emails: Set<string> }>();
|
|
1671
|
+
for (const [p, { roles = [], emails }] of Object.entries(perPath)) {
|
|
1672
|
+
out.set(p, { roles, users: emails.map((email) => ({ name: '', email })), emails: new Set(emails) });
|
|
1673
|
+
}
|
|
1674
|
+
return out;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// A real repository, so the changed paths come from the `git diff` the
|
|
1678
|
+
// service runs in production and not from a replaced helper: `main` is the
|
|
1679
|
+
// base and the ref `origin/main` the diff is taken against; `ali/x` adds
|
|
1680
|
+
// PATHS. The checkout sits under the knowledge-base folder name the test
|
|
1681
|
+
// context gives, where the service looks for it.
|
|
1682
|
+
let workspace: string;
|
|
1683
|
+
beforeAll(() => {
|
|
1684
|
+
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'hexis-open-tells-'));
|
|
1685
|
+
const repo = path.join(workspace, testKbContext().kbDirName);
|
|
1686
|
+
fs.mkdirSync(repo);
|
|
1687
|
+
const git = (...args: string[]) =>
|
|
1688
|
+
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t.test', '-c', 'commit.gpgsign=false', ...args], { cwd: repo, stdio: 'pipe' });
|
|
1689
|
+
git('init', '-q', '-b', 'main');
|
|
1690
|
+
git('commit', '-q', '--allow-empty', '-m', 'base');
|
|
1691
|
+
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
|
|
1692
|
+
git('checkout', '-q', '-b', 'ali/x');
|
|
1693
|
+
for (const p of PATHS) {
|
|
1694
|
+
fs.mkdirSync(path.dirname(path.join(repo, p)), { recursive: true });
|
|
1695
|
+
fs.writeFileSync(path.join(repo, p), `# ${p}\n`);
|
|
1696
|
+
}
|
|
1697
|
+
git('add', '-A');
|
|
1698
|
+
git('commit', '-q', '-m', 'the change');
|
|
1699
|
+
});
|
|
1700
|
+
afterAll(() => {
|
|
1701
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
function makeNotifyHarness(opts: {
|
|
1705
|
+
eligible?: ReturnType<typeof eligibility> | null;
|
|
1706
|
+
push?: ReturnType<typeof vi.fn>;
|
|
1707
|
+
merge?: ReturnType<typeof vi.fn>;
|
|
1708
|
+
detail?: ReturnType<typeof vi.fn>;
|
|
1709
|
+
accounts?: { findActiveAccountsByEmail: ReturnType<typeof vi.fn> } | undefined;
|
|
1710
|
+
link?: { url: string; urlNote?: string };
|
|
1711
|
+
} = {}) {
|
|
1712
|
+
const git = Object.assign(makeGit(), {
|
|
1713
|
+
mergeFromOrigin: opts.merge ?? vi.fn().mockResolvedValue({ kind: 'clean', alreadyUpToDate: false }),
|
|
1714
|
+
push: opts.push ?? vi.fn().mockResolvedValue(undefined),
|
|
1715
|
+
pull: vi.fn().mockResolvedValue({ treeChanged: false }),
|
|
1716
|
+
}) as unknown as GitService;
|
|
1717
|
+
const prs = makePrs();
|
|
1718
|
+
(prs.listOpenPrs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
1719
|
+
if (opts.detail) prs.getPrDetail = opts.detail as unknown as PullRequestService['getPrDetail'];
|
|
1720
|
+
else (prs.getPrDetail as ReturnType<typeof vi.fn>).mockResolvedValue({ number: 12, branch: 'ali/x', base: 'main' });
|
|
1721
|
+
if (opts.link) (prs.linkFor as ReturnType<typeof vi.fn>).mockReturnValue(opts.link);
|
|
1722
|
+
const workspaces = makeWorkspaceService();
|
|
1723
|
+
(workspaces.getWorkspacePath as ReturnType<typeof vi.fn>).mockResolvedValue(workspace);
|
|
1724
|
+
const access = makeAccessControl();
|
|
1725
|
+
(access.eligibleWritersForPathsAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(opts.eligible ?? null);
|
|
1726
|
+
const values = vi.fn(() => ({ returning: vi.fn(async () => [{ number: 12 }]) }));
|
|
1727
|
+
const db = { insert: vi.fn(() => ({ values })) } as unknown as Database;
|
|
1728
|
+
const run = vi.fn<(event: ChangeRequestOpenedEvent) => Promise<string[]>>(async () => ['email']);
|
|
1729
|
+
const hooks: IChangeRequestHooks = { onChangeRequestOpened: vi.fn(), runChangeRequestOpened: run };
|
|
1730
|
+
const accounts =
|
|
1731
|
+
'accounts' in opts
|
|
1732
|
+
? opts.accounts
|
|
1733
|
+
: {
|
|
1734
|
+
findActiveAccountsByEmail: vi.fn(async (emails: readonly string[]) => {
|
|
1735
|
+
const found = new Map<string, { id: string; email: string; name: string }>();
|
|
1736
|
+
for (const email of emails) {
|
|
1737
|
+
const account = ACCOUNTS[email];
|
|
1738
|
+
if (account) found.set(email, account);
|
|
1739
|
+
}
|
|
1740
|
+
return found;
|
|
1741
|
+
}),
|
|
1742
|
+
};
|
|
1743
|
+
const svc = new WorkflowService(
|
|
1744
|
+
db, git, prs, makeReviewWorkflow(), workspaces, access,
|
|
1745
|
+
makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate(),
|
|
1746
|
+
{ emit: vi.fn() } as unknown as WorkflowEventBus, undefined, undefined, hooks, accounts,
|
|
1747
|
+
);
|
|
1748
|
+
return { svc, run, prs, access, accounts, values };
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
it('fires once, after the row exists, with the request, who opened it and the approvers as accounts — once each', async () => {
|
|
1752
|
+
const h = makeNotifyHarness({
|
|
1753
|
+
eligible: eligibility({
|
|
1754
|
+
[PATHS[0]]: { roles: ['Sales'], emails: ['dana@acme.test', 'lena@acme.test'] },
|
|
1755
|
+
// Lena again, by another spelling: one person.
|
|
1756
|
+
[PATHS[1]]: { roles: ['Legal'], emails: ['LENA@acme.test'] },
|
|
1757
|
+
}),
|
|
1758
|
+
});
|
|
1759
|
+
const user = makeUser({ id: 'u-ali', email: 'ali@acme.test', name: 'Ali' });
|
|
1760
|
+
const answer = await h.svc.openChangeRequest('ali%2Fx', user, INPUT);
|
|
1761
|
+
|
|
1762
|
+
expect(h.run).toHaveBeenCalledTimes(1);
|
|
1763
|
+
const [event] = h.run.mock.calls[0]!;
|
|
1764
|
+
expect(event).toEqual({
|
|
1765
|
+
number: 12,
|
|
1766
|
+
title: 'Tidy the glossary',
|
|
1767
|
+
sourceBranch: 'ali/x',
|
|
1768
|
+
targetBranch: 'main',
|
|
1769
|
+
openedBy: { id: 'u-ali', email: 'ali@acme.test', name: 'Ali' },
|
|
1770
|
+
approvers: [
|
|
1771
|
+
{ id: 'u-dana', email: 'dana@acme.test', name: 'Dana' },
|
|
1772
|
+
{ id: 'u-lena', email: 'Lena@acme.test', name: 'Lena' },
|
|
1773
|
+
],
|
|
1774
|
+
unresolvedApprovers: 0,
|
|
1775
|
+
pushPending: false,
|
|
1776
|
+
});
|
|
1777
|
+
// No public address: no `url` at all, not a relative one.
|
|
1778
|
+
expect(event).not.toHaveProperty('url');
|
|
1779
|
+
// The accounts were read once, for the distinct canonical addresses.
|
|
1780
|
+
expect(h.accounts!.findActiveAccountsByEmail).toHaveBeenCalledTimes(1);
|
|
1781
|
+
expect(h.accounts!.findActiveAccountsByEmail.mock.calls[0]![0]).toEqual(['dana@acme.test', 'lena@acme.test']);
|
|
1782
|
+
// The eligibility was asked for the paths the request changes — the
|
|
1783
|
+
// `git diff` against the target's published ref, as in production.
|
|
1784
|
+
const eligibleFor = h.access.eligibleWritersForPathsAtRef as ReturnType<typeof vi.fn>;
|
|
1785
|
+
expect(eligibleFor).toHaveBeenCalledTimes(1);
|
|
1786
|
+
expect(eligibleFor.mock.calls[0]!.slice(-1)[0]).toEqual([...PATHS].sort()); // as `git diff --name-only` orders them
|
|
1787
|
+
// The row landed before anyone was told, and the answer says who was.
|
|
1788
|
+
expect(h.values).toHaveBeenCalledTimes(1);
|
|
1789
|
+
expect(h.values.mock.invocationCallOrder[0]).toBeLessThan(h.run.mock.invocationCallOrder[0]!);
|
|
1790
|
+
expect(answer).toMatchObject({ number: 12, notified: ['email'] });
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it('counts a person without an account and the built-in `everyone` as unresolved, and hands neither over', async () => {
|
|
1794
|
+
const h = makeNotifyHarness({
|
|
1795
|
+
eligible: eligibility({
|
|
1796
|
+
[PATHS[0]]: { roles: ['everyone'], emails: ['dana@acme.test', 'nobody@acme.test'] },
|
|
1797
|
+
[PATHS[1]]: { roles: ['everyone', 'Legal'], emails: ['dana@acme.test'] },
|
|
1798
|
+
}),
|
|
1799
|
+
});
|
|
1800
|
+
await h.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT);
|
|
1801
|
+
const [event] = h.run.mock.calls[0]!;
|
|
1802
|
+
expect(event.approvers).toEqual([{ id: 'u-dana', email: 'dana@acme.test', name: 'Dana' }]);
|
|
1803
|
+
// `nobody@` once, `everyone` once however many paths grant it.
|
|
1804
|
+
expect(event.unresolvedApprovers).toBe(2);
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it('hands over the public address when one is configured', async () => {
|
|
1808
|
+
const h = makeNotifyHarness({ link: { url: 'https://kb.acme.test/change-requests/12' } });
|
|
1809
|
+
await h.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT);
|
|
1810
|
+
expect(h.run.mock.calls[0]![0]).toMatchObject({ url: 'https://kb.acme.test/change-requests/12', approvers: [], unresolvedApprovers: 0 });
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
it('with no accounts to ask, or a read of them that fails, every approver is unresolved and the open goes on', async () => {
|
|
1814
|
+
const eligible = eligibility({ [PATHS[0]]: { emails: ['dana@acme.test', 'lena@acme.test'] } });
|
|
1815
|
+
const without = makeNotifyHarness({ eligible, accounts: undefined });
|
|
1816
|
+
await without.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT);
|
|
1817
|
+
expect(without.run.mock.calls[0]![0]).toMatchObject({ approvers: [], unresolvedApprovers: 2 });
|
|
1818
|
+
|
|
1819
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1820
|
+
try {
|
|
1821
|
+
const failing = makeNotifyHarness({
|
|
1822
|
+
eligible,
|
|
1823
|
+
accounts: { findActiveAccountsByEmail: vi.fn().mockRejectedValue(new Error('db down')) },
|
|
1824
|
+
});
|
|
1825
|
+
await expect(failing.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT)).resolves.toMatchObject({ notified: ['email'] });
|
|
1826
|
+
expect(failing.run.mock.calls[0]![0]).toMatchObject({ approvers: [], unresolvedApprovers: 2 });
|
|
1827
|
+
} finally {
|
|
1828
|
+
warn.mockRestore();
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
it('answers `notified: []` truthfully when the listeners reached nobody', async () => {
|
|
1833
|
+
const h = makeNotifyHarness();
|
|
1834
|
+
h.run.mockResolvedValue([]);
|
|
1835
|
+
await expect(h.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT)).resolves.toMatchObject({ number: 12, notified: [] });
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
it('tells the listeners even when the host refused the push, and says so: the request exists, its commits are still to land', async () => {
|
|
1839
|
+
const h = makeNotifyHarness({
|
|
1840
|
+
push: vi.fn().mockRejectedValue(new Error('git push failed: remote: Internal Server Error\n ! [remote rejected] ali/x -> ali/x (Internal Server Error)')),
|
|
1841
|
+
});
|
|
1842
|
+
const err = await h.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT).catch((e: unknown) => e);
|
|
1843
|
+
expect(err).toBeInstanceOf(PushNeedsAgentResolutionError);
|
|
1844
|
+
expect(h.run).toHaveBeenCalledTimes(1);
|
|
1845
|
+
expect(h.run.mock.calls[0]![0]).toMatchObject({ number: 12, sourceBranch: 'ali/x', targetBranch: 'main', pushPending: true });
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
it('tells the listeners once the branches\' lifecycle locks are released: a slow mailer holds up the answer, not a delete or a merge', async () => {
|
|
1849
|
+
const h = makeNotifyHarness();
|
|
1850
|
+
let release!: () => void;
|
|
1851
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
1852
|
+
h.run.mockImplementation(async () => { await gate; return ['email']; });
|
|
1853
|
+
const opening = h.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT);
|
|
1854
|
+
await vi.waitFor(() => { expect(h.run).toHaveBeenCalledTimes(1); });
|
|
1855
|
+
// `createBranch` takes the branch's lifecycle lock — the one `deleteBranch`
|
|
1856
|
+
// and `mergeBranch` take too — so it completing while the listener still
|
|
1857
|
+
// waits shows the lock was released before the listeners were told.
|
|
1858
|
+
const free = await Promise.race([
|
|
1859
|
+
h.svc.createBranch('ali%2Fx', 'ali/x').then(() => 'free'),
|
|
1860
|
+
new Promise<string>((resolve) => setTimeout(() => resolve('held'), 300)),
|
|
1861
|
+
]);
|
|
1862
|
+
expect(free).toBe('free');
|
|
1863
|
+
release();
|
|
1864
|
+
await expect(opening).resolves.toMatchObject({ number: 12, notified: ['email'] });
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
it('tells the listeners when the detail cannot be read back: the request exists, a retry would be a duplicate', async () => {
|
|
1868
|
+
const failing = makeNotifyHarness({ detail: vi.fn().mockRejectedValue(new Error('detail store down')) });
|
|
1869
|
+
await expect(failing.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT)).rejects.toThrow('detail store down');
|
|
1870
|
+
expect(failing.run).toHaveBeenCalledTimes(1);
|
|
1871
|
+
expect(failing.run.mock.calls[0]![0]).toMatchObject({ number: 12, pushPending: false });
|
|
1872
|
+
|
|
1873
|
+
const missing = makeNotifyHarness({ detail: vi.fn().mockResolvedValue(null) });
|
|
1874
|
+
await expect(missing.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT)).rejects.toBeInstanceOf(WorkflowValidationError);
|
|
1875
|
+
expect(missing.run).toHaveBeenCalledTimes(1);
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
it('a refused open tells nobody: a validation failure, a conflicting auto-merge', async () => {
|
|
1879
|
+
const invalid = makeNotifyHarness();
|
|
1880
|
+
await expect(invalid.svc.openChangeRequest('ali%2Fx', makeUser(), { ...INPUT, title: ' ' })).rejects.toBeInstanceOf(WorkflowValidationError);
|
|
1881
|
+
expect(invalid.run).not.toHaveBeenCalled();
|
|
1882
|
+
expect(invalid.values).not.toHaveBeenCalled();
|
|
1883
|
+
|
|
1884
|
+
const conflicting = makeNotifyHarness({
|
|
1885
|
+
merge: vi.fn().mockResolvedValue({ kind: 'conflicts', paths: ['KnowledgeBase/Sales/Deal.md'] }),
|
|
1886
|
+
});
|
|
1887
|
+
await expect(conflicting.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT)).rejects.toBeInstanceOf(ChangeRequestConflictsError);
|
|
1888
|
+
expect(conflicting.run).not.toHaveBeenCalled();
|
|
1889
|
+
expect(conflicting.values).not.toHaveBeenCalled();
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
it('a refused open (a duplicate, say) tells nobody', async () => {
|
|
1893
|
+
const h = makeNotifyHarness();
|
|
1894
|
+
(h.prs.listOpenPrs as ReturnType<typeof vi.fn>).mockResolvedValue([{ number: 3, branch: 'ali/x', base: 'main' }]);
|
|
1895
|
+
await expect(h.svc.openChangeRequest('ali%2Fx', makeUser(), INPUT)).rejects.toThrow();
|
|
1896
|
+
expect(h.run).not.toHaveBeenCalled();
|
|
1897
|
+
});
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1397
1900
|
/**
|
|
1398
1901
|
* Writes are committed asynchronously: a write tool answers once the bytes are
|
|
1399
1902
|
* on disk, and the pending-commits worker commits and pushes them seconds
|