@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.
Files changed (76) hide show
  1. package/dist/core/create-core-services.d.ts +7 -0
  2. package/dist/core/create-core-services.d.ts.map +1 -1
  3. package/dist/core/create-core-services.js +12 -1
  4. package/dist/core/create-core-services.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/modules/access-model/index.d.ts +14 -0
  10. package/dist/modules/access-model/index.d.ts.map +1 -0
  11. package/dist/modules/access-model/index.js +13 -0
  12. package/dist/modules/access-model/index.js.map +1 -0
  13. package/dist/modules/auth/account-hooks.d.ts +4 -6
  14. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  15. package/dist/modules/auth/account-hooks.js +7 -17
  16. package/dist/modules/auth/account-hooks.js.map +1 -1
  17. package/dist/modules/auth/auth.service.d.ts +9 -8
  18. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  19. package/dist/modules/auth/auth.service.js +19 -18
  20. package/dist/modules/auth/auth.service.js.map +1 -1
  21. package/dist/modules/database/core-schema.d.ts +21 -1
  22. package/dist/modules/database/core-schema.d.ts.map +1 -1
  23. package/dist/modules/database/core-schema.js +16 -1
  24. package/dist/modules/database/core-schema.js.map +1 -1
  25. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  26. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  27. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  28. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  29. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  30. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  31. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  32. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  33. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  34. package/dist/modules/workflow/agent-tools/workflow.tools.js +5 -0
  35. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  36. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  37. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  38. package/dist/modules/workflow/change-request-hooks.js +15 -0
  39. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  40. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  41. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  42. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  43. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  44. package/dist/modules/workflow/workflow.service.d.ts +75 -15
  45. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  46. package/dist/modules/workflow/workflow.service.js +163 -32
  47. package/dist/modules/workflow/workflow.service.js.map +1 -1
  48. package/dist/shared/listener-seam.d.ts +34 -0
  49. package/dist/shared/listener-seam.d.ts.map +1 -0
  50. package/dist/shared/listener-seam.js +42 -0
  51. package/dist/shared/listener-seam.js.map +1 -0
  52. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  53. package/migrations/meta/0019_snapshot.json +2435 -0
  54. package/migrations/meta/_journal.json +7 -0
  55. package/package.json +3 -3
  56. package/src/core/create-core-services.ts +17 -0
  57. package/src/index.ts +7 -0
  58. package/src/modules/access-model/index.ts +13 -0
  59. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  60. package/src/modules/auth/account-hooks.ts +13 -21
  61. package/src/modules/auth/auth.service.ts +19 -18
  62. package/src/modules/database/core-schema.ts +16 -1
  63. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  64. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  65. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  66. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  67. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  68. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  69. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +258 -1
  70. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +10 -0
  71. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  72. package/src/modules/workflow/agent-tools/workflow.tools.ts +6 -0
  73. package/src/modules/workflow/change-request-hooks.ts +96 -0
  74. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  75. package/src/modules/workflow/workflow.service.ts +188 -40
  76. 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
  }