@sanoma-ai/workflows 0.7.1 → 0.8.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/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 +25 -2
- package/dist/config.js +36 -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/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 +60 -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/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,161 @@
|
|
|
1
|
+
import { ADMIN, ChangeInput, initialChangeState } from "./change.js";
|
|
2
|
+
import { defineWorkflow } from "./define.js";
|
|
3
|
+
import { errorCode, isRunControlError } from "./errors.js";
|
|
4
|
+
import { approverLabel, errorMessage } from "./shared.js";
|
|
5
|
+
/** The approval Publish is: the Inbox's title for it. */
|
|
6
|
+
const PUBLISH = "Publish the change";
|
|
7
|
+
/** The `sanoma` status on the host: pending while it can merge, failure while checks fail or the policy denies; and what it waits for. */
|
|
8
|
+
function reviewOf(decision, facts) {
|
|
9
|
+
if (decision.answer === "deny") {
|
|
10
|
+
return { state: "failure", description: `Denied: ${decision.reason ?? "the policy refuses it"}` };
|
|
11
|
+
}
|
|
12
|
+
const waits = decision.title ?? `Waits for ${approverLabel(decision.approver ?? { group: ADMIN })} to publish it`;
|
|
13
|
+
return facts.checksOk
|
|
14
|
+
? { state: "pending", description: waits }
|
|
15
|
+
: { state: "failure", description: `sanoma check fails. ${waits}` };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The change workflow over `ops` (`changeOps`, the built-in connector's) and `steps`:
|
|
19
|
+
* `resolveConfig` adds it to every config. It `uses` the four operations and `approval`.
|
|
20
|
+
*
|
|
21
|
+
* A run is the life of one change (run id `change:<draft>`): each round opens the draft's branch
|
|
22
|
+
* as a pull request (`open`, which pushes it), checks it (`steps.check`), plans its facts
|
|
23
|
+
* (`steps.plan`), asks the policy ahead (`steps.ahead`), and reports on the host (`review`). While
|
|
24
|
+
* the policy denies it, the run waits for what happens next (`steps.next`). Otherwise it asks one
|
|
25
|
+
* approval, Publish, of the hold's approver (else whoever drafted it), covering the merge, with the
|
|
26
|
+
* change and its facts as its details; once approved, it merges (`merge`). An event that comes in
|
|
27
|
+
* meanwhile (`SanomaClient.changeEvent`) withdraws the approval, and the run reads it:
|
|
28
|
+
* `commits` plans another round, after withdrawing every approval approved for the merge
|
|
29
|
+
* (`steps.retire`), so none lifts a hold on the new head; `close` closes the pull request; the
|
|
30
|
+
* pull request `closed` or `merged` on the host ends the change as it is. A rejected Publish
|
|
31
|
+
* closes it too. A merge whose branch moved plans another round; one the host refuses waits.
|
|
32
|
+
*
|
|
33
|
+
* Once merged, the deploy (`steps.deploy`): the checkout follows `main`, then apply on the
|
|
34
|
+
* declared hosts, as a run this one waits for, then drift on them (`steps.verify`). A `merged` or
|
|
35
|
+
* `closed` event someone told is checked on the host as it is read (`steps.next`, `steps.queued`); one it does not bear
|
|
36
|
+
* out plans another round. A Publish withdrawn with no event waits, blocked, for the next one.
|
|
37
|
+
* Then the draft is closed. Each phase is published (`steps.publish`, `CHANGE_EVENT`); the run's
|
|
38
|
+
* output is its `ChangeReport`.
|
|
39
|
+
*/
|
|
40
|
+
export function changeWorkflow(ops, steps) {
|
|
41
|
+
return defineWorkflow({
|
|
42
|
+
// The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
|
|
43
|
+
name: "change",
|
|
44
|
+
title: "Publish a change",
|
|
45
|
+
trigger: "manual",
|
|
46
|
+
input: ChangeInput,
|
|
47
|
+
uses: [ops.change.open, ops.change.review, ops.change.merge, ops.change.close, "approval"],
|
|
48
|
+
run: async (ctx, input) => {
|
|
49
|
+
const state = initialChangeState(input);
|
|
50
|
+
/** Moves the change on, and publishes where it is. */
|
|
51
|
+
const show = (patch) => {
|
|
52
|
+
Object.assign(state, patch);
|
|
53
|
+
return steps.publish(state);
|
|
54
|
+
};
|
|
55
|
+
let report;
|
|
56
|
+
while (report === undefined) {
|
|
57
|
+
// An approval is given for a head: one approved for an earlier round lifts no hold on this one.
|
|
58
|
+
await steps.retire("the change was planned again at a new head");
|
|
59
|
+
await show({ phase: "opening", decision: undefined, blocked: undefined });
|
|
60
|
+
const opened = await ctx.sanoma.change.open(input);
|
|
61
|
+
const pullRequest = { number: opened.number, url: opened.url };
|
|
62
|
+
await show({ branch: opened.branch, pullRequest, head: opened.head, phase: "checking" });
|
|
63
|
+
const check = await steps.check(input.id);
|
|
64
|
+
await show({ check, phase: "planning" });
|
|
65
|
+
const facts = await steps.plan(input.id, check);
|
|
66
|
+
const merge = { id: input.id, number: opened.number, head: opened.head, title: input.title, facts };
|
|
67
|
+
const decision = await steps.ahead(merge);
|
|
68
|
+
await show({ facts, decision, phase: "reviewing" });
|
|
69
|
+
await ctx.sanoma.change.review({
|
|
70
|
+
id: input.id,
|
|
71
|
+
number: opened.number,
|
|
72
|
+
head: opened.head,
|
|
73
|
+
...reviewOf(decision, facts),
|
|
74
|
+
...(input.page !== undefined && { page: input.page }),
|
|
75
|
+
});
|
|
76
|
+
// What came in while the round was planned decides it before any approval is asked.
|
|
77
|
+
let told = await steps.queued(opened.number);
|
|
78
|
+
let event;
|
|
79
|
+
let blocked = told === undefined && decision.answer === "deny" ? decision.reason : undefined;
|
|
80
|
+
if (told === undefined && blocked === undefined) {
|
|
81
|
+
try {
|
|
82
|
+
await show({ phase: "waiting" });
|
|
83
|
+
await ctx.approval(PUBLISH, {
|
|
84
|
+
approver: decision.approver,
|
|
85
|
+
covers: [ops.change.merge],
|
|
86
|
+
details: JSON.stringify({ change: { id: input.id, title: input.title, branch: opened.branch }, facts }),
|
|
87
|
+
links: [opened.url, ...(input.page === undefined ? [] : [input.page])],
|
|
88
|
+
});
|
|
89
|
+
// An event told while Publish was being asked found no approval to withdraw: it is read before the merge.
|
|
90
|
+
told = await steps.queued(opened.number);
|
|
91
|
+
if (told === undefined) {
|
|
92
|
+
await show({ phase: "merging" });
|
|
93
|
+
let merged;
|
|
94
|
+
try {
|
|
95
|
+
merged = await ctx.sanoma.change.merge(merge);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// Approved, but the policy or the host refuses the merge now: the change waits, as for a refusal.
|
|
99
|
+
// A hold the policy puts on the merge itself is decided as Publish is, below.
|
|
100
|
+
if (isRunControlError(err) || errorCode(err)?.startsWith("approval_"))
|
|
101
|
+
throw err;
|
|
102
|
+
merged = { outcome: "blocked", reason: errorMessage(err) };
|
|
103
|
+
}
|
|
104
|
+
if (merged.outcome === "merged")
|
|
105
|
+
event = { kind: "merged", sha: merged.sha ?? opened.head };
|
|
106
|
+
else if (merged.outcome === "closed")
|
|
107
|
+
event = { kind: "closed" };
|
|
108
|
+
else if (merged.outcome === "moved")
|
|
109
|
+
event = { kind: "commits" };
|
|
110
|
+
else
|
|
111
|
+
blocked = merged.reason ?? "the host refuses the merge";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// A withdrawal comes with an event, else Publish was taken back. A rejection of Publish closes the change.
|
|
116
|
+
const code = errorCode(err);
|
|
117
|
+
if (code === "approval_withdrawn") {
|
|
118
|
+
told = await steps.queued(opened.number);
|
|
119
|
+
if (told === undefined)
|
|
120
|
+
blocked = "Publish was withdrawn";
|
|
121
|
+
}
|
|
122
|
+
else if (code === "approval_rejected") {
|
|
123
|
+
event = { kind: "close", by: err.data.by.id };
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (blocked !== undefined) {
|
|
130
|
+
await show({ phase: "blocked", blocked });
|
|
131
|
+
told = await steps.next(opened.number);
|
|
132
|
+
}
|
|
133
|
+
event ??= told;
|
|
134
|
+
if (event.kind === "merged") {
|
|
135
|
+
await show({ phase: "deploying" });
|
|
136
|
+
const deploy = await steps.deploy(event.sha);
|
|
137
|
+
const verified = await steps.verify(deploy);
|
|
138
|
+
await steps.closeDraft(input.id);
|
|
139
|
+
const merged = { sha: event.sha, at: await ctx.now(), deploy, ...(verified && { verified }) };
|
|
140
|
+
await show({ phase: "merged", merged });
|
|
141
|
+
report = {
|
|
142
|
+
outcome: "merged",
|
|
143
|
+
head: opened.head,
|
|
144
|
+
pullRequest,
|
|
145
|
+
sha: merged.sha,
|
|
146
|
+
deploy,
|
|
147
|
+
...(verified && { verified }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
else if (event.kind !== "commits") {
|
|
151
|
+
if (event.kind === "close")
|
|
152
|
+
await ctx.sanoma.change.close({ id: input.id, number: opened.number });
|
|
153
|
+
await show({ phase: "closed", ...(event.kind === "close" && { closedBy: event.by }) });
|
|
154
|
+
report = { outcome: "closed", head: opened.head, pullRequest };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return report;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=change-workflow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"change-workflow.js","sourceRoot":"","sources":["../src/change-workflow.ts"],"names":[],"mappings":"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"}
|
package/dist/change.d.ts
CHANGED
|
@@ -3,8 +3,9 @@ import type { ApprovalState, Approver } from "./define.ts";
|
|
|
3
3
|
import { type Driver } from "./op.ts";
|
|
4
4
|
import { type Decision } from "./policy.ts";
|
|
5
5
|
import { type Resource } from "./resource.ts";
|
|
6
|
+
import type { Secrets } from "./secret.ts";
|
|
6
7
|
import { type PeopleResource } from "./shared.ts";
|
|
7
|
-
import type
|
|
8
|
+
import { type CheckResult, type RemoteHalf, type RepoRef, type Source, type SourceHost } from "./source.ts";
|
|
8
9
|
/**
|
|
9
10
|
* What the planner finds in a change, before the policy is asked: the attributes of the
|
|
10
11
|
* `Sanoma::Change` that `sanoma.change.merge` acts on, so a rule reads `resource.touches`.
|
|
@@ -139,33 +140,123 @@ export declare function reservedFor(op: {
|
|
|
139
140
|
does: string;
|
|
140
141
|
instead: string;
|
|
141
142
|
} | undefined;
|
|
142
|
-
/**
|
|
143
|
-
export declare
|
|
143
|
+
/** The branch a change merges into. */
|
|
144
|
+
export declare const BASE = "main";
|
|
145
|
+
/** The worker's repository, read from the run; a `DriverError` outside one, or for a worker given none. */
|
|
146
|
+
export declare function sourceOfRun(): Source;
|
|
147
|
+
/**
|
|
148
|
+
* The company's repository and its host: `source`, by default the worker's for a call made in a
|
|
149
|
+
* run (a change's driver and its steps, `sourceOfRun`), the clone URL of its `origin`, and that
|
|
150
|
+
* repository (`repo`) on the host whose connector's remote half matches the URL, opened once per
|
|
151
|
+
* remote half, secrets and URL, so its token is kept between calls. A `DriverError` when the
|
|
152
|
+
* repository has no remote (`no_remote`: saving is publishing then, there is nothing to review a
|
|
153
|
+
* branch on) or no connector is for its host (`no_remote_half`).
|
|
154
|
+
*/
|
|
155
|
+
export declare function repositoryOf(remotes: ReadonlyMap<string, RemoteHalf>, secrets: Secrets, source?: Source): Promise<{
|
|
156
|
+
source: Source;
|
|
157
|
+
url: string;
|
|
158
|
+
repo: RepoRef;
|
|
159
|
+
host: SourceHost;
|
|
160
|
+
}>;
|
|
161
|
+
/**
|
|
162
|
+
* Fast-forwards the checkout to the host's `main` (`Source.follow`), as a deploy does first so the
|
|
163
|
+
* hosts and what apply reads are main's: the revision after, why it did not, or the error, never
|
|
164
|
+
* thrown (what is merged is merged; a deploy that cannot follow still applies).
|
|
165
|
+
*/
|
|
166
|
+
export declare function followBase(remotes: ReadonlyMap<string, RemoteHalf>, secrets: Secrets, source?: Source): Promise<{
|
|
167
|
+
revision: string;
|
|
168
|
+
} | {
|
|
169
|
+
skipped: string;
|
|
170
|
+
} | {
|
|
171
|
+
error: string;
|
|
172
|
+
}>;
|
|
173
|
+
/**
|
|
174
|
+
* The driver of a change's operations, over the repository the worker was given (`source`, read
|
|
175
|
+
* from the run) and its host (`remotes`, the connectors' remote halves: `remoteFor` its
|
|
176
|
+
* `origin`, opened once and kept: `repositoryOf`).
|
|
177
|
+
*
|
|
178
|
+
* - `open` pushes the draft's branch and opens its pull request into `main` (the open one when it
|
|
179
|
+
* has one): safe to retry. Its `head` is the branch's commit as pushed.
|
|
180
|
+
* - `review` sets the `sanoma` status on the head, linking to the change page: `pending`, or
|
|
181
|
+
* `failure`; never `success`, which only a merge sets.
|
|
182
|
+
* - `merge` reads the pull request: merged already (a retry whose answer was lost) or closed are
|
|
183
|
+
* answered as they are; a branch at another commit than `head`, on the host or in the draft, is
|
|
184
|
+
* `moved`. Else it sets the status `success` and squash-merges at `head`: the host's `head_moved`
|
|
185
|
+
* is `moved`, `not_mergeable` is `blocked` with the host's reason. The branch is then deleted,
|
|
186
|
+
* as far as it can be.
|
|
187
|
+
* - `close` closes the pull request.
|
|
188
|
+
*/
|
|
189
|
+
export declare function changeDriver(remotes: ReadonlyMap<string, RemoteHalf>): Driver;
|
|
144
190
|
/**
|
|
145
191
|
* Each path, relative to the root with `/` between its parts, and each of its parent directories
|
|
146
192
|
* with a trailing `/`, sorted, each once: what a rule asks with `touches.contains("resources/finance/")`.
|
|
147
193
|
*/
|
|
148
194
|
export declare function touchesOf(files: readonly string[]): string[];
|
|
195
|
+
/** The group the runtime holds a change to the rules for. */
|
|
196
|
+
export declare const ADMIN = "admin";
|
|
197
|
+
/**
|
|
198
|
+
* Whether a path in `touches` is the rules whatever `rules` says: the policies, the root's or an
|
|
199
|
+
* app's (`apps/crm/policies/`), and the template's people file.
|
|
200
|
+
*/
|
|
201
|
+
export declare const isRules: (path: string) => boolean;
|
|
149
202
|
/**
|
|
150
203
|
* The runtime's own rule, asked before any company policy and under `allowAll` too: a merge of a
|
|
151
|
-
* change to the rules (`facts.rules`, or `touches` naming `policies
|
|
204
|
+
* change to the rules (`facts.rules`, or `touches` naming `policies/`, an app's `policies/` or
|
|
205
|
+
* `resources/people.ts`) waits for `admin`, whoever made it, until an approval
|
|
152
206
|
* addressed to `admin` covering the merge is approved; denied while no data file declares the
|
|
153
207
|
* group. Undefined for anything else, which the company's policy alone decides. `people` are the
|
|
154
208
|
* running instance's, never the change's, so a change cannot add its author to `admin`.
|
|
155
209
|
*/
|
|
156
210
|
export declare function runtimeHold(opId: string, input: unknown, approvals: readonly ApprovalState[], people: readonly PeopleResource[]): Decision | undefined;
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
kind: "
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
211
|
+
/**
|
|
212
|
+
* What a change run is told (on `CHANGE_TOPIC`): new commits on its branch, a close someone asked
|
|
213
|
+
* for, or its pull request closed or merged on the host.
|
|
214
|
+
*/
|
|
215
|
+
export declare const ChangeEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
216
|
+
kind: z.ZodLiteral<"commits">;
|
|
217
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
218
|
+
kind: z.ZodLiteral<"close">;
|
|
219
|
+
by: z.ZodString;
|
|
220
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
221
|
+
kind: z.ZodLiteral<"closed">;
|
|
222
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
223
|
+
kind: z.ZodLiteral<"merged">;
|
|
224
|
+
sha: z.ZodString;
|
|
225
|
+
}, z.core.$strip>], "kind">;
|
|
226
|
+
export type ChangeEvent = z.infer<typeof ChangeEventSchema>;
|
|
227
|
+
/** What a merge is asked with: the change, its pull request at the head planned, and the facts. */
|
|
228
|
+
export type MergeInput = z.input<typeof changeOps.change.merge.input>;
|
|
229
|
+
/**
|
|
230
|
+
* The policy's answer to a change's merge, asked ahead (`ChangeState.decision`): `allow`, `hold`
|
|
231
|
+
* for the approver and title the policy gave, or `deny` with its reason. An allowed or held change
|
|
232
|
+
* has an `approver`, whose approval is Publish: the hold's, else whoever drafted it.
|
|
233
|
+
*/
|
|
234
|
+
export type ChangeDecision = NonNullable<ChangeState["decision"]>;
|
|
235
|
+
/**
|
|
236
|
+
* What became of the deploy after a merge: the checkout fast-forwarded to `main` (`follow`: the
|
|
237
|
+
* revision, why not, or its error), then the apply run on the declared hosts, when any.
|
|
238
|
+
*/
|
|
239
|
+
export interface ChangeDeploy {
|
|
240
|
+
follow: {
|
|
241
|
+
revision: string;
|
|
242
|
+
} | {
|
|
243
|
+
skipped: string;
|
|
244
|
+
} | {
|
|
245
|
+
error: string;
|
|
246
|
+
};
|
|
247
|
+
/** The apply run, restricted to the declared hosts' ids (`<file>#<export>`), and its error when it failed. */
|
|
248
|
+
apply?: {
|
|
249
|
+
runId: string;
|
|
250
|
+
hosts: string[];
|
|
251
|
+
error?: string;
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** The drift run that read the hosts back after their apply, and the hosts it found not as declared. */
|
|
255
|
+
export interface ChangeVerified {
|
|
256
|
+
runId: string;
|
|
257
|
+
drifted: string[];
|
|
258
|
+
error?: string;
|
|
259
|
+
}
|
|
169
260
|
/** Where a change is, as its run publishes it (`CHANGE_EVENT`) for the app. */
|
|
170
261
|
export interface ChangeState {
|
|
171
262
|
id: string;
|
|
@@ -195,14 +286,21 @@ export interface ChangeState {
|
|
|
195
286
|
merged?: {
|
|
196
287
|
sha: string;
|
|
197
288
|
at: number;
|
|
198
|
-
deploy?:
|
|
199
|
-
|
|
200
|
-
followed?: string;
|
|
201
|
-
skipped?: string;
|
|
202
|
-
};
|
|
289
|
+
deploy?: ChangeDeploy;
|
|
290
|
+
verified?: ChangeVerified;
|
|
203
291
|
};
|
|
292
|
+
/** Why the change cannot merge now: the policy denies it, or the host refuses the merge. */
|
|
204
293
|
blocked?: string;
|
|
294
|
+
/** Who closed it, when someone asked (a `close` event, or a rejection of the Publish approval). */
|
|
295
|
+
closedBy?: string;
|
|
205
296
|
}
|
|
297
|
+
/** The review card on the host, which this version does not write. */
|
|
298
|
+
export declare const NO_CARD: {
|
|
299
|
+
readonly status: "unavailable";
|
|
300
|
+
readonly reason: "This version writes no review card on the host";
|
|
301
|
+
};
|
|
302
|
+
/** Where a change is before its run has opened it. */
|
|
303
|
+
export declare const initialChangeState: (input: ChangeInput) => ChangeState;
|
|
206
304
|
/** How a change run ended. */
|
|
207
305
|
export interface ChangeReport {
|
|
208
306
|
outcome: "merged" | "closed";
|
|
@@ -212,8 +310,7 @@ export interface ChangeReport {
|
|
|
212
310
|
url: string;
|
|
213
311
|
};
|
|
214
312
|
sha?: string;
|
|
215
|
-
deploy?:
|
|
216
|
-
|
|
217
|
-
};
|
|
313
|
+
deploy?: ChangeDeploy;
|
|
314
|
+
verified?: ChangeVerified;
|
|
218
315
|
}
|
|
219
316
|
export {};
|
package/dist/change.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { errorInfo } from "./errors.js";
|
|
2
3
|
import { defineConnector, defineDriver, DriverError } from "./op.js";
|
|
3
4
|
import { approve, approvedFor, deny } from "./policy.js";
|
|
4
5
|
import { isChangeOp } from "./resource.js";
|
|
5
|
-
import {
|
|
6
|
+
import { currentRun } from "./run.js";
|
|
7
|
+
import { APPLY_WORKFLOW, CHANGE_MERGE, CHANGE_WORKFLOW, DRAFT_ID, errorMessage, groupNamesOf, } from "./shared.js";
|
|
8
|
+
import { remoteFor, } from "./source.js";
|
|
6
9
|
// A change: how a draft of the company's repository goes live, as the built-in `change` workflow
|
|
7
|
-
// runs it (
|
|
8
|
-
//
|
|
9
|
-
// a change to the rules waits for `admin`,
|
|
10
|
+
// runs it (`change-workflow.ts`): its four operations on the built-in connector `sanoma` and their
|
|
11
|
+
// driver over the repository's host, the facts a policy decides its merge on (the attributes of
|
|
12
|
+
// its `Sanoma::Change`), and the runtime's own rule that a change to the rules waits for `admin`,
|
|
13
|
+
// before any company policy is asked.
|
|
10
14
|
/**
|
|
11
15
|
* What the planner finds in a change, before the policy is asked: the attributes of the
|
|
12
16
|
* `Sanoma::Change` that `sanoma.change.merge` acts on, so a rule reads `resource.touches`.
|
|
@@ -20,12 +24,12 @@ export const ChangeFacts = z.object({
|
|
|
20
24
|
stranded: z.array(z.string()),
|
|
21
25
|
/** Whether `sanoma check` passed on the change. */
|
|
22
26
|
checksOk: z.boolean(),
|
|
23
|
-
/** Whether the change edits the rules: a policy or a people file, which wait for `admin` (`runtimeHold`). */
|
|
27
|
+
/** Whether the change edits the rules: a policy (the root's or an app's) or a people file, which wait for `admin` (`runtimeHold`). */
|
|
24
28
|
rules: z.boolean(),
|
|
25
29
|
});
|
|
26
30
|
/** What opening a change takes: its draft's id, and the pull request's title, body and the change page's link. */
|
|
27
31
|
export const ChangeInput = z.object({
|
|
28
|
-
id: z.string().regex(
|
|
32
|
+
id: z.string().regex(DRAFT_ID),
|
|
29
33
|
title: z.string().min(1),
|
|
30
34
|
body: z.string().optional(),
|
|
31
35
|
page: z.string().url().optional(),
|
|
@@ -100,12 +104,162 @@ export function reservedFor(op, types) {
|
|
|
100
104
|
}
|
|
101
105
|
return undefined;
|
|
102
106
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
/** The branch a change merges into. */
|
|
108
|
+
export const BASE = "main";
|
|
109
|
+
/** The company's credentials for a host, as its remote half asks for them (`Secrets` puts the environment first). */
|
|
110
|
+
const remoteContext = (secrets) => ({
|
|
111
|
+
async secrets(names) {
|
|
112
|
+
const found = await Promise.all(names.map(async (name) => [name, await secrets.get(name)]));
|
|
113
|
+
return new Map(found.filter((entry) => Boolean(entry[1])));
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
/** The worker's repository, read from the run; a `DriverError` outside one, or for a worker given none. */
|
|
117
|
+
export function sourceOfRun() {
|
|
118
|
+
const source = currentRun.getStore()?.state.source;
|
|
119
|
+
if (!source) {
|
|
120
|
+
throw new DriverError("This worker was given no repository (`source`), so it cannot change one", {
|
|
121
|
+
retryable: false,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return source;
|
|
125
|
+
}
|
|
126
|
+
/** 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. */
|
|
127
|
+
const openedHosts = new WeakMap();
|
|
128
|
+
/**
|
|
129
|
+
* The company's repository and its host: `source`, by default the worker's for a call made in a
|
|
130
|
+
* run (a change's driver and its steps, `sourceOfRun`), the clone URL of its `origin`, and that
|
|
131
|
+
* repository (`repo`) on the host whose connector's remote half matches the URL, opened once per
|
|
132
|
+
* remote half, secrets and URL, so its token is kept between calls. A `DriverError` when the
|
|
133
|
+
* repository has no remote (`no_remote`: saving is publishing then, there is nothing to review a
|
|
134
|
+
* branch on) or no connector is for its host (`no_remote_half`).
|
|
135
|
+
*/
|
|
136
|
+
export async function repositoryOf(remotes, secrets, source = sourceOfRun()) {
|
|
137
|
+
const url = await source.remoteUrl?.();
|
|
138
|
+
if (!url) {
|
|
139
|
+
throw new DriverError("saving is publishing: this repository has no remote", {
|
|
140
|
+
retryable: false,
|
|
141
|
+
vendorCode: "no_remote",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const found = remoteFor(remotes, url);
|
|
145
|
+
if (!found) {
|
|
146
|
+
throw new DriverError(`No connector's remote half is for ${url}: add the connector of its host`, {
|
|
147
|
+
retryable: false,
|
|
148
|
+
vendorCode: "no_remote_half",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const bySecrets = openedHosts.get(found.remote) ?? new WeakMap();
|
|
152
|
+
openedHosts.set(found.remote, bySecrets);
|
|
153
|
+
const byUrl = bySecrets.get(secrets) ?? new Map();
|
|
154
|
+
bySecrets.set(secrets, byUrl);
|
|
155
|
+
const host = byUrl.get(url) ?? found.remote.open(found.repo, remoteContext(secrets));
|
|
156
|
+
byUrl.set(url, host);
|
|
157
|
+
return { source, url, repo: found.repo, host };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Fast-forwards the checkout to the host's `main` (`Source.follow`), as a deploy does first so the
|
|
161
|
+
* hosts and what apply reads are main's: the revision after, why it did not, or the error, never
|
|
162
|
+
* thrown (what is merged is merged; a deploy that cannot follow still applies).
|
|
163
|
+
*/
|
|
164
|
+
export async function followBase(remotes, secrets, source) {
|
|
165
|
+
try {
|
|
166
|
+
const { source: at, url, host } = await repositoryOf(remotes, secrets, source);
|
|
167
|
+
if (!at.follow)
|
|
168
|
+
return { skipped: "this repository cannot follow its remote" };
|
|
169
|
+
const moved = await at.follow({ url, token: await host.token(), ref: BASE });
|
|
170
|
+
return "revision" in moved ? { revision: moved.revision } : moved;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return { error: errorMessage(err) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The driver of a change's operations, over the repository the worker was given (`source`, read
|
|
178
|
+
* from the run) and its host (`remotes`, the connectors' remote halves: `remoteFor` its
|
|
179
|
+
* `origin`, opened once and kept: `repositoryOf`).
|
|
180
|
+
*
|
|
181
|
+
* - `open` pushes the draft's branch and opens its pull request into `main` (the open one when it
|
|
182
|
+
* has one): safe to retry. Its `head` is the branch's commit as pushed.
|
|
183
|
+
* - `review` sets the `sanoma` status on the head, linking to the change page: `pending`, or
|
|
184
|
+
* `failure`; never `success`, which only a merge sets.
|
|
185
|
+
* - `merge` reads the pull request: merged already (a retry whose answer was lost) or closed are
|
|
186
|
+
* answered as they are; a branch at another commit than `head`, on the host or in the draft, is
|
|
187
|
+
* `moved`. Else it sets the status `success` and squash-merges at `head`: the host's `head_moved`
|
|
188
|
+
* is `moved`, `not_mergeable` is `blocked` with the host's reason. The branch is then deleted,
|
|
189
|
+
* as far as it can be.
|
|
190
|
+
* - `close` closes the pull request.
|
|
191
|
+
*/
|
|
192
|
+
export function changeDriver(remotes) {
|
|
193
|
+
const open = (call) => repositoryOf(remotes, call.secrets);
|
|
194
|
+
return defineDriver(changeOps, {
|
|
195
|
+
change: {
|
|
196
|
+
async open({ id, title, body }, call) {
|
|
197
|
+
const { source, url, host } = await open(call);
|
|
198
|
+
if (!source.draft || !source.drafts || !source.push) {
|
|
199
|
+
throw new DriverError("This repository keeps no drafts, so it has no change to open", { retryable: false });
|
|
200
|
+
}
|
|
201
|
+
if (!(await source.drafts()).includes(id)) {
|
|
202
|
+
throw new DriverError(`There is no open draft "${id}" to send for review`, {
|
|
203
|
+
retryable: false,
|
|
204
|
+
vendorCode: "no_draft",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const { branch } = await source.draft(id);
|
|
208
|
+
// The head is read before the push: a commit made in the draft meanwhile is the next round's.
|
|
209
|
+
const head = await source.head(branch);
|
|
210
|
+
await source.push({ url, token: await host.token(), ref: branch });
|
|
211
|
+
const pr = await host.openPullRequest({ branch, base: BASE, title, body: body ?? "" });
|
|
212
|
+
return { number: pr.number, url: pr.url, head, branch };
|
|
213
|
+
},
|
|
214
|
+
async review({ head, state, description, page }, call) {
|
|
215
|
+
const { host } = await open(call);
|
|
216
|
+
await host.setStatus({ sha: head, state, description, ...(page !== undefined && { targetUrl: page }) });
|
|
217
|
+
return {};
|
|
218
|
+
},
|
|
219
|
+
async merge({ number, head, title }, call) {
|
|
220
|
+
const { source, host } = await open(call);
|
|
221
|
+
const pr = await host.pullRequest(number);
|
|
222
|
+
if (pr.state === "merged")
|
|
223
|
+
return { outcome: "merged", ...(pr.mergeSha !== undefined && { sha: pr.mergeSha }) };
|
|
224
|
+
if (pr.state === "closed")
|
|
225
|
+
return { outcome: "closed" };
|
|
226
|
+
// Commits the run has not planned: pushed to the host, or made in the draft and not pushed yet.
|
|
227
|
+
const local = await Promise.resolve(source.head(pr.branch)).catch(() => undefined);
|
|
228
|
+
if (pr.head !== head || (local !== undefined && local !== head))
|
|
229
|
+
return { outcome: "moved" };
|
|
230
|
+
await host.setStatus({ sha: head, state: "success", description: "Published through Sanoma" });
|
|
231
|
+
let sha;
|
|
232
|
+
try {
|
|
233
|
+
({ sha } = await host.mergePullRequest(number, {
|
|
234
|
+
sha: head,
|
|
235
|
+
method: "squash",
|
|
236
|
+
title: `${title} (#${number})`,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
// Not merged: Sanoma's status on the head goes back to failing, so the host does not merge it either.
|
|
241
|
+
const reason = errorMessage(err);
|
|
242
|
+
await host
|
|
243
|
+
.setStatus({ sha: head, state: "failure", description: `Not published: ${reason}`.slice(0, 140) })
|
|
244
|
+
.catch(() => undefined);
|
|
245
|
+
const { vendorCode: code } = errorInfo(err);
|
|
246
|
+
if (code === "head_moved")
|
|
247
|
+
return { outcome: "moved" };
|
|
248
|
+
if (code === "not_mergeable")
|
|
249
|
+
return { outcome: "blocked", reason };
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
// Best effort: a branch left behind holds nothing the merge did not take.
|
|
253
|
+
await host.deleteBranch?.(pr.branch).catch(() => undefined);
|
|
254
|
+
return { outcome: "merged", sha };
|
|
255
|
+
},
|
|
256
|
+
async close({ number }, call) {
|
|
257
|
+
const { host } = await open(call);
|
|
258
|
+
await host.closePullRequest(number);
|
|
259
|
+
return { closed: true };
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
});
|
|
109
263
|
}
|
|
110
264
|
/**
|
|
111
265
|
* Each path, relative to the root with `/` between its parts, and each of its parent directories
|
|
@@ -121,14 +275,18 @@ export function touchesOf(files) {
|
|
|
121
275
|
return [...touches].toSorted();
|
|
122
276
|
}
|
|
123
277
|
/** The group the runtime holds a change to the rules for. */
|
|
124
|
-
const ADMIN = "admin";
|
|
125
|
-
/**
|
|
126
|
-
|
|
278
|
+
export const ADMIN = "admin";
|
|
279
|
+
/**
|
|
280
|
+
* Whether a path in `touches` is the rules whatever `rules` says: the policies, the root's or an
|
|
281
|
+
* app's (`apps/crm/policies/`), and the template's people file.
|
|
282
|
+
*/
|
|
283
|
+
export const isRules = (path) => path === "policies/" || path === "resources/people.ts" || /^apps\/[^/]+\/policies\/$/.test(path);
|
|
127
284
|
/** The runtime rule's id, as a hold's `reasons` name it. */
|
|
128
285
|
const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
|
|
129
286
|
/**
|
|
130
287
|
* The runtime's own rule, asked before any company policy and under `allowAll` too: a merge of a
|
|
131
|
-
* change to the rules (`facts.rules`, or `touches` naming `policies
|
|
288
|
+
* change to the rules (`facts.rules`, or `touches` naming `policies/`, an app's `policies/` or
|
|
289
|
+
* `resources/people.ts`) waits for `admin`, whoever made it, until an approval
|
|
132
290
|
* addressed to `admin` covering the merge is approved; denied while no data file declares the
|
|
133
291
|
* group. Undefined for anything else, which the company's policy alone decides. `people` are the
|
|
134
292
|
* running instance's, never the change's, so a change cannot add its author to `admin`.
|
|
@@ -136,7 +294,7 @@ const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
|
|
|
136
294
|
export function runtimeHold(opId, input, approvals, people) {
|
|
137
295
|
const facts = opId === CHANGE_MERGE ? input.facts : undefined;
|
|
138
296
|
// The planner's `rules`, or what `touches` already says: a fact left false cannot skip the rule.
|
|
139
|
-
if (!facts || !(facts.rules ||
|
|
297
|
+
if (!facts || !(facts.rules || facts.touches.some(isRules)))
|
|
140
298
|
return undefined;
|
|
141
299
|
if (!groupNamesOf(people).includes(ADMIN)) {
|
|
142
300
|
return deny(`A change to the policies or the people waits for the group "${ADMIN}", which no data file declares: ` +
|
|
@@ -147,4 +305,25 @@ export function runtimeHold(opId, input, approvals, people) {
|
|
|
147
305
|
return undefined;
|
|
148
306
|
return approve(approver, { title: "A change to the rules waits for admin", reasons: [RULES_WAIT_FOR_ADMIN] });
|
|
149
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* What a change run is told (on `CHANGE_TOPIC`): new commits on its branch, a close someone asked
|
|
310
|
+
* for, or its pull request closed or merged on the host.
|
|
311
|
+
*/
|
|
312
|
+
export const ChangeEventSchema = z.discriminatedUnion("kind", [
|
|
313
|
+
z.object({ kind: z.literal("commits") }),
|
|
314
|
+
z.object({ kind: z.literal("close"), by: z.string().min(1) }),
|
|
315
|
+
z.object({ kind: z.literal("closed") }),
|
|
316
|
+
z.object({ kind: z.literal("merged"), sha: z.string().min(1) }),
|
|
317
|
+
]);
|
|
318
|
+
/** The review card on the host, which this version does not write. */
|
|
319
|
+
export const NO_CARD = { status: "unavailable", reason: "This version writes no review card on the host" };
|
|
320
|
+
/** Where a change is before its run has opened it. */
|
|
321
|
+
export const initialChangeState = (input) => ({
|
|
322
|
+
id: input.id,
|
|
323
|
+
branch: "",
|
|
324
|
+
title: input.title,
|
|
325
|
+
...(input.page !== undefined && { page: input.page }),
|
|
326
|
+
phase: "opening",
|
|
327
|
+
card: NO_CARD,
|
|
328
|
+
});
|
|
150
329
|
//# sourceMappingURL=change.js.map
|