@bevel-software/platform-core-backend 0.31.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/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 +12 -1
- 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-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/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 +21 -1
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +16 -1
- package/dist/modules/database/core-schema.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/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.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +5 -0
- 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/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/workflow.service.d.ts +75 -15
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -32
- package/dist/modules/workflow/workflow.service.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/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/create-core-services.ts +17 -0
- package/src/index.ts +7 -0
- package/src/modules/access-model/index.ts +13 -0
- 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 +16 -1
- 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/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- 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 +258 -1
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +10 -0
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +6 -0
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/workflow.service.ts +188 -40
- package/src/shared/listener-seam.ts +52 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
5
|
+
import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
|
|
6
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
7
|
+
import type { AuthService } from '../../auth/auth.service.js';
|
|
8
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
9
|
+
import { WorkflowEventBus } from '../event-bus.js';
|
|
10
|
+
import { createWorkflowRoutes } from '../workflow.routes.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `POST /api/workflow/change-requests` answers the opened request as the
|
|
14
|
+
* service hands it over, `notified` included: the channels the deployment's
|
|
15
|
+
* change-request listeners said reached the approvers, so the dialog can say
|
|
16
|
+
* "the reviewers were emailed" — or hand over the link when nobody was.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ALICE: AuthUser = { id: '11111111-1111-4111-8111-111111111111', email: 'alice@example.com', name: 'Alice' };
|
|
20
|
+
|
|
21
|
+
async function serve(workflow: Partial<IWorkflowService>): Promise<{ server: Server; baseUrl: string }> {
|
|
22
|
+
const authService = { getUserById: vi.fn(async () => ALICE) } as unknown as AuthService;
|
|
23
|
+
const workspaceService = {
|
|
24
|
+
getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: encodeURIComponent(branch) })),
|
|
25
|
+
} as unknown as WorkspaceService;
|
|
26
|
+
const app = express();
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
app.use('/api', (req, _res, next) => {
|
|
29
|
+
(req as unknown as { userId: string }).userId = ALICE.id;
|
|
30
|
+
next();
|
|
31
|
+
});
|
|
32
|
+
app.use(
|
|
33
|
+
'/api',
|
|
34
|
+
createWorkflowRoutes(
|
|
35
|
+
workflow as IWorkflowService,
|
|
36
|
+
workspaceService,
|
|
37
|
+
authService,
|
|
38
|
+
new WorkflowEventBus(),
|
|
39
|
+
{} as unknown as IAccessControl,
|
|
40
|
+
'knowledge-base',
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
const server = await new Promise<Server>((resolve) => {
|
|
44
|
+
const s = app.listen(0, () => resolve(s));
|
|
45
|
+
});
|
|
46
|
+
return { server, baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('POST /workflow/change-requests answers who was notified', () => {
|
|
50
|
+
let server: Server | undefined;
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
if (server) await new Promise<void>((resolve, reject) => server!.close((e) => (e ? reject(e) : resolve())));
|
|
53
|
+
server = undefined;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each([
|
|
57
|
+
['the channels that reached the approvers', ['email']],
|
|
58
|
+
['nobody, truthfully', []],
|
|
59
|
+
])('carries `notified`: %s', async (_what, notified) => {
|
|
60
|
+
const openChangeRequest = vi.fn(async () => ({ number: 12, branch: 'alice/deal', base: 'main', notified }) as never);
|
|
61
|
+
let baseUrl: string;
|
|
62
|
+
({ server, baseUrl } = await serve({ openChangeRequest }));
|
|
63
|
+
|
|
64
|
+
const res = await fetch(`${baseUrl}/api/workflow/change-requests`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ sourceBranch: 'alice/deal', targetBranch: 'main', title: 'Deal' }),
|
|
68
|
+
});
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
expect(await res.json()).toEqual({ number: 12, branch: 'alice/deal', base: 'main', notified });
|
|
71
|
+
// The open runs in the SOURCE branch's workspace, by the caller.
|
|
72
|
+
expect(openChangeRequest).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(openChangeRequest).toHaveBeenCalledWith(
|
|
74
|
+
encodeURIComponent('alice/deal'),
|
|
75
|
+
ALICE,
|
|
76
|
+
{ sourceBranch: 'alice/deal', targetBranch: 'main', title: 'Deal', description: undefined },
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -115,6 +115,7 @@ interface Harness {
|
|
|
115
115
|
deleted: string[];
|
|
116
116
|
/** What the service announced to connected tabs. */
|
|
117
117
|
emitted: { kind: string; number?: number }[];
|
|
118
|
+
changeRequestHooks: { onChangeRequestOpened: ReturnType<typeof vi.fn>; runChangeRequestOpened: ReturnType<typeof vi.fn> };
|
|
118
119
|
hasAnyActive: MockInstance<FileLockService['hasAnyActive']>;
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -142,6 +143,7 @@ function harness(opts: {
|
|
|
142
143
|
}): Harness {
|
|
143
144
|
const deleted: string[] = [];
|
|
144
145
|
const emitted: { kind: string; number?: number }[] = [];
|
|
146
|
+
const changeRequestHooks = { onChangeRequestOpened: vi.fn(), runChangeRequestOpened: vi.fn(async () => []) };
|
|
145
147
|
const rows = opts.rows ?? [];
|
|
146
148
|
const branches = { ...(opts.branches ?? {}) };
|
|
147
149
|
const git = {
|
|
@@ -212,8 +214,11 @@ function harness(opts: {
|
|
|
212
214
|
testKbContext(),
|
|
213
215
|
openChangeGate(),
|
|
214
216
|
{ emit: (e: { kind: string; number?: number }) => void emitted.push(e) } as unknown as WorkflowEventBus,
|
|
217
|
+
undefined,
|
|
218
|
+
undefined,
|
|
219
|
+
changeRequestHooks,
|
|
215
220
|
);
|
|
216
|
-
return { svc, git, fetch, deleted, emitted, hasAnyActive };
|
|
221
|
+
return { svc, git, fetch, deleted, emitted, hasAnyActive, changeRequestHooks };
|
|
217
222
|
}
|
|
218
223
|
|
|
219
224
|
const merged = (number: number, source: string, sha: string | null): CrRow => ({
|
|
@@ -557,6 +562,9 @@ describe('a change request that proposes nothing never blocks a deletion', () =>
|
|
|
557
562
|
expect.objectContaining({ number: 21, source: 'ali/sync', target: DEFAULT }),
|
|
558
563
|
expect.objectContaining({ number: 22, source: 'ali/feature', target: 'ali/sync' }),
|
|
559
564
|
]);
|
|
565
|
+
// A reopened request was opened once already: the deployment's
|
|
566
|
+
// change-request-opened listeners are not told again.
|
|
567
|
+
expect(h.changeRequestHooks.runChangeRequestOpened).not.toHaveBeenCalled();
|
|
560
568
|
// The app's path, the same.
|
|
561
569
|
reopens.length = 0;
|
|
562
570
|
await expect(h.svc.deleteBranch(workspaceIdForBranch(DEFAULT), 'ali/sync', ADMIN)).rejects.toThrow(/stale info/);
|
|
@@ -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,10 +19,13 @@ 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';
|
|
@@ -225,6 +232,8 @@ function makePrs(): PullRequestService {
|
|
|
225
232
|
getPrDetail: vi.fn(),
|
|
226
233
|
invalidateDetailCache: vi.fn(),
|
|
227
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.' })),
|
|
228
237
|
} as unknown as PullRequestService;
|
|
229
238
|
}
|
|
230
239
|
|
|
@@ -1640,6 +1649,254 @@ describe('WorkflowService.openChangeRequest — when the repository host refuses
|
|
|
1640
1649
|
});
|
|
1641
1650
|
});
|
|
1642
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
|
+
|
|
1643
1900
|
/**
|
|
1644
1901
|
* Writes are committed asynchronously: a write tool answers once the bytes are
|
|
1645
1902
|
* on disk, and the pending-commits worker commits and pushes them seconds
|
|
@@ -652,6 +652,16 @@ describe('registerWorkflowTools', () => {
|
|
|
652
652
|
expect(files.every((f) => !('patch' in f))).toBe(true);
|
|
653
653
|
});
|
|
654
654
|
|
|
655
|
+
it('says who was notified when the service does, and nothing of it otherwise', async () => {
|
|
656
|
+
const base = await start();
|
|
657
|
+
// A read of a request later has no `notified` to report: the key is absent, not empty.
|
|
658
|
+
expect(await open(base)).not.toHaveProperty('notified');
|
|
659
|
+
opened = { ...BIG(), notified: ['email'] };
|
|
660
|
+
expect(await open(base)).toMatchObject({ number: 7, notified: ['email'] });
|
|
661
|
+
opened = { ...BIG(), notified: [] };
|
|
662
|
+
expect((await open(base)).notified).toEqual([]);
|
|
663
|
+
});
|
|
664
|
+
|
|
655
665
|
it('keeps the 27-file answer under 10,000 characters', async () => {
|
|
656
666
|
const base = await start();
|
|
657
667
|
const body = await open(base);
|
|
@@ -96,6 +96,13 @@ export interface ChangeRequestSummary {
|
|
|
96
96
|
files: ChangeRequestSummaryFile[];
|
|
97
97
|
/** How many files the request changes in total, cut or not. */
|
|
98
98
|
totalFiles: number;
|
|
99
|
+
/**
|
|
100
|
+
* The channels that reached the approvers as the request was opened
|
|
101
|
+
* (`['email']`, say), as the deployment's listeners said; empty when nobody
|
|
102
|
+
* was told. Only on the answer to an open — a request read later has no
|
|
103
|
+
* such moment to report.
|
|
104
|
+
*/
|
|
105
|
+
notified?: string[];
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
/**
|
|
@@ -157,7 +164,7 @@ function summaryFileOf(file: ChangedFile, withPatch: boolean): ChangeRequestSumm
|
|
|
157
164
|
* by that: `mergeBlockedReasons` names every missing approval, cut or not.
|
|
158
165
|
*/
|
|
159
166
|
export function summarizeChangeRequest(
|
|
160
|
-
detail: ChangeRequestDetail,
|
|
167
|
+
detail: ChangeRequestDetail & { notified?: string[] },
|
|
161
168
|
include: readonly ChangeRequestInclude[] = [],
|
|
162
169
|
): ChangeRequestSummary {
|
|
163
170
|
const all = detail.files ?? [];
|
|
@@ -176,6 +183,8 @@ export function summarizeChangeRequest(
|
|
|
176
183
|
mergeBlockedReasons: detail.mergeBlockedReasons ?? [],
|
|
177
184
|
files: listed.map((f) => summaryFileOf(f, withPatch)),
|
|
178
185
|
totalFiles: all.length,
|
|
186
|
+
// Only an open carries it; a read of a request later has none to report.
|
|
187
|
+
...(detail.notified ? { notified: detail.notified } : {}),
|
|
179
188
|
};
|
|
180
189
|
}
|
|
181
190
|
|
|
@@ -154,6 +154,12 @@ const changeRequestSummarySchema: JsonSchema = {
|
|
|
154
154
|
description: `The changed paths with their kind of change, cut to ${CHANGE_REQUEST_PATH_LIMIT}; \`include: ["all-paths"]\` lists them all.`,
|
|
155
155
|
},
|
|
156
156
|
totalFiles: { type: 'integer', description: 'How many files the request changes in total, cut or not.' },
|
|
157
|
+
notified: {
|
|
158
|
+
type: 'array',
|
|
159
|
+
items: { type: 'string' },
|
|
160
|
+
description:
|
|
161
|
+
'On the answer to an open only: the channels that reached the approvers ("email", say), as the deployment\'s listeners said; empty when nobody was told and the request waits to be found in the app.',
|
|
162
|
+
},
|
|
157
163
|
},
|
|
158
164
|
required: ['url', 'number', 'title', 'state', 'sourceBranch', 'targetBranch', 'approvals', 'mergeBlockedReasons', 'files', 'totalFiles'],
|
|
159
165
|
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
2
|
+
import { logger } from '../../shared/logging.js';
|
|
3
|
+
import { ListenerSeam, type NotifyingListener } from '../../shared/listener-seam.js';
|
|
4
|
+
|
|
5
|
+
const log = logger('cr');
|
|
6
|
+
|
|
7
|
+
/** Somebody a listener can write to: an account's id, address and name. */
|
|
8
|
+
export type ChangeRequestPerson = Pick<AuthUser, 'id' | 'email' | 'name'>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A change request was OPENED — by a person in the app or by an agent through
|
|
12
|
+
* its tool, which take the one road (`WorkflowService.openChangeRequest`).
|
|
13
|
+
* Raised once per request, after its row exists and its source branch was
|
|
14
|
+
* pushed, or the push was refused: the request exists either way, and the
|
|
15
|
+
* branch's next push carries the commits. Never raised for a request that is
|
|
16
|
+
* reopened after a failed branch deletion — it was opened once already — nor
|
|
17
|
+
* for one updated from its target.
|
|
18
|
+
*/
|
|
19
|
+
export interface ChangeRequestOpenedEvent {
|
|
20
|
+
number: number;
|
|
21
|
+
title: string;
|
|
22
|
+
sourceBranch: string;
|
|
23
|
+
targetBranch: string;
|
|
24
|
+
/** Who opened it: the person, or the person the agent acted for. */
|
|
25
|
+
openedBy: ChangeRequestPerson;
|
|
26
|
+
/**
|
|
27
|
+
* The distinct people eligible to approve some changed path, as accounts —
|
|
28
|
+
* the eligibility the request's `## Affected owners` block lists, with the
|
|
29
|
+
* roles and groups expanded to their members, each person once however
|
|
30
|
+
* many paths or roles name them. Only a person with an account is here: a
|
|
31
|
+
* listener writes to accounts. The author is not left out; a listener that
|
|
32
|
+
* will not tell people of their own request reads `openedBy`.
|
|
33
|
+
*/
|
|
34
|
+
approvers: ChangeRequestPerson[];
|
|
35
|
+
/**
|
|
36
|
+
* Eligible approvers the event cannot hand over as accounts, each counted
|
|
37
|
+
* once: a person the access model names who has no account yet, and the
|
|
38
|
+
* built-in `everyone` (anyone signed in may approve, and nobody can be
|
|
39
|
+
* listed for that). Zero when every approver is in `approvers`.
|
|
40
|
+
*/
|
|
41
|
+
unresolvedApprovers: number;
|
|
42
|
+
/**
|
|
43
|
+
* The request's address, when the deployment has a public address
|
|
44
|
+
* configured (`PUBLIC_FRONTEND_URL`). Absent otherwise: a listener that
|
|
45
|
+
* knows where the app is builds the link to `/change-requests/<number>`
|
|
46
|
+
* itself.
|
|
47
|
+
*/
|
|
48
|
+
url?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Whether the source branch's push was refused by the host as the request
|
|
51
|
+
* was opened: the request exists, but its commits reach the host only when
|
|
52
|
+
* an agent has reconciled the branch and pushed again, which is seconds
|
|
53
|
+
* usually and may fail. A listener decides what that is worth — a mail
|
|
54
|
+
* worded for it, a delay, or none — because a reviewer sent to the
|
|
55
|
+
* request before then opens one whose changes are not there yet. False
|
|
56
|
+
* when the push landed.
|
|
57
|
+
*/
|
|
58
|
+
pushPending: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A listener answers how it reached the approvers — a non-empty channel name,
|
|
63
|
+
* `'email'` say — or null when it did not; the rule is {@link NotifyingListener}'s:
|
|
64
|
+
* awaited, a throw logged and counted as null, never failing the open.
|
|
65
|
+
*/
|
|
66
|
+
export type ChangeRequestOpenedHook = NotifyingListener<ChangeRequestOpenedEvent>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Core's seam for "a change request was opened": a deployment (the cloud
|
|
70
|
+
* with its mail, an enterprise overlay) registers how to tell the reviewers,
|
|
71
|
+
* and core says in its answer whether somebody did. Core registers nothing,
|
|
72
|
+
* so a core-only deployment notifies nobody and the request waits to be
|
|
73
|
+
* found in the app, as before.
|
|
74
|
+
*/
|
|
75
|
+
export interface IChangeRequestHooks {
|
|
76
|
+
onChangeRequestOpened(hook: ChangeRequestOpenedHook): void;
|
|
77
|
+
/** Run every listener in registration order; the channels that reached the approvers, in that order. */
|
|
78
|
+
runChangeRequestOpened(event: ChangeRequestOpenedEvent): Promise<string[]>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class ChangeRequestHooks implements IChangeRequestHooks {
|
|
82
|
+
// The request exists whether or not the reviewers were told: the opener's
|
|
83
|
+
// answer says they were not, and the request is found in the app as before.
|
|
84
|
+
private readonly opened = new ListenerSeam<ChangeRequestOpenedEvent>(
|
|
85
|
+
log,
|
|
86
|
+
'a change-request-opened listener failed; the reviewers were not notified by it:',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
onChangeRequestOpened(hook: ChangeRequestOpenedHook): void {
|
|
90
|
+
this.opened.add(hook);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
runChangeRequestOpened(event: ChangeRequestOpenedEvent): Promise<string[]> {
|
|
94
|
+
return this.opened.run(event);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -253,6 +253,16 @@ export class PullRequestService implements IPullRequestService {
|
|
|
253
253
|
this.linkBase = changeRequestLinkBase(publicFrontendUrl);
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The link to change request `number`, as every summary and detail carries
|
|
258
|
+
* it — absolute under the public address, else the in-app path with its
|
|
259
|
+
* note. Needs no read: a caller holding only the number (an open whose push
|
|
260
|
+
* was refused, say) builds the same link a listing would.
|
|
261
|
+
*/
|
|
262
|
+
linkFor(number: number): { url: string; urlNote?: string } {
|
|
263
|
+
return changeRequestLink(number, this.linkBase);
|
|
264
|
+
}
|
|
265
|
+
|
|
256
266
|
setDetailEnricher(enricher: PrDetailEnricher): void {
|
|
257
267
|
this.detailEnricher = enricher;
|
|
258
268
|
}
|