@sanoma-ai/workflows 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -7
- package/dist/apps-list.d.ts +90 -0
- package/dist/apps-list.js +109 -0
- package/dist/apps-list.js.map +1 -0
- package/dist/apps.d.ts +42 -0
- package/dist/apps.js +137 -0
- package/dist/apps.js.map +1 -0
- package/dist/call-workflow.js +5 -6
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.js +6 -3
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +1 -1
- package/dist/cedar.js +6 -4
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.d.ts +10 -0
- package/dist/change-steps.js +204 -0
- package/dist/change-steps.js.map +1 -0
- package/dist/change-workflow.d.ts +54 -0
- package/dist/change-workflow.js +161 -0
- package/dist/change-workflow.js.map +1 -0
- package/dist/change.d.ts +121 -24
- package/dist/change.js +196 -17
- package/dist/change.js.map +1 -1
- package/dist/claims.d.ts +58 -0
- package/dist/claims.js +27 -0
- package/dist/claims.js.map +1 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +268 -6
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +6 -1
- package/dist/collection.js +11 -3
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +30 -2
- package/dist/config.js +41 -20
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +15 -0
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +5 -2
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +30 -14
- package/dist/define.js +9 -1
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +15 -4
- package/dist/demo.js +27 -12
- package/dist/demo.js.map +1 -1
- package/dist/describe.d.ts +16 -2
- package/dist/describe.js +39 -4
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +4 -3
- package/dist/direct.js +7 -4
- package/dist/direct.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/lint.js +19 -5
- package/dist/lint.js.map +1 -1
- package/dist/outline.js +13 -6
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +54 -2
- package/dist/paths.js.map +1 -1
- package/dist/resources.d.ts +7 -2
- package/dist/resources.js +23 -8
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +7 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +2 -2
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +2 -2
- package/dist/scenario.js +10 -4
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +1 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +19 -5
- package/dist/sdk.js.map +1 -1
- package/dist/shared.d.ts +43 -2
- package/dist/shared.js +46 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +26 -5
- package/dist/sites.js +56 -20
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +55 -5
- package/dist/source.js +4 -0
- package/dist/source.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.js +21 -13
- package/dist/version.js.map +1 -1
- package/dist/worker.js +50 -3
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -2
- package/package.json +1 -1
- package/src/apps-list.ts +119 -0
- package/src/apps.ts +162 -0
- package/src/call-workflow.ts +10 -2
- package/src/call.ts +6 -3
- package/src/cedar.ts +9 -7
- package/src/change-steps.ts +274 -0
- package/src/change-workflow.ts +198 -0
- package/src/change.ts +257 -27
- package/src/claims.ts +79 -0
- package/src/client.ts +320 -4
- package/src/collection.ts +22 -3
- package/src/config.ts +66 -22
- package/src/db.ts +23 -0
- package/src/deadlines.ts +5 -2
- package/src/define.ts +31 -12
- package/src/demo.ts +55 -24
- package/src/describe.ts +67 -4
- package/src/direct.ts +9 -5
- package/src/errors.ts +3 -0
- package/src/index.ts +14 -0
- package/src/lint.ts +20 -5
- package/src/outline.ts +15 -6
- package/src/paths.ts +57 -2
- package/src/resources.ts +27 -9
- package/src/run.ts +5 -0
- package/src/sandbox.ts +2 -5
- package/src/scenario.ts +14 -4
- package/src/scenarios.ts +1 -1
- package/src/sdk.ts +21 -7
- package/src/shared.ts +60 -5
- package/src/sites.ts +70 -24
- package/src/source.ts +50 -5
- package/src/version.ts +25 -16
- package/src/worker.ts +46 -3
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangeDecision,
|
|
3
|
+
ChangeDeploy,
|
|
4
|
+
ChangeEvent,
|
|
5
|
+
ChangeFacts,
|
|
6
|
+
ChangeReport,
|
|
7
|
+
ChangeState,
|
|
8
|
+
ChangeVerified,
|
|
9
|
+
changeOps,
|
|
10
|
+
MergeInput,
|
|
11
|
+
} from "./change.ts";
|
|
12
|
+
import { ADMIN, ChangeInput, initialChangeState } from "./change.ts";
|
|
13
|
+
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
14
|
+
import { errorCode, isRunControlError, type SanomaError } from "./errors.ts";
|
|
15
|
+
import { approverLabel, type CHANGE_WORKFLOW, errorMessage } from "./shared.ts";
|
|
16
|
+
import type { CheckResult } from "./source.ts";
|
|
17
|
+
|
|
18
|
+
// The built-in `change` workflow: the life of a change to the company's repository, from its
|
|
19
|
+
// draft to live. It opens the draft's branch as a pull request, checks and plans it, asks the
|
|
20
|
+
// policy, reports Sanoma's review on the host, waits in the Inbox for Publish (the approval),
|
|
21
|
+
// squash-merges, deploys and verifies. A workflow like any other, held to the same lint: every
|
|
22
|
+
// operation goes through `ctx`, and what is no operation is a step of `change-steps.ts`.
|
|
23
|
+
|
|
24
|
+
/** What the change workflow does that is no operation, each a step of its own (`change-steps.ts`). */
|
|
25
|
+
export interface ChangeSteps {
|
|
26
|
+
/** `sanoma check` on the draft (`Source.check`), its Permissions grid among it. */
|
|
27
|
+
check(id: string): Promise<CheckResult>;
|
|
28
|
+
/** The facts the policy decides the merge on: from the draft's diff, the waiting runs and `check`. */
|
|
29
|
+
plan(id: string, check: CheckResult): Promise<ChangeFacts>;
|
|
30
|
+
/** The policy's answer to the merge now, the runtime's rule first: who Publish waits for, or why it is denied. */
|
|
31
|
+
ahead(merge: MergeInput): Promise<ChangeDecision>;
|
|
32
|
+
/** Waits for the next event on the change's topic: the one that weighs most of those waiting then, a `merged` or `closed` checked on the host for pull request `number`. */
|
|
33
|
+
next(number: number): Promise<ChangeEvent>;
|
|
34
|
+
/** The event that weighs most of those waiting on the topic now, without waiting, checked as `next` does; undefined for none. */
|
|
35
|
+
queued(number: number): Promise<ChangeEvent | undefined>;
|
|
36
|
+
/** Withdraws every approval of the run approved for the merge, saying `reason`: none lifts a hold on a newer head. */
|
|
37
|
+
retire(reason: string): Promise<void>;
|
|
38
|
+
/** After the merge: the checkout follows `main`, then apply on the declared hosts, as a run of its own this one waits for. */
|
|
39
|
+
deploy(sha: string): Promise<ChangeDeploy>;
|
|
40
|
+
/** After an apply on the hosts: drift on them, as a run of its own; undefined with no host. */
|
|
41
|
+
verify(deploy: ChangeDeploy): Promise<ChangeVerified | undefined>;
|
|
42
|
+
/** Closes the draft the change merged. */
|
|
43
|
+
closeDraft(id: string): Promise<void>;
|
|
44
|
+
/** Publishes where the change is (`CHANGE_EVENT`), for the app. */
|
|
45
|
+
publish(state: ChangeState): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the change workflow uses: its four operations, and `approval`. */
|
|
49
|
+
type ChangeUse = (typeof changeOps.change)[keyof typeof changeOps.change] | "approval";
|
|
50
|
+
|
|
51
|
+
/** The approval Publish is: the Inbox's title for it. */
|
|
52
|
+
const PUBLISH = "Publish the change";
|
|
53
|
+
|
|
54
|
+
/** The `sanoma` status on the host: pending while it can merge, failure while checks fail or the policy denies; and what it waits for. */
|
|
55
|
+
function reviewOf(decision: ChangeDecision, facts: ChangeFacts): { state: "pending" | "failure"; description: string } {
|
|
56
|
+
if (decision.answer === "deny") {
|
|
57
|
+
return { state: "failure", description: `Denied: ${decision.reason ?? "the policy refuses it"}` };
|
|
58
|
+
}
|
|
59
|
+
const waits = decision.title ?? `Waits for ${approverLabel(decision.approver ?? { group: ADMIN })} to publish it`;
|
|
60
|
+
return facts.checksOk
|
|
61
|
+
? { state: "pending", description: waits }
|
|
62
|
+
: { state: "failure", description: `sanoma check fails. ${waits}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The change workflow over `ops` (`changeOps`, the built-in connector's) and `steps`:
|
|
67
|
+
* `resolveConfig` adds it to every config. It `uses` the four operations and `approval`.
|
|
68
|
+
*
|
|
69
|
+
* A run is the life of one change (run id `change:<draft>`): each round opens the draft's branch
|
|
70
|
+
* as a pull request (`open`, which pushes it), checks it (`steps.check`), plans its facts
|
|
71
|
+
* (`steps.plan`), asks the policy ahead (`steps.ahead`), and reports on the host (`review`). While
|
|
72
|
+
* the policy denies it, the run waits for what happens next (`steps.next`). Otherwise it asks one
|
|
73
|
+
* approval, Publish, of the hold's approver (else whoever drafted it), covering the merge, with the
|
|
74
|
+
* change and its facts as its details; once approved, it merges (`merge`). An event that comes in
|
|
75
|
+
* meanwhile (`SanomaClient.changeEvent`) withdraws the approval, and the run reads it:
|
|
76
|
+
* `commits` plans another round, after withdrawing every approval approved for the merge
|
|
77
|
+
* (`steps.retire`), so none lifts a hold on the new head; `close` closes the pull request; the
|
|
78
|
+
* pull request `closed` or `merged` on the host ends the change as it is. A rejected Publish
|
|
79
|
+
* closes it too. A merge whose branch moved plans another round; one the host refuses waits.
|
|
80
|
+
*
|
|
81
|
+
* Once merged, the deploy (`steps.deploy`): the checkout follows `main`, then apply on the
|
|
82
|
+
* declared hosts, as a run this one waits for, then drift on them (`steps.verify`). A `merged` or
|
|
83
|
+
* `closed` event someone told is checked on the host as it is read (`steps.next`, `steps.queued`); one it does not bear
|
|
84
|
+
* out plans another round. A Publish withdrawn with no event waits, blocked, for the next one.
|
|
85
|
+
* Then the draft is closed. Each phase is published (`steps.publish`, `CHANGE_EVENT`); the run's
|
|
86
|
+
* output is its `ChangeReport`.
|
|
87
|
+
*/
|
|
88
|
+
export function changeWorkflow(
|
|
89
|
+
ops: typeof changeOps,
|
|
90
|
+
steps: ChangeSteps,
|
|
91
|
+
): WorkflowDefinition<readonly ChangeUse[], typeof ChangeInput> {
|
|
92
|
+
return defineWorkflow({
|
|
93
|
+
// The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
|
|
94
|
+
name: "change" satisfies typeof CHANGE_WORKFLOW,
|
|
95
|
+
title: "Publish a change",
|
|
96
|
+
trigger: "manual",
|
|
97
|
+
input: ChangeInput,
|
|
98
|
+
uses: [ops.change.open, ops.change.review, ops.change.merge, ops.change.close, "approval"],
|
|
99
|
+
run: async (ctx, input) => {
|
|
100
|
+
const state = initialChangeState(input);
|
|
101
|
+
/** Moves the change on, and publishes where it is. */
|
|
102
|
+
const show = (patch: Partial<ChangeState>) => {
|
|
103
|
+
Object.assign(state, patch);
|
|
104
|
+
return steps.publish(state);
|
|
105
|
+
};
|
|
106
|
+
let report: ChangeReport | undefined;
|
|
107
|
+
while (report === undefined) {
|
|
108
|
+
// An approval is given for a head: one approved for an earlier round lifts no hold on this one.
|
|
109
|
+
await steps.retire("the change was planned again at a new head");
|
|
110
|
+
await show({ phase: "opening", decision: undefined, blocked: undefined });
|
|
111
|
+
const opened = await ctx.sanoma.change.open(input);
|
|
112
|
+
const pullRequest = { number: opened.number, url: opened.url };
|
|
113
|
+
await show({ branch: opened.branch, pullRequest, head: opened.head, phase: "checking" });
|
|
114
|
+
const check = await steps.check(input.id);
|
|
115
|
+
await show({ check, phase: "planning" });
|
|
116
|
+
const facts = await steps.plan(input.id, check);
|
|
117
|
+
const merge = { id: input.id, number: opened.number, head: opened.head, title: input.title, facts };
|
|
118
|
+
const decision = await steps.ahead(merge);
|
|
119
|
+
await show({ facts, decision, phase: "reviewing" });
|
|
120
|
+
await ctx.sanoma.change.review({
|
|
121
|
+
id: input.id,
|
|
122
|
+
number: opened.number,
|
|
123
|
+
head: opened.head,
|
|
124
|
+
...reviewOf(decision, facts),
|
|
125
|
+
...(input.page !== undefined && { page: input.page }),
|
|
126
|
+
});
|
|
127
|
+
// What came in while the round was planned decides it before any approval is asked.
|
|
128
|
+
let told = await steps.queued(opened.number);
|
|
129
|
+
let event: ChangeEvent | undefined;
|
|
130
|
+
let blocked = told === undefined && decision.answer === "deny" ? decision.reason : undefined;
|
|
131
|
+
if (told === undefined && blocked === undefined) {
|
|
132
|
+
try {
|
|
133
|
+
await show({ phase: "waiting" });
|
|
134
|
+
await ctx.approval(PUBLISH, {
|
|
135
|
+
approver: decision.approver!,
|
|
136
|
+
covers: [ops.change.merge],
|
|
137
|
+
details: JSON.stringify({ change: { id: input.id, title: input.title, branch: opened.branch }, facts }),
|
|
138
|
+
links: [opened.url, ...(input.page === undefined ? [] : [input.page])],
|
|
139
|
+
});
|
|
140
|
+
// An event told while Publish was being asked found no approval to withdraw: it is read before the merge.
|
|
141
|
+
told = await steps.queued(opened.number);
|
|
142
|
+
if (told === undefined) {
|
|
143
|
+
await show({ phase: "merging" });
|
|
144
|
+
let merged: Awaited<ReturnType<typeof ctx.sanoma.change.merge>>;
|
|
145
|
+
try {
|
|
146
|
+
merged = await ctx.sanoma.change.merge(merge);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// Approved, but the policy or the host refuses the merge now: the change waits, as for a refusal.
|
|
149
|
+
// A hold the policy puts on the merge itself is decided as Publish is, below.
|
|
150
|
+
if (isRunControlError(err) || errorCode(err)?.startsWith("approval_")) throw err;
|
|
151
|
+
merged = { outcome: "blocked", reason: errorMessage(err) };
|
|
152
|
+
}
|
|
153
|
+
if (merged.outcome === "merged") event = { kind: "merged", sha: merged.sha ?? opened.head };
|
|
154
|
+
else if (merged.outcome === "closed") event = { kind: "closed" };
|
|
155
|
+
else if (merged.outcome === "moved") event = { kind: "commits" };
|
|
156
|
+
else blocked = merged.reason ?? "the host refuses the merge";
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// A withdrawal comes with an event, else Publish was taken back. A rejection of Publish closes the change.
|
|
160
|
+
const code = errorCode(err);
|
|
161
|
+
if (code === "approval_withdrawn") {
|
|
162
|
+
told = await steps.queued(opened.number);
|
|
163
|
+
if (told === undefined) blocked = "Publish was withdrawn";
|
|
164
|
+
} else if (code === "approval_rejected") {
|
|
165
|
+
event = { kind: "close", by: ((err as SanomaError).data.by as { id: string }).id };
|
|
166
|
+
} else throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (blocked !== undefined) {
|
|
170
|
+
await show({ phase: "blocked", blocked });
|
|
171
|
+
told = await steps.next(opened.number);
|
|
172
|
+
}
|
|
173
|
+
event ??= told!;
|
|
174
|
+
if (event.kind === "merged") {
|
|
175
|
+
await show({ phase: "deploying" });
|
|
176
|
+
const deploy = await steps.deploy(event.sha);
|
|
177
|
+
const verified = await steps.verify(deploy);
|
|
178
|
+
await steps.closeDraft(input.id);
|
|
179
|
+
const merged = { sha: event.sha, at: await ctx.now(), deploy, ...(verified && { verified }) };
|
|
180
|
+
await show({ phase: "merged", merged });
|
|
181
|
+
report = {
|
|
182
|
+
outcome: "merged",
|
|
183
|
+
head: opened.head,
|
|
184
|
+
pullRequest,
|
|
185
|
+
sha: merged.sha,
|
|
186
|
+
deploy,
|
|
187
|
+
...(verified && { verified }),
|
|
188
|
+
};
|
|
189
|
+
} else if (event.kind !== "commits") {
|
|
190
|
+
if (event.kind === "close") await ctx.sanoma.change.close({ id: input.id, number: opened.number });
|
|
191
|
+
await show({ phase: "closed", ...(event.kind === "close" && { closedBy: event.by }) });
|
|
192
|
+
report = { outcome: "closed", head: opened.head, pullRequest };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return report;
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
package/src/change.ts
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { ApprovalState, Approver } from "./define.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { errorInfo } from "./errors.ts";
|
|
4
|
+
import { type CallContext, defineConnector, defineDriver, type Driver, DriverError } from "./op.ts";
|
|
4
5
|
import { approve, approvedFor, type Decision, deny } from "./policy.ts";
|
|
5
6
|
import { isChangeOp, type Resource } from "./resource.ts";
|
|
6
|
-
import {
|
|
7
|
-
import type {
|
|
7
|
+
import { currentRun } from "./run.ts";
|
|
8
|
+
import type { Secrets } from "./secret.ts";
|
|
9
|
+
import {
|
|
10
|
+
APPLY_WORKFLOW,
|
|
11
|
+
CHANGE_MERGE,
|
|
12
|
+
CHANGE_WORKFLOW,
|
|
13
|
+
DRAFT_ID,
|
|
14
|
+
errorMessage,
|
|
15
|
+
groupNamesOf,
|
|
16
|
+
type PeopleResource,
|
|
17
|
+
} from "./shared.ts";
|
|
18
|
+
import {
|
|
19
|
+
type CheckResult,
|
|
20
|
+
type RemoteContext,
|
|
21
|
+
type RemoteHalf,
|
|
22
|
+
remoteFor,
|
|
23
|
+
type RepoRef,
|
|
24
|
+
type Source,
|
|
25
|
+
type SourceHost,
|
|
26
|
+
} from "./source.ts";
|
|
8
27
|
|
|
9
28
|
// A change: how a draft of the company's repository goes live, as the built-in `change` workflow
|
|
10
|
-
// runs it (
|
|
11
|
-
//
|
|
12
|
-
// a change to the rules waits for `admin`,
|
|
29
|
+
// runs it (`change-workflow.ts`): its four operations on the built-in connector `sanoma` and their
|
|
30
|
+
// driver over the repository's host, the facts a policy decides its merge on (the attributes of
|
|
31
|
+
// its `Sanoma::Change`), and the runtime's own rule that a change to the rules waits for `admin`,
|
|
32
|
+
// before any company policy is asked.
|
|
13
33
|
|
|
14
34
|
/**
|
|
15
35
|
* What the planner finds in a change, before the policy is asked: the attributes of the
|
|
@@ -24,14 +44,14 @@ export const ChangeFacts = z.object({
|
|
|
24
44
|
stranded: z.array(z.string()),
|
|
25
45
|
/** Whether `sanoma check` passed on the change. */
|
|
26
46
|
checksOk: z.boolean(),
|
|
27
|
-
/** Whether the change edits the rules: a policy or a people file, which wait for `admin` (`runtimeHold`). */
|
|
47
|
+
/** Whether the change edits the rules: a policy (the root's or an app's) or a people file, which wait for `admin` (`runtimeHold`). */
|
|
28
48
|
rules: z.boolean(),
|
|
29
49
|
});
|
|
30
50
|
export type ChangeFacts = z.infer<typeof ChangeFacts>;
|
|
31
51
|
|
|
32
52
|
/** What opening a change takes: its draft's id, and the pull request's title, body and the change page's link. */
|
|
33
53
|
export const ChangeInput = z.object({
|
|
34
|
-
id: z.string().regex(
|
|
54
|
+
id: z.string().regex(DRAFT_ID),
|
|
35
55
|
title: z.string().min(1),
|
|
36
56
|
body: z.string().optional(),
|
|
37
57
|
page: z.string().url().optional(),
|
|
@@ -114,13 +134,169 @@ export function reservedFor(
|
|
|
114
134
|
return undefined;
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
};
|
|
137
|
+
/** The branch a change merges into. */
|
|
138
|
+
export const BASE = "main";
|
|
120
139
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
140
|
+
/** The company's credentials for a host, as its remote half asks for them (`Secrets` puts the environment first). */
|
|
141
|
+
const remoteContext = (secrets: Secrets): RemoteContext => ({
|
|
142
|
+
async secrets(names) {
|
|
143
|
+
const found = await Promise.all(names.map(async (name) => [name, await secrets.get(name)] as const));
|
|
144
|
+
return new Map(found.filter((entry): entry is readonly [string, string] => Boolean(entry[1])));
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** The worker's repository, read from the run; a `DriverError` outside one, or for a worker given none. */
|
|
149
|
+
export function sourceOfRun(): Source {
|
|
150
|
+
const source = currentRun.getStore()?.state.source;
|
|
151
|
+
if (!source) {
|
|
152
|
+
throw new DriverError("This worker was given no repository (`source`), so it cannot change one", {
|
|
153
|
+
retryable: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return source;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Each host opened, by its remote half, the secrets it reads and the clone URL: its token is minted once and kept while it holds. */
|
|
160
|
+
const openedHosts = new WeakMap<RemoteHalf, WeakMap<Secrets, Map<string, SourceHost>>>();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The company's repository and its host: `source`, by default the worker's for a call made in a
|
|
164
|
+
* run (a change's driver and its steps, `sourceOfRun`), the clone URL of its `origin`, and that
|
|
165
|
+
* repository (`repo`) on the host whose connector's remote half matches the URL, opened once per
|
|
166
|
+
* remote half, secrets and URL, so its token is kept between calls. A `DriverError` when the
|
|
167
|
+
* repository has no remote (`no_remote`: saving is publishing then, there is nothing to review a
|
|
168
|
+
* branch on) or no connector is for its host (`no_remote_half`).
|
|
169
|
+
*/
|
|
170
|
+
export async function repositoryOf(
|
|
171
|
+
remotes: ReadonlyMap<string, RemoteHalf>,
|
|
172
|
+
secrets: Secrets,
|
|
173
|
+
source: Source = sourceOfRun(),
|
|
174
|
+
): Promise<{ source: Source; url: string; repo: RepoRef; host: SourceHost }> {
|
|
175
|
+
const url = await source.remoteUrl?.();
|
|
176
|
+
if (!url) {
|
|
177
|
+
throw new DriverError("saving is publishing: this repository has no remote", {
|
|
178
|
+
retryable: false,
|
|
179
|
+
vendorCode: "no_remote",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const found = remoteFor(remotes, url);
|
|
183
|
+
if (!found) {
|
|
184
|
+
throw new DriverError(`No connector's remote half is for ${url}: add the connector of its host`, {
|
|
185
|
+
retryable: false,
|
|
186
|
+
vendorCode: "no_remote_half",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const bySecrets = openedHosts.get(found.remote) ?? new WeakMap<Secrets, Map<string, SourceHost>>();
|
|
190
|
+
openedHosts.set(found.remote, bySecrets);
|
|
191
|
+
const byUrl = bySecrets.get(secrets) ?? new Map<string, SourceHost>();
|
|
192
|
+
bySecrets.set(secrets, byUrl);
|
|
193
|
+
const host = byUrl.get(url) ?? found.remote.open(found.repo, remoteContext(secrets));
|
|
194
|
+
byUrl.set(url, host);
|
|
195
|
+
return { source, url, repo: found.repo, host };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fast-forwards the checkout to the host's `main` (`Source.follow`), as a deploy does first so the
|
|
200
|
+
* hosts and what apply reads are main's: the revision after, why it did not, or the error, never
|
|
201
|
+
* thrown (what is merged is merged; a deploy that cannot follow still applies).
|
|
202
|
+
*/
|
|
203
|
+
export async function followBase(
|
|
204
|
+
remotes: ReadonlyMap<string, RemoteHalf>,
|
|
205
|
+
secrets: Secrets,
|
|
206
|
+
source?: Source,
|
|
207
|
+
): Promise<{ revision: string } | { skipped: string } | { error: string }> {
|
|
208
|
+
try {
|
|
209
|
+
const { source: at, url, host } = await repositoryOf(remotes, secrets, source);
|
|
210
|
+
if (!at.follow) return { skipped: "this repository cannot follow its remote" };
|
|
211
|
+
const moved = await at.follow({ url, token: await host.token(), ref: BASE });
|
|
212
|
+
return "revision" in moved ? { revision: moved.revision } : moved;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { error: errorMessage(err) };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The driver of a change's operations, over the repository the worker was given (`source`, read
|
|
220
|
+
* from the run) and its host (`remotes`, the connectors' remote halves: `remoteFor` its
|
|
221
|
+
* `origin`, opened once and kept: `repositoryOf`).
|
|
222
|
+
*
|
|
223
|
+
* - `open` pushes the draft's branch and opens its pull request into `main` (the open one when it
|
|
224
|
+
* has one): safe to retry. Its `head` is the branch's commit as pushed.
|
|
225
|
+
* - `review` sets the `sanoma` status on the head, linking to the change page: `pending`, or
|
|
226
|
+
* `failure`; never `success`, which only a merge sets.
|
|
227
|
+
* - `merge` reads the pull request: merged already (a retry whose answer was lost) or closed are
|
|
228
|
+
* answered as they are; a branch at another commit than `head`, on the host or in the draft, is
|
|
229
|
+
* `moved`. Else it sets the status `success` and squash-merges at `head`: the host's `head_moved`
|
|
230
|
+
* is `moved`, `not_mergeable` is `blocked` with the host's reason. The branch is then deleted,
|
|
231
|
+
* as far as it can be.
|
|
232
|
+
* - `close` closes the pull request.
|
|
233
|
+
*/
|
|
234
|
+
export function changeDriver(remotes: ReadonlyMap<string, RemoteHalf>): Driver {
|
|
235
|
+
const open = (call: CallContext) => repositoryOf(remotes, call.secrets);
|
|
236
|
+
return defineDriver(changeOps, {
|
|
237
|
+
change: {
|
|
238
|
+
async open({ id, title, body }, call) {
|
|
239
|
+
const { source, url, host } = await open(call);
|
|
240
|
+
if (!source.draft || !source.drafts || !source.push) {
|
|
241
|
+
throw new DriverError("This repository keeps no drafts, so it has no change to open", { retryable: false });
|
|
242
|
+
}
|
|
243
|
+
if (!(await source.drafts()).includes(id)) {
|
|
244
|
+
throw new DriverError(`There is no open draft "${id}" to send for review`, {
|
|
245
|
+
retryable: false,
|
|
246
|
+
vendorCode: "no_draft",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const { branch } = await source.draft(id);
|
|
250
|
+
// The head is read before the push: a commit made in the draft meanwhile is the next round's.
|
|
251
|
+
const head = await source.head(branch);
|
|
252
|
+
await source.push({ url, token: await host.token(), ref: branch });
|
|
253
|
+
const pr = await host.openPullRequest({ branch, base: BASE, title, body: body ?? "" });
|
|
254
|
+
return { number: pr.number, url: pr.url, head, branch };
|
|
255
|
+
},
|
|
256
|
+
async review({ head, state, description, page }, call) {
|
|
257
|
+
const { host } = await open(call);
|
|
258
|
+
await host.setStatus({ sha: head, state, description, ...(page !== undefined && { targetUrl: page }) });
|
|
259
|
+
return {};
|
|
260
|
+
},
|
|
261
|
+
async merge({ number, head, title }, call) {
|
|
262
|
+
const { source, host } = await open(call);
|
|
263
|
+
const pr = await host.pullRequest(number);
|
|
264
|
+
if (pr.state === "merged")
|
|
265
|
+
return { outcome: "merged" as const, ...(pr.mergeSha !== undefined && { sha: pr.mergeSha }) };
|
|
266
|
+
if (pr.state === "closed") return { outcome: "closed" as const };
|
|
267
|
+
// Commits the run has not planned: pushed to the host, or made in the draft and not pushed yet.
|
|
268
|
+
const local = await Promise.resolve(source.head(pr.branch)).catch(() => undefined);
|
|
269
|
+
if (pr.head !== head || (local !== undefined && local !== head)) return { outcome: "moved" as const };
|
|
270
|
+
await host.setStatus({ sha: head, state: "success", description: "Published through Sanoma" });
|
|
271
|
+
let sha: string;
|
|
272
|
+
try {
|
|
273
|
+
({ sha } = await host.mergePullRequest(number, {
|
|
274
|
+
sha: head,
|
|
275
|
+
method: "squash",
|
|
276
|
+
title: `${title} (#${number})`,
|
|
277
|
+
}));
|
|
278
|
+
} catch (err) {
|
|
279
|
+
// Not merged: Sanoma's status on the head goes back to failing, so the host does not merge it either.
|
|
280
|
+
const reason = errorMessage(err);
|
|
281
|
+
await host
|
|
282
|
+
.setStatus({ sha: head, state: "failure", description: `Not published: ${reason}`.slice(0, 140) })
|
|
283
|
+
.catch(() => undefined);
|
|
284
|
+
const { vendorCode: code } = errorInfo(err);
|
|
285
|
+
if (code === "head_moved") return { outcome: "moved" as const };
|
|
286
|
+
if (code === "not_mergeable") return { outcome: "blocked" as const, reason };
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
// Best effort: a branch left behind holds nothing the merge did not take.
|
|
290
|
+
await host.deleteBranch?.(pr.branch).catch(() => undefined);
|
|
291
|
+
return { outcome: "merged" as const, sha };
|
|
292
|
+
},
|
|
293
|
+
async close({ number }, call) {
|
|
294
|
+
const { host } = await open(call);
|
|
295
|
+
await host.closePullRequest(number);
|
|
296
|
+
return { closed: true as const };
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
});
|
|
124
300
|
}
|
|
125
301
|
|
|
126
302
|
/**
|
|
@@ -137,15 +313,20 @@ export function touchesOf(files: readonly string[]): string[] {
|
|
|
137
313
|
}
|
|
138
314
|
|
|
139
315
|
/** The group the runtime holds a change to the rules for. */
|
|
140
|
-
const ADMIN = "admin";
|
|
141
|
-
/**
|
|
142
|
-
|
|
316
|
+
export const ADMIN = "admin";
|
|
317
|
+
/**
|
|
318
|
+
* Whether a path in `touches` is the rules whatever `rules` says: the policies, the root's or an
|
|
319
|
+
* app's (`apps/crm/policies/`), and the template's people file.
|
|
320
|
+
*/
|
|
321
|
+
export const isRules = (path: string) =>
|
|
322
|
+
path === "policies/" || path === "resources/people.ts" || /^apps\/[^/]+\/policies\/$/.test(path);
|
|
143
323
|
/** The runtime rule's id, as a hold's `reasons` name it. */
|
|
144
324
|
const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
|
|
145
325
|
|
|
146
326
|
/**
|
|
147
327
|
* The runtime's own rule, asked before any company policy and under `allowAll` too: a merge of a
|
|
148
|
-
* change to the rules (`facts.rules`, or `touches` naming `policies
|
|
328
|
+
* change to the rules (`facts.rules`, or `touches` naming `policies/`, an app's `policies/` or
|
|
329
|
+
* `resources/people.ts`) waits for `admin`, whoever made it, until an approval
|
|
149
330
|
* addressed to `admin` covering the merge is approved; denied while no data file declares the
|
|
150
331
|
* group. Undefined for anything else, which the company's policy alone decides. `people` are the
|
|
151
332
|
* running instance's, never the change's, so a change cannot add its author to `admin`.
|
|
@@ -158,7 +339,7 @@ export function runtimeHold(
|
|
|
158
339
|
): Decision | undefined {
|
|
159
340
|
const facts = opId === CHANGE_MERGE ? (input as { facts?: ChangeFacts }).facts : undefined;
|
|
160
341
|
// The planner's `rules`, or what `touches` already says: a fact left false cannot skip the rule.
|
|
161
|
-
if (!facts || !(facts.rules ||
|
|
342
|
+
if (!facts || !(facts.rules || facts.touches.some(isRules))) return undefined;
|
|
162
343
|
if (!groupNamesOf(people).includes(ADMIN)) {
|
|
163
344
|
return deny(
|
|
164
345
|
`A change to the policies or the people waits for the group "${ADMIN}", which no data file declares: ` +
|
|
@@ -171,12 +352,44 @@ export function runtimeHold(
|
|
|
171
352
|
return approve(approver, { title: "A change to the rules waits for admin", reasons: [RULES_WAIT_FOR_ADMIN] });
|
|
172
353
|
}
|
|
173
354
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
355
|
+
/**
|
|
356
|
+
* What a change run is told (on `CHANGE_TOPIC`): new commits on its branch, a close someone asked
|
|
357
|
+
* for, or its pull request closed or merged on the host.
|
|
358
|
+
*/
|
|
359
|
+
export const ChangeEventSchema = z.discriminatedUnion("kind", [
|
|
360
|
+
z.object({ kind: z.literal("commits") }),
|
|
361
|
+
z.object({ kind: z.literal("close"), by: z.string().min(1) }),
|
|
362
|
+
z.object({ kind: z.literal("closed") }),
|
|
363
|
+
z.object({ kind: z.literal("merged"), sha: z.string().min(1) }),
|
|
364
|
+
]);
|
|
365
|
+
export type ChangeEvent = z.infer<typeof ChangeEventSchema>;
|
|
366
|
+
|
|
367
|
+
/** What a merge is asked with: the change, its pull request at the head planned, and the facts. */
|
|
368
|
+
export type MergeInput = z.input<typeof changeOps.change.merge.input>;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The policy's answer to a change's merge, asked ahead (`ChangeState.decision`): `allow`, `hold`
|
|
372
|
+
* for the approver and title the policy gave, or `deny` with its reason. An allowed or held change
|
|
373
|
+
* has an `approver`, whose approval is Publish: the hold's, else whoever drafted it.
|
|
374
|
+
*/
|
|
375
|
+
export type ChangeDecision = NonNullable<ChangeState["decision"]>;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What became of the deploy after a merge: the checkout fast-forwarded to `main` (`follow`: the
|
|
379
|
+
* revision, why not, or its error), then the apply run on the declared hosts, when any.
|
|
380
|
+
*/
|
|
381
|
+
export interface ChangeDeploy {
|
|
382
|
+
follow: { revision: string } | { skipped: string } | { error: string };
|
|
383
|
+
/** The apply run, restricted to the declared hosts' ids (`<file>#<export>`), and its error when it failed. */
|
|
384
|
+
apply?: { runId: string; hosts: string[]; error?: string };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The drift run that read the hosts back after their apply, and the hosts it found not as declared. */
|
|
388
|
+
export interface ChangeVerified {
|
|
389
|
+
runId: string;
|
|
390
|
+
drifted: string[];
|
|
391
|
+
error?: string;
|
|
392
|
+
}
|
|
180
393
|
|
|
181
394
|
/** Where a change is, as its run publishes it (`CHANGE_EVENT`) for the app. */
|
|
182
395
|
export interface ChangeState {
|
|
@@ -208,15 +421,32 @@ export interface ChangeState {
|
|
|
208
421
|
};
|
|
209
422
|
/** The review card on the host: unavailable in this version. */
|
|
210
423
|
card: { status: "unavailable"; reason: string };
|
|
211
|
-
merged?: { sha: string; at: number; deploy?:
|
|
424
|
+
merged?: { sha: string; at: number; deploy?: ChangeDeploy; verified?: ChangeVerified };
|
|
425
|
+
/** Why the change cannot merge now: the policy denies it, or the host refuses the merge. */
|
|
212
426
|
blocked?: string;
|
|
427
|
+
/** Who closed it, when someone asked (a `close` event, or a rejection of the Publish approval). */
|
|
428
|
+
closedBy?: string;
|
|
213
429
|
}
|
|
214
430
|
|
|
431
|
+
/** The review card on the host, which this version does not write. */
|
|
432
|
+
export const NO_CARD = { status: "unavailable", reason: "This version writes no review card on the host" } as const;
|
|
433
|
+
|
|
434
|
+
/** Where a change is before its run has opened it. */
|
|
435
|
+
export const initialChangeState = (input: ChangeInput): ChangeState => ({
|
|
436
|
+
id: input.id,
|
|
437
|
+
branch: "",
|
|
438
|
+
title: input.title,
|
|
439
|
+
...(input.page !== undefined && { page: input.page }),
|
|
440
|
+
phase: "opening",
|
|
441
|
+
card: NO_CARD,
|
|
442
|
+
});
|
|
443
|
+
|
|
215
444
|
/** How a change run ended. */
|
|
216
445
|
export interface ChangeReport {
|
|
217
446
|
outcome: "merged" | "closed";
|
|
218
447
|
head: string;
|
|
219
448
|
pullRequest: { number: number; url: string };
|
|
220
449
|
sha?: string;
|
|
221
|
-
deploy?:
|
|
450
|
+
deploy?: ChangeDeploy;
|
|
451
|
+
verified?: ChangeVerified;
|
|
222
452
|
}
|
package/src/claims.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Sanoma's claims: what Sanoma says about connectors and apps that their authors cannot say about
|
|
2
|
+
// their own (the tier, whether Sanoma reviewed it, what it blocks), and the solutions, each a kind
|
|
3
|
+
// of customer with the apps that cover their problems and one demo repository. Data, changed by
|
|
4
|
+
// pull request; `sanoma/claims` re-exports it, so a company and its sites import it statically.
|
|
5
|
+
// Browser-safe: nothing here imports anything. Everything else about a connector or an app is
|
|
6
|
+
// its author's, read from npm or GitHub (`sanoma.apps.list`), never copied here.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whose a connector or an app is: Sanoma's own, its vendor's (checked by Sanoma), or anyone's. With
|
|
10
|
+
* no claim, `official` is read from the name (the `@sanoma-ai/` scope, the `Sanoma-AI` owner).
|
|
11
|
+
*/
|
|
12
|
+
export const TIERS = ["official", "vendor-verified", "community"] as const;
|
|
13
|
+
export type Tier = (typeof TIERS)[number];
|
|
14
|
+
|
|
15
|
+
/** What Sanoma says of a connector package, by its npm name. */
|
|
16
|
+
export interface ConnectorClaim {
|
|
17
|
+
tier: Tier;
|
|
18
|
+
/** Whether Sanoma read its code. */
|
|
19
|
+
reviewed: boolean;
|
|
20
|
+
/** Versions Sanoma blocks. */
|
|
21
|
+
blocked?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What Sanoma says of an app's source repository, by `<owner>/<repo>` on GitHub. */
|
|
25
|
+
export interface AppClaim {
|
|
26
|
+
tier: Tier;
|
|
27
|
+
/** Whether Sanoma read its code. */
|
|
28
|
+
reviewed: boolean;
|
|
29
|
+
/** Commits Sanoma blocks, by their full id. */
|
|
30
|
+
blocked?: string[];
|
|
31
|
+
/** The solutions it belongs to, by id. */
|
|
32
|
+
solutions: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A solution: a kind of customer and the apps that cover their problems, with the one public demo
|
|
37
|
+
* repository (a fictional business of that kind, those apps merged under `apps/`) that a visitor
|
|
38
|
+
* meets it in.
|
|
39
|
+
*/
|
|
40
|
+
export interface SolutionClaim {
|
|
41
|
+
/** The solution's name, as its page heads it. */
|
|
42
|
+
title: string;
|
|
43
|
+
/** The kind of customer it is for, in their words: "property managers". */
|
|
44
|
+
kind: string;
|
|
45
|
+
/** Its apps' source repositories, `<owner>/<repo>`. */
|
|
46
|
+
apps: string[];
|
|
47
|
+
/** Its demo: the company repository, `<owner>/<repo>`, and where it runs once it does. */
|
|
48
|
+
demo: { repo: string; url?: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Sanoma's claims, each keyed as its kind is named: a package, a repository, a solution's id. */
|
|
52
|
+
export interface Claims {
|
|
53
|
+
connectors: Readonly<Record<string, ConnectorClaim>>;
|
|
54
|
+
apps: Readonly<Record<string, AppClaim>>;
|
|
55
|
+
solutions: Readonly<Record<string, SolutionClaim>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sanoma's claims. Empty until the first outside connector, the first app and the first solution exist. */
|
|
59
|
+
export const claims: Claims = {
|
|
60
|
+
connectors: {},
|
|
61
|
+
apps: {},
|
|
62
|
+
solutions: {},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A connector package's tier: its claim's, else official for the `@sanoma-ai/` scope and community otherwise. */
|
|
66
|
+
export const connectorTier = (pkg: string, from: Claims = claims): Tier =>
|
|
67
|
+
from.connectors[pkg]?.tier ?? (pkg.startsWith("@sanoma-ai/") ? "official" : "community");
|
|
68
|
+
|
|
69
|
+
/** The claim on the app whose source is `repo` (`<owner>/<repo>`, as GitHub names it, in any case). */
|
|
70
|
+
export const appClaim = (repo: string, from: Claims = claims): AppClaim | undefined =>
|
|
71
|
+
Object.entries(from.apps).find(([key]) => key.toLowerCase() === repo.toLowerCase())?.[1];
|
|
72
|
+
|
|
73
|
+
/** The GitHub owner whose repositories are Sanoma's own. */
|
|
74
|
+
export const SANOMA_ORG = "Sanoma-AI";
|
|
75
|
+
|
|
76
|
+
/** An app source's tier: its claim's, else official for the `Sanoma-AI` owner's and community otherwise. */
|
|
77
|
+
export const appTier = (repo: string, from: Claims = claims): Tier =>
|
|
78
|
+
appClaim(repo, from)?.tier ??
|
|
79
|
+
(repo.toLowerCase().startsWith(`${SANOMA_ORG.toLowerCase()}/`) ? "official" : "community");
|