@sanoma-ai/workflows 0.7.0 → 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.
Files changed (168) hide show
  1. package/README.md +43 -12
  2. package/dist/apps-list.d.ts +90 -0
  3. package/dist/apps-list.js +109 -0
  4. package/dist/apps-list.js.map +1 -0
  5. package/dist/apps.d.ts +42 -0
  6. package/dist/apps.js +137 -0
  7. package/dist/apps.js.map +1 -0
  8. package/dist/call-workflow.js +5 -6
  9. package/dist/call-workflow.js.map +1 -1
  10. package/dist/call.js +6 -3
  11. package/dist/call.js.map +1 -1
  12. package/dist/cedar.d.ts +1 -1
  13. package/dist/cedar.js +6 -4
  14. package/dist/cedar.js.map +1 -1
  15. package/dist/change-steps.d.ts +10 -0
  16. package/dist/change-steps.js +204 -0
  17. package/dist/change-steps.js.map +1 -0
  18. package/dist/change-workflow.d.ts +54 -0
  19. package/dist/change-workflow.js +161 -0
  20. package/dist/change-workflow.js.map +1 -0
  21. package/dist/change.d.ts +121 -24
  22. package/dist/change.js +196 -17
  23. package/dist/change.js.map +1 -1
  24. package/dist/claims.d.ts +58 -0
  25. package/dist/claims.js +27 -0
  26. package/dist/claims.js.map +1 -0
  27. package/dist/client.d.ts +92 -0
  28. package/dist/client.js +268 -6
  29. package/dist/client.js.map +1 -1
  30. package/dist/collection.d.ts +6 -1
  31. package/dist/collection.js +11 -3
  32. package/dist/collection.js.map +1 -1
  33. package/dist/config.d.ts +56 -8
  34. package/dist/config.js +71 -29
  35. package/dist/config.js.map +1 -1
  36. package/dist/constructors.d.ts +37 -1
  37. package/dist/constructors.js +6 -2
  38. package/dist/constructors.js.map +1 -1
  39. package/dist/db.d.ts +21 -21
  40. package/dist/db.js +31 -54
  41. package/dist/db.js.map +1 -1
  42. package/dist/deadlines.d.ts +18 -12
  43. package/dist/deadlines.js +18 -24
  44. package/dist/deadlines.js.map +1 -1
  45. package/dist/define.d.ts +30 -14
  46. package/dist/define.js +9 -1
  47. package/dist/define.js.map +1 -1
  48. package/dist/demo.d.ts +15 -4
  49. package/dist/demo.js +27 -12
  50. package/dist/demo.js.map +1 -1
  51. package/dist/describe.d.ts +16 -2
  52. package/dist/describe.js +39 -4
  53. package/dist/describe.js.map +1 -1
  54. package/dist/direct.d.ts +4 -3
  55. package/dist/direct.js +7 -4
  56. package/dist/direct.js.map +1 -1
  57. package/dist/errors.d.ts +2 -0
  58. package/dist/errors.js +2 -0
  59. package/dist/errors.js.map +1 -1
  60. package/dist/fake.d.ts +26 -0
  61. package/dist/fake.js +123 -0
  62. package/dist/fake.js.map +1 -1
  63. package/dist/host.d.ts +2 -3
  64. package/dist/host.js +1 -1
  65. package/dist/host.js.map +1 -1
  66. package/dist/index.d.ts +8 -7
  67. package/dist/index.js +7 -6
  68. package/dist/index.js.map +1 -1
  69. package/dist/lint.js +20 -6
  70. package/dist/lint.js.map +1 -1
  71. package/dist/op.d.ts +8 -1
  72. package/dist/op.js.map +1 -1
  73. package/dist/outline.js +13 -6
  74. package/dist/outline.js.map +1 -1
  75. package/dist/paths.d.ts +16 -0
  76. package/dist/paths.js +54 -2
  77. package/dist/paths.js.map +1 -1
  78. package/dist/person.d.ts +5 -0
  79. package/dist/person.js +3 -1
  80. package/dist/person.js.map +1 -1
  81. package/dist/resources.d.ts +7 -2
  82. package/dist/resources.js +27 -10
  83. package/dist/resources.js.map +1 -1
  84. package/dist/run.d.ts +7 -0
  85. package/dist/run.js.map +1 -1
  86. package/dist/scenario.d.ts +2 -2
  87. package/dist/scenario.js +10 -4
  88. package/dist/scenario.js.map +1 -1
  89. package/dist/scenarios.d.ts +1 -1
  90. package/dist/scenarios.js.map +1 -1
  91. package/dist/sdk.d.ts +1 -1
  92. package/dist/sdk.js +19 -5
  93. package/dist/sdk.js.map +1 -1
  94. package/dist/shared.d.ts +43 -2
  95. package/dist/shared.js +46 -4
  96. package/dist/shared.js.map +1 -1
  97. package/dist/sites.d.ts +26 -5
  98. package/dist/sites.js +56 -20
  99. package/dist/sites.js.map +1 -1
  100. package/dist/source.d.ts +179 -5
  101. package/dist/source.js +13 -0
  102. package/dist/source.js.map +1 -1
  103. package/dist/supervisor.d.ts +5 -4
  104. package/dist/supervisor.js +2 -11
  105. package/dist/supervisor.js.map +1 -1
  106. package/dist/version.d.ts +8 -0
  107. package/dist/version.js +21 -13
  108. package/dist/version.js.map +1 -1
  109. package/dist/wakeup-driver.d.ts +128 -0
  110. package/dist/wakeup-driver.js +216 -0
  111. package/dist/wakeup-driver.js.map +1 -0
  112. package/dist/wakeup-service.d.ts +4 -4
  113. package/dist/wakeup-service.js +19 -25
  114. package/dist/wakeup-service.js.map +1 -1
  115. package/dist/wakeup-type.d.ts +43 -0
  116. package/dist/wakeup-type.js +80 -0
  117. package/dist/wakeup-type.js.map +1 -0
  118. package/dist/wakeup.d.ts +1 -8
  119. package/dist/wakeup.js +2 -11
  120. package/dist/wakeup.js.map +1 -1
  121. package/dist/worker.d.ts +2 -2
  122. package/dist/worker.js +50 -7
  123. package/dist/worker.js.map +1 -1
  124. package/oxlint.json +3 -2
  125. package/package.json +3 -1
  126. package/src/apps-list.ts +119 -0
  127. package/src/apps.ts +162 -0
  128. package/src/call-workflow.ts +10 -2
  129. package/src/call.ts +6 -3
  130. package/src/cedar.ts +9 -7
  131. package/src/change-steps.ts +274 -0
  132. package/src/change-workflow.ts +198 -0
  133. package/src/change.ts +257 -27
  134. package/src/claims.ts +79 -0
  135. package/src/client.ts +320 -4
  136. package/src/collection.ts +22 -3
  137. package/src/config.ts +104 -35
  138. package/src/constructors.ts +8 -2
  139. package/src/db.ts +39 -66
  140. package/src/deadlines.ts +27 -30
  141. package/src/define.ts +31 -12
  142. package/src/demo.ts +55 -24
  143. package/src/describe.ts +67 -4
  144. package/src/direct.ts +9 -5
  145. package/src/errors.ts +3 -0
  146. package/src/fake.ts +137 -0
  147. package/src/host.ts +3 -4
  148. package/src/index.ts +22 -3
  149. package/src/lint.ts +21 -6
  150. package/src/op.ts +8 -1
  151. package/src/outline.ts +15 -6
  152. package/src/paths.ts +57 -2
  153. package/src/person.ts +4 -1
  154. package/src/resources.ts +31 -11
  155. package/src/run.ts +5 -0
  156. package/src/scenario.ts +14 -4
  157. package/src/scenarios.ts +1 -1
  158. package/src/sdk.ts +21 -7
  159. package/src/shared.ts +60 -5
  160. package/src/sites.ts +70 -24
  161. package/src/source.ts +168 -5
  162. package/src/supervisor.ts +7 -12
  163. package/src/version.ts +25 -16
  164. package/src/wakeup-driver.ts +270 -0
  165. package/src/wakeup-service.ts +21 -28
  166. package/src/wakeup-type.ts +90 -0
  167. package/src/wakeup.ts +6 -17
  168. package/src/worker.ts +48 -9
@@ -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 { CheckResult } from "./source.ts";
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
- /** A placeholder: lane C5 replaces it with the driver over the repository's host. */
143
- export declare function changeDriver(): Driver;
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/` or `resources/people.ts`) waits for `admin`, whoever made it, until an approval
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
- /** What a change run is told while it waits: new commits on its branch, a close asked for, or its pull request closed or merged on the host. */
158
- export type ChangeEvent = {
159
- kind: "commits";
160
- } | {
161
- kind: "close";
162
- by: string;
163
- } | {
164
- kind: "closed";
165
- } | {
166
- kind: "merged";
167
- sha: string;
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
- runId?: string;
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
- runId?: string;
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 { APPLY_WORKFLOW, CHANGE_MERGE, CHANGE_WORKFLOW, groupNamesOf } from "./shared.js";
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 (lane C5): its four operations on the built-in connector `sanoma`, the facts a policy
8
- // decides its merge on (the attributes of its `Sanoma::Change`), and the runtime's own rule that
9
- // a change to the rules waits for `admin`, before any company policy is asked.
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(/^[\w-]{1,64}$/),
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
- const notBuilt = async () => {
104
- throw new DriverError("not built", { retryable: false });
105
- };
106
- /** A placeholder: lane C5 replaces it with the driver over the repository's host. */
107
- export function changeDriver() {
108
- return defineDriver(changeOps, { change: { open: notBuilt, review: notBuilt, merge: notBuilt, close: notBuilt } });
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
- /** The paths in `touches` that are the rules whatever `rules` says: the policies, and the template's people file. */
126
- const RULES = ["policies/", "resources/people.ts"];
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/` or `resources/people.ts`) waits for `admin`, whoever made it, until an approval
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 || RULES.some((path) => facts.touches.includes(path))))
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