@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
|
@@ -89,6 +89,11 @@ import type { WorkflowEventBus } from './event-bus.js';
|
|
|
89
89
|
import { describeSyncFailure, sanitizeError } from './sanitize-error.js';
|
|
90
90
|
import type { FileChangeNotifier } from '../kb-fs/file-change-notifier.js';
|
|
91
91
|
import { WorkflowHooks } from './workflow-hooks.js';
|
|
92
|
+
import {
|
|
93
|
+
ChangeRequestHooks,
|
|
94
|
+
type ChangeRequestPerson,
|
|
95
|
+
type IChangeRequestHooks,
|
|
96
|
+
} from './change-request-hooks.js';
|
|
92
97
|
import { WorkspaceMutex } from '../kb-fs/mutex.js';
|
|
93
98
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
94
99
|
import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
|
|
@@ -108,8 +113,7 @@ import {
|
|
|
108
113
|
WorkflowValidationError,
|
|
109
114
|
} from '../../shared/domain-errors.js';
|
|
110
115
|
import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
|
|
111
|
-
import { AccessDeniedError } from '../access-model/
|
|
112
|
-
import type { IChangeReadGate } from '../access-model/change-gate.js';
|
|
116
|
+
import { AccessDeniedError, EVERYONE_CANONICAL, type IChangeReadGate } from '../access-model/index.js';
|
|
113
117
|
import { printable } from '../../shared/printable.js';
|
|
114
118
|
|
|
115
119
|
const execFileAsync = promisify(execFile);
|
|
@@ -249,6 +253,34 @@ export function formatAffectedOwnersBlock(
|
|
|
249
253
|
return out.join('\n');
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
/** What the access layer answers for the changed paths of a request, or null when it cannot. */
|
|
257
|
+
type EligibleWritersAtRef = Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>>;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The `## Affected owners` markdown block appended to the CR body, from the
|
|
261
|
+
* eligibility already resolved. The per-path list is capped (see
|
|
262
|
+
* {@link formatAffectedOwnersBlock}) so the body stays within the
|
|
263
|
+
* `gh pr create --body` arg limit on large changesets. The empty string when
|
|
264
|
+
* nothing was resolvable — keeps the body clean rather than printing a stub
|
|
265
|
+
* heading.
|
|
266
|
+
*/
|
|
267
|
+
function buildAffectedOwnersBlock(paths: string[], resolved: EligibleWritersAtRef): string {
|
|
268
|
+
if (!resolved || resolved.size === 0) return '';
|
|
269
|
+
return formatAffectedOwnersBlock(paths, resolved);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The one question the open asks of accounts: which of these addresses have
|
|
274
|
+
* a live account. `AuthService.findActiveAccountsByEmail` answers it in a
|
|
275
|
+
* single indexed read; the shape is spelled here so the workflow module
|
|
276
|
+
* imports nothing of auth's. Keyed by the canonical address asked for.
|
|
277
|
+
*/
|
|
278
|
+
export interface ApproverAccounts {
|
|
279
|
+
findActiveAccountsByEmail(
|
|
280
|
+
emails: readonly string[],
|
|
281
|
+
): Promise<Map<string, { id: string; email: string; name: string }>>;
|
|
282
|
+
}
|
|
283
|
+
|
|
252
284
|
/** Git commit subjects are capped at 200 characters (`GitService.commitFile`). */
|
|
253
285
|
const MAX_COMMIT_SUBJECT = 200;
|
|
254
286
|
|
|
@@ -343,6 +375,19 @@ export class WorkflowService implements IWorkflowService {
|
|
|
343
375
|
* hooks) unchanged.
|
|
344
376
|
*/
|
|
345
377
|
public readonly hooks: WorkflowHooks = new WorkflowHooks(),
|
|
378
|
+
/**
|
|
379
|
+
* The seam a deployment's notification extends when a request is opened
|
|
380
|
+
* (see `change-request-hooks.ts`). The composition root passes the SAME
|
|
381
|
+
* instance it exposes as `changeRequestHooks`; the default registers
|
|
382
|
+
* nobody, so a test construction notifies nobody and says so.
|
|
383
|
+
*/
|
|
384
|
+
private readonly changeRequestHooks: IChangeRequestHooks = new ChangeRequestHooks(),
|
|
385
|
+
/**
|
|
386
|
+
* Who turns the addresses the access model names into accounts, for the
|
|
387
|
+
* opened event's `approvers`. Absent (a harness without accounts), every
|
|
388
|
+
* approver counts as unresolved.
|
|
389
|
+
*/
|
|
390
|
+
private readonly accounts?: ApproverAccounts,
|
|
346
391
|
) {
|
|
347
392
|
// The type already requires it; this holds for a caller the compiler did
|
|
348
393
|
// not see (plain JavaScript, a positional slip in a harness).
|
|
@@ -2270,12 +2315,16 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2270
2315
|
* account, so no hidden body marker). The DB-assigned number is the CR id.
|
|
2271
2316
|
* 6. Re-fetch the full detail via the PR service so the response shape
|
|
2272
2317
|
* matches every other CR endpoint.
|
|
2318
|
+
* 7. Tell the deployment's change-request listeners (see
|
|
2319
|
+
* `change-request-hooks.ts`); `notified` in the answer says which
|
|
2320
|
+
* channels reached the approvers. Once per request, here, whether a
|
|
2321
|
+
* person or an agent opened it — both take this road.
|
|
2273
2322
|
*/
|
|
2274
2323
|
async openChangeRequest(
|
|
2275
2324
|
workspaceId: string,
|
|
2276
2325
|
user: AuthUser,
|
|
2277
2326
|
input: OpenChangeRequestInput,
|
|
2278
|
-
): Promise<ChangeRequestDetail> {
|
|
2327
|
+
): Promise<ChangeRequestDetail & { notified: string[] }> {
|
|
2279
2328
|
if (!input.sourceBranch) throw new WorkflowValidationError('sourceBranch is required');
|
|
2280
2329
|
if (!input.targetBranch) throw new WorkflowValidationError('targetBranch is required');
|
|
2281
2330
|
if (input.sourceBranch === input.targetBranch) {
|
|
@@ -2300,7 +2349,19 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2300
2349
|
// its branches is retired inside the window, and holding only the source
|
|
2301
2350
|
// left a `Y -> X` request open to X being deleted under it — the very
|
|
2302
2351
|
// failure the widened guards exist to prevent.
|
|
2303
|
-
|
|
2352
|
+
//
|
|
2353
|
+
// The deployment's listeners are told AFTER the locks are released: they
|
|
2354
|
+
// are awaited (the answer's `notified` is true to what happened), and a
|
|
2355
|
+
// slow mailer must hold up this answer alone, never a delete or a merge
|
|
2356
|
+
// on either branch. So the locked part answers what it made — the detail,
|
|
2357
|
+
// or the refusal — and the telling follows.
|
|
2358
|
+
const outcome = await this.runOnBranchPair(input.sourceBranch, input.targetBranch, async (): Promise<
|
|
2359
|
+
{ number: number; eligible: EligibleWritersAtRef } & (
|
|
2360
|
+
| { detail: ChangeRequestDetail; pushRefused: null; detailError: null }
|
|
2361
|
+
| { detail: null; pushRefused: PushNeedsAgentResolutionError; detailError: null }
|
|
2362
|
+
| { detail: null; pushRefused: null; detailError: Error }
|
|
2363
|
+
)
|
|
2364
|
+
> => {
|
|
2304
2365
|
// Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
|
|
2305
2366
|
// parallel is still allowed). `listOpenPrs` is cached for 30s so we
|
|
2306
2367
|
// force-fresh to catch a CR that opened just now.
|
|
@@ -2366,11 +2427,10 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2366
2427
|
input.targetBranch,
|
|
2367
2428
|
input.sourceBranch,
|
|
2368
2429
|
);
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
);
|
|
2430
|
+
// Resolved once: the body lists this eligibility, and the opened event
|
|
2431
|
+
// hands the same people to the deployment's listeners.
|
|
2432
|
+
const eligible = await this.eligibleWritersFor(workspaceId, input.targetBranch, touchedPaths);
|
|
2433
|
+
const affectedOwnersBlock = buildAffectedOwnersBlock(touchedPaths, eligible);
|
|
2374
2434
|
|
|
2375
2435
|
// The change request is a plain DB row now — no provider PR. Author is a
|
|
2376
2436
|
// real column (no hidden body marker needed, since nothing runs as a shared
|
|
@@ -2453,22 +2513,117 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2453
2513
|
// refused push never moved — answering the refusal is what the caller
|
|
2454
2514
|
// needs, not a detail read that may fail on a branch the host lacks.
|
|
2455
2515
|
this.events?.emit(opened);
|
|
2456
|
-
|
|
2516
|
+
return { number: prNumber, eligible, detail: null, pushRefused, detailError: null };
|
|
2457
2517
|
}
|
|
2458
2518
|
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
workspaceId,
|
|
2462
|
-
viewerEmail: user.email,
|
|
2463
|
-
});
|
|
2464
|
-
if (!detail) {
|
|
2465
|
-
throw new WorkflowValidationError(
|
|
2466
|
-
`Created change request #${prNumber} but could not fetch its detail`,
|
|
2467
|
-
);
|
|
2468
|
-
}
|
|
2519
|
+
// The row exists and the push landed: every tab is told, whatever the
|
|
2520
|
+
// read-back below does.
|
|
2469
2521
|
this.events?.emit(opened);
|
|
2470
|
-
|
|
2522
|
+
// Reading the detail back is the caller's answer, not the open: a read
|
|
2523
|
+
// that fails is reported, but it is not allowed to cost the reviewers
|
|
2524
|
+
// their message — the request stands, and a retry of the open meets the
|
|
2525
|
+
// duplicate check, so there is no second chance to tell them.
|
|
2526
|
+
let detail: ChangeRequestDetail | null = null;
|
|
2527
|
+
let detailError: Error | null = null;
|
|
2528
|
+
try {
|
|
2529
|
+
detail = await this.prs.getPrDetail(prNumber, {
|
|
2530
|
+
fresh: true,
|
|
2531
|
+
workspaceId,
|
|
2532
|
+
viewerEmail: user.email,
|
|
2533
|
+
});
|
|
2534
|
+
} catch (err) {
|
|
2535
|
+
detailError = err instanceof Error ? err : new Error(String(err));
|
|
2536
|
+
}
|
|
2537
|
+
if (detail) return { number: prNumber, eligible, detail, pushRefused: null, detailError: null };
|
|
2538
|
+
return {
|
|
2539
|
+
number: prNumber,
|
|
2540
|
+
eligible,
|
|
2541
|
+
detail: null,
|
|
2542
|
+
pushRefused: null,
|
|
2543
|
+
detailError:
|
|
2544
|
+
detailError ?? new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`),
|
|
2545
|
+
};
|
|
2471
2546
|
});
|
|
2547
|
+
|
|
2548
|
+
// The request exists in every branch of the outcome — the listeners are
|
|
2549
|
+
// told all the same when the push was refused (`pushPending` says so:
|
|
2550
|
+
// the request is there to find, its commits reach the host with the
|
|
2551
|
+
// branch's next push, and what that is worth to a reviewer is each
|
|
2552
|
+
// listener's call) and when its detail could not be read back. Only
|
|
2553
|
+
// then is the refusal or the failed read the caller's answer.
|
|
2554
|
+
const notified = await this.notifyOpened(outcome.number, input, user, outcome.eligible, outcome.pushRefused !== null);
|
|
2555
|
+
if (outcome.pushRefused) throw outcome.pushRefused;
|
|
2556
|
+
if (outcome.detailError) throw outcome.detailError;
|
|
2557
|
+
return { ...outcome.detail, notified };
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/**
|
|
2561
|
+
* Tell the deployment's listeners the request was opened (see
|
|
2562
|
+
* {@link IChangeRequestHooks}); the channels that reached the approvers.
|
|
2563
|
+
* Never throws: a listener's failure is the hooks' to log, and a failed
|
|
2564
|
+
* read of the approvers' accounts hands the listeners none of them, counted
|
|
2565
|
+
* as unresolved.
|
|
2566
|
+
*/
|
|
2567
|
+
private async notifyOpened(
|
|
2568
|
+
number: number,
|
|
2569
|
+
input: OpenChangeRequestInput,
|
|
2570
|
+
user: AuthUser,
|
|
2571
|
+
eligible: EligibleWritersAtRef,
|
|
2572
|
+
pushPending: boolean,
|
|
2573
|
+
): Promise<string[]> {
|
|
2574
|
+
const { approvers, unresolvedApprovers } = await this.resolveApprovers(eligible);
|
|
2575
|
+
// Only an address a person can open from outside is handed over; the
|
|
2576
|
+
// relative in-app path (no `PUBLIC_FRONTEND_URL`) is the listener's to
|
|
2577
|
+
// complete.
|
|
2578
|
+
const link = this.prs.linkFor(number);
|
|
2579
|
+
return this.changeRequestHooks.runChangeRequestOpened({
|
|
2580
|
+
number,
|
|
2581
|
+
title: input.title.trim(),
|
|
2582
|
+
sourceBranch: input.sourceBranch,
|
|
2583
|
+
targetBranch: input.targetBranch,
|
|
2584
|
+
openedBy: { id: user.id, email: user.email, name: user.name },
|
|
2585
|
+
approvers,
|
|
2586
|
+
unresolvedApprovers,
|
|
2587
|
+
...(link.urlNote === undefined ? { url: link.url } : {}),
|
|
2588
|
+
pushPending,
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
/**
|
|
2593
|
+
* The distinct people eligible to approve some changed path, as accounts.
|
|
2594
|
+
* Each path's eligibility already expands its roles and groups to the
|
|
2595
|
+
* addresses the access model names (`emails`), so a person is one address
|
|
2596
|
+
* however many paths or roles name them; the addresses are looked up as
|
|
2597
|
+
* live accounts in one read. An address with no account, or with one
|
|
2598
|
+
* switched off, and the built-in `everyone` (anyone signed in may approve;
|
|
2599
|
+
* nobody can be listed for that), are counted, not handed over.
|
|
2600
|
+
*/
|
|
2601
|
+
private async resolveApprovers(
|
|
2602
|
+
eligible: EligibleWritersAtRef,
|
|
2603
|
+
): Promise<{ approvers: ChangeRequestPerson[]; unresolvedApprovers: number }> {
|
|
2604
|
+
const addresses = new Set<string>();
|
|
2605
|
+
let everyone = false;
|
|
2606
|
+
for (const info of eligible?.values() ?? []) {
|
|
2607
|
+
for (const email of info.emails) addresses.add(canonicalEmail(email));
|
|
2608
|
+
if (info.roles.includes(EVERYONE_CANONICAL)) everyone = true;
|
|
2609
|
+
}
|
|
2610
|
+
let accounts = new Map<string, { id: string; email: string; name: string }>();
|
|
2611
|
+
if (addresses.size > 0 && this.accounts) {
|
|
2612
|
+
try {
|
|
2613
|
+
accounts = await this.accounts.findActiveAccountsByEmail([...addresses]);
|
|
2614
|
+
} catch (err) {
|
|
2615
|
+
crLog.warn('the approvers of a change request could not be read as accounts; the listeners are handed none:', { err });
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
const approvers: ChangeRequestPerson[] = [];
|
|
2619
|
+
let unresolvedApprovers = everyone ? 1 : 0;
|
|
2620
|
+
for (const address of addresses) {
|
|
2621
|
+
const account = accounts.get(address);
|
|
2622
|
+
if (account) approvers.push({ id: account.id, email: account.email, name: account.name });
|
|
2623
|
+
else unresolvedApprovers++;
|
|
2624
|
+
}
|
|
2625
|
+
approvers.sort((a, b) => a.email.localeCompare(b.email));
|
|
2626
|
+
return { approvers, unresolvedApprovers };
|
|
2472
2627
|
}
|
|
2473
2628
|
|
|
2474
2629
|
/**
|
|
@@ -4694,37 +4849,30 @@ export class WorkflowService implements IWorkflowService {
|
|
|
4694
4849
|
}
|
|
4695
4850
|
|
|
4696
4851
|
/**
|
|
4697
|
-
*
|
|
4698
|
-
*
|
|
4699
|
-
*
|
|
4700
|
-
*
|
|
4701
|
-
* matches who can actually approve at merge time.
|
|
4702
|
-
*
|
|
4703
|
-
*
|
|
4704
|
-
*
|
|
4705
|
-
* Returns the empty string when there are no touched paths or no
|
|
4706
|
-
* resolvable eligibility — keeps the body clean rather than printing
|
|
4707
|
-
* a stub heading.
|
|
4852
|
+
* The eligible approvers (roles + named users, and the addresses they
|
|
4853
|
+
* expand to) per changed path, per the access tree on
|
|
4854
|
+
* `origin/<targetBranch>` — the same tree the approval gate uses
|
|
4855
|
+
* server-side, so what the body advertises and the listeners are told
|
|
4856
|
+
* matches who can actually approve at merge time. Null when there are no
|
|
4857
|
+
* touched paths or no resolvable eligibility: a missing affected-owners
|
|
4858
|
+
* block is cosmetic, not a reason to abort the open.
|
|
4708
4859
|
*/
|
|
4709
|
-
private async
|
|
4860
|
+
private async eligibleWritersFor(
|
|
4710
4861
|
workspaceId: string,
|
|
4711
4862
|
targetBranch: string,
|
|
4712
4863
|
paths: string[],
|
|
4713
|
-
): Promise<
|
|
4714
|
-
if (paths.length === 0) return
|
|
4715
|
-
let resolved: Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>> = null;
|
|
4864
|
+
): Promise<EligibleWritersAtRef> {
|
|
4865
|
+
if (paths.length === 0) return null;
|
|
4716
4866
|
try {
|
|
4717
|
-
|
|
4867
|
+
return await this.accessControl.eligibleWritersForPathsAtRef(
|
|
4718
4868
|
workspaceId,
|
|
4719
4869
|
`origin/${targetBranch}`,
|
|
4720
4870
|
paths,
|
|
4721
4871
|
);
|
|
4722
4872
|
} catch (err) {
|
|
4723
4873
|
log.warn('eligibleWritersForPathsAtRef failed:', { err });
|
|
4874
|
+
return null;
|
|
4724
4875
|
}
|
|
4725
|
-
if (!resolved || resolved.size === 0) return '';
|
|
4726
|
-
|
|
4727
|
-
return formatAffectedOwnersBlock(paths, resolved);
|
|
4728
4876
|
}
|
|
4729
4877
|
|
|
4730
4878
|
async updateBranch(
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ILogger } from './logger.contract.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A listener answers how it reached the people an event concerns — a
|
|
5
|
+
* non-empty channel name, `'email'` say — or null when it did not (nothing
|
|
6
|
+
* configured, nobody to tell, the send failed). It is awaited: the caller's
|
|
7
|
+
* answer says who was notified, truthfully, so a listener bounds its own
|
|
8
|
+
* wait. A throw is logged and counts as null; it never fails the caller. An
|
|
9
|
+
* empty name is no channel and counts as null too.
|
|
10
|
+
*/
|
|
11
|
+
export type NotifyingListener<E> = (event: E) => Promise<string | null>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The one way core runs the notification listeners a deployment registers
|
|
15
|
+
* on a seam (an account invited, a change request opened): every listener
|
|
16
|
+
* in registration order, each awaited, the channels that reached somebody
|
|
17
|
+
* collected in that order, a failure logged and counted as "did not" so the
|
|
18
|
+
* event's own effect — the account made, the request opened — stands
|
|
19
|
+
* whatever the listeners do. A seam wraps one of these per event and keeps
|
|
20
|
+
* its own typed registration method, so the rule above is written once.
|
|
21
|
+
*/
|
|
22
|
+
export class ListenerSeam<E> {
|
|
23
|
+
private readonly listeners: NotifyingListener<E>[] = [];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param log Where a failed listener is reported.
|
|
27
|
+
* @param failed The line logged for one, said in the seam's own terms
|
|
28
|
+
* ("an account-invited listener failed; the person was not notified by it:").
|
|
29
|
+
*/
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly log: ILogger,
|
|
32
|
+
private readonly failed: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
add(listener: NotifyingListener<E>): void {
|
|
36
|
+
this.listeners.push(listener);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run every listener in registration order; the channels that reached somebody, in that order. */
|
|
40
|
+
async run(event: E): Promise<string[]> {
|
|
41
|
+
const notified: string[] = [];
|
|
42
|
+
for (const listener of this.listeners) {
|
|
43
|
+
try {
|
|
44
|
+
const channel = await listener(event);
|
|
45
|
+
if (channel !== null && channel !== '') notified.push(channel);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
this.log.error(this.failed, { err });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return notified;
|
|
51
|
+
}
|
|
52
|
+
}
|