@samebase/alchemy-convex 0.17.0 → 0.18.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/NOTICE +3 -0
- package/README.md +27 -0
- package/lib/cli.js +215 -9
- package/lib/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/StackComment.ts +307 -0
- package/src/cli.ts +50 -32
package/NOTICE
CHANGED
|
@@ -8,6 +8,9 @@ packages/alchemy, under the ISC license:
|
|
|
8
8
|
- the Convex CLI runner in src/Push.ts (internal/CommandRunner.ts),
|
|
9
9
|
- the deployment lookup rules in src/Deployment.ts and the project listing in src/Project.ts.
|
|
10
10
|
|
|
11
|
+
The Workers and R2 icons in assets/stack-comment are by Cloudflare, under CC BY 4.0
|
|
12
|
+
(https://creativecommons.org/licenses/by/4.0/), with Cloudflare orange fill.
|
|
13
|
+
|
|
11
14
|
ISC License
|
|
12
15
|
|
|
13
16
|
Copyright 2024 RJ Dellecese
|
package/README.md
CHANGED
|
@@ -292,6 +292,33 @@ of the app, before the first push: it registers the deployment of that directory
|
|
|
292
292
|
npx alchemy-convex hub-client --team my-team --app scout --callback-path /auth/hub/callback
|
|
293
293
|
```
|
|
294
294
|
|
|
295
|
+
## Pull request comment
|
|
296
|
+
|
|
297
|
+
`alchemy-convex stack-comment` keeps one comment on a pull request. The comment has a card for each
|
|
298
|
+
stack of the repository at the stage `pr-<number>`: the folder, the address, the last run, and the
|
|
299
|
+
Convex deployments, Workers, and R2 buckets. A stack sees only itself, so a comment that a stack
|
|
300
|
+
declares cannot show this.
|
|
301
|
+
|
|
302
|
+
Run it after every deploy of the workflow. The run record gives the repository and the pull request,
|
|
303
|
+
so the command needs no argument with a team access token:
|
|
304
|
+
|
|
305
|
+
```yaml
|
|
306
|
+
permissions:
|
|
307
|
+
pull-requests: write
|
|
308
|
+
|
|
309
|
+
steps:
|
|
310
|
+
# The deploy steps of each stack.
|
|
311
|
+
- name: Update the stack comment
|
|
312
|
+
if: github.event_name == 'pull_request'
|
|
313
|
+
run: pnpm exec alchemy-convex stack-comment
|
|
314
|
+
env:
|
|
315
|
+
CONVEX_ACCESS_TOKEN: ${{ secrets.CONVEX_ACCESS_TOKEN }}
|
|
316
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
A stack named `owner/name`, or `owner/name/<folder>` for a stack in a folder, belongs to the
|
|
320
|
+
repository `owner/name`. A stack without the stage of the pull request has no card.
|
|
321
|
+
|
|
295
322
|
## Data safety
|
|
296
323
|
|
|
297
324
|
- `Convex.Project` and `Convex.Deployment` have the removal policy `retain`. `alchemy destroy`
|
package/lib/cli.js
CHANGED
|
@@ -1,9 +1,207 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { I as ManagementApiLive, M as installedCli, V as fromConfig, a as registerDevelopmentClient } from "./HubClient-BZKweNfD.js";
|
|
2
|
+
import { I as ManagementApiLive, M as installedCli, V as fromConfig, a as registerDevelopmentClient, c as RUN_RECORD_KEY, s as state, u as runRecord } from "./HubClient-BZKweNfD.js";
|
|
3
3
|
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
4
5
|
import * as Layer from "effect/Layer";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
7
|
+
import { State } from "alchemy/State";
|
|
6
8
|
import { execFileSync } from "node:child_process";
|
|
9
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
10
|
+
//#region src/StackComment.ts
|
|
11
|
+
/** The first text of the comment, which finds it again. */
|
|
12
|
+
const MARKER = "<!-- samebase-stacks -->";
|
|
13
|
+
/** GitHub does not proxy this host, so a new icon needs no purge of its image cache. */
|
|
14
|
+
const ICONS = "https://raw.githubusercontent.com/samebase/alchemy-convex/main/assets/stack-comment";
|
|
15
|
+
const GITHUB_API = "https://api.github.com";
|
|
16
|
+
/** The resources that a card shows. A row of any other type, or without `attr` from an interrupted deploy, is left out. */
|
|
17
|
+
const Resource = Schema.Union([
|
|
18
|
+
Schema.Struct({
|
|
19
|
+
resourceType: Schema.Literal("Convex.Deployment"),
|
|
20
|
+
attr: Schema.Struct({
|
|
21
|
+
name: Schema.String,
|
|
22
|
+
siteUrl: Schema.String
|
|
23
|
+
})
|
|
24
|
+
}),
|
|
25
|
+
Schema.Struct({
|
|
26
|
+
resourceType: Schema.Literal("Cloudflare.Worker"),
|
|
27
|
+
attr: Schema.Struct({
|
|
28
|
+
workerName: Schema.String,
|
|
29
|
+
url: Schema.optional(Schema.String)
|
|
30
|
+
})
|
|
31
|
+
}),
|
|
32
|
+
Schema.Struct({
|
|
33
|
+
resourceType: Schema.Literal("Cloudflare.R2.Bucket"),
|
|
34
|
+
attr: Schema.Struct({
|
|
35
|
+
bucketName: Schema.String,
|
|
36
|
+
accountId: Schema.String,
|
|
37
|
+
jurisdiction: Schema.String
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
]);
|
|
41
|
+
/** The fields of the run record that a card shows. */
|
|
42
|
+
const StageOutput = Schema.Struct({ [RUN_RECORD_KEY]: Schema.Struct({
|
|
43
|
+
headCommit: Schema.String,
|
|
44
|
+
runUrl: Schema.NullOr(Schema.String),
|
|
45
|
+
recordedAt: Schema.String
|
|
46
|
+
}) });
|
|
47
|
+
var StackCommentError = class extends Schema.TaggedError()("StackCommentError", { reason: Schema.String }) {
|
|
48
|
+
get message() {
|
|
49
|
+
return `The stack comment cannot be updated: ${this.reason}`;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** The cards of the stacks of `repository` that have `stage`, in name order. */
|
|
53
|
+
const readStackCards = ({ repository, stage }) => Effect.gen(function* () {
|
|
54
|
+
const state = yield* yield* State;
|
|
55
|
+
const names = (yield* state.listStacks()).filter((name) => name === repository || name.startsWith(`${repository}/`));
|
|
56
|
+
const cards = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (!(yield* state.listStages(name)).includes(stage)) continue;
|
|
59
|
+
const request = {
|
|
60
|
+
stack: name,
|
|
61
|
+
stage
|
|
62
|
+
};
|
|
63
|
+
const rows = yield* Effect.forEach(yield* state.list(request), (fqn) => state.get({
|
|
64
|
+
...request,
|
|
65
|
+
fqn
|
|
66
|
+
}));
|
|
67
|
+
const output = yield* state.getOutput(request);
|
|
68
|
+
cards.push({
|
|
69
|
+
name,
|
|
70
|
+
stage,
|
|
71
|
+
resources: rows.filter(Schema.is(Resource)),
|
|
72
|
+
run: Schema.is(StageOutput)(output) ? output : null
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return cards;
|
|
76
|
+
});
|
|
77
|
+
/** A stack can create a deployment in one resource and push to it in another: a card shows it once. */
|
|
78
|
+
const withoutRepeats = (resources) => {
|
|
79
|
+
const deployments = /* @__PURE__ */ new Set();
|
|
80
|
+
return resources.filter((resource) => {
|
|
81
|
+
if (resource.resourceType !== "Convex.Deployment") return true;
|
|
82
|
+
const repeated = deployments.has(resource.attr.name);
|
|
83
|
+
deployments.add(resource.attr.name);
|
|
84
|
+
return !repeated;
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
const escapeHtml = (text) => text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
88
|
+
const link = (href, text) => `<a href="${escapeHtml(href)}">${text}</a>`;
|
|
89
|
+
const named = (href, icon, alt, name) => {
|
|
90
|
+
const image = `<img src="${ICONS}/${icon}.svg" width="16" align="absmiddle" alt="${alt}">`;
|
|
91
|
+
return `${link(href, image)} ${link(href, escapeHtml(name))}`;
|
|
92
|
+
};
|
|
93
|
+
const hostOf = (url) => escapeHtml(new URL(url).host);
|
|
94
|
+
/** Where a stack serves: its Convex deployment, else its first Worker with a URL. */
|
|
95
|
+
const addressOf = (resources) => {
|
|
96
|
+
for (const resource of resources) if (resource.resourceType === "Convex.Deployment") return resource.attr.siteUrl;
|
|
97
|
+
for (const resource of resources) if (resource.resourceType === "Cloudflare.Worker" && resource.attr.url !== void 0) return resource.attr.url;
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
const resourceRow = (resource, address) => {
|
|
101
|
+
switch (resource.resourceType) {
|
|
102
|
+
case "Convex.Deployment": {
|
|
103
|
+
const { name } = resource.attr;
|
|
104
|
+
return named(`https://dashboard.convex.dev/d/${encodeURIComponent(name)}`, "convex", "Convex", name);
|
|
105
|
+
}
|
|
106
|
+
case "Cloudflare.Worker": {
|
|
107
|
+
const { url, workerName } = resource.attr;
|
|
108
|
+
const row = named(`https://dash.cloudflare.com/?to=/:account/workers/services/view/${encodeURIComponent(workerName)}`, "worker", "Worker", workerName);
|
|
109
|
+
return url === void 0 || url === address ? row : `${row}<br><sub>${link(url, hostOf(url))}</sub>`;
|
|
110
|
+
}
|
|
111
|
+
case "Cloudflare.R2.Bucket": {
|
|
112
|
+
const { accountId, bucketName, jurisdiction } = resource.attr;
|
|
113
|
+
return named(`https://dash.cloudflare.com/${encodeURIComponent(accountId)}/r2/${encodeURIComponent(jurisdiction)}/buckets/${encodeURIComponent(bucketName)}`, "r2", "R2 bucket", bucketName);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const runLine = ({ run }) => {
|
|
118
|
+
if (run === null) return "";
|
|
119
|
+
const { headCommit, recordedAt, runUrl } = run[RUN_RECORD_KEY];
|
|
120
|
+
const commit = escapeHtml(headCommit.slice(0, 7));
|
|
121
|
+
const time = `${recordedAt.slice(0, 16).replace("T", " ")} UTC`;
|
|
122
|
+
return `<br><sub>${runUrl === null ? commit : link(runUrl, commit)} ${escapeHtml(time)}</sub>`;
|
|
123
|
+
};
|
|
124
|
+
const SPACER = `<div><img src="${ICONS}/spacer.svg" width="460" align="left" alt=""></div>`;
|
|
125
|
+
/** `alone`: the one card of the repository. A card at the root then has no folder, as on the repository page. */
|
|
126
|
+
const renderCard = (card, repository, alone) => {
|
|
127
|
+
const folder = card.name.slice(repository.length + 1);
|
|
128
|
+
const heading = [folder === "" && alone ? "" : `<b>${escapeHtml(folder || "/")}</b> `, `<sub>${escapeHtml(card.stage)}</sub>`].join("");
|
|
129
|
+
const resources = withoutRepeats(card.resources);
|
|
130
|
+
const address = addressOf(resources);
|
|
131
|
+
const addressLine = address === null ? "" : `<br>${link(address, hostOf(address))}`;
|
|
132
|
+
const rows = resources.map((resource) => `<tbody><tr><td>${resourceRow(resource, address)}</td></tr></tbody>`).join("\n");
|
|
133
|
+
return `<table>
|
|
134
|
+
<tbody><tr><td>${SPACER}${heading}${addressLine}${runLine(card)}</td></tr></tbody>
|
|
135
|
+
${rows}
|
|
136
|
+
</table>`;
|
|
137
|
+
};
|
|
138
|
+
const renderStackComment = (cards, repository) => `${MARKER}
|
|
139
|
+
${cards.map((card) => renderCard(card, repository, cards.length === 1)).join("\n\n")}
|
|
140
|
+
`;
|
|
141
|
+
const Comments = Schema.Array(Schema.Struct({
|
|
142
|
+
id: Schema.Number,
|
|
143
|
+
body: Schema.String
|
|
144
|
+
}));
|
|
145
|
+
const SavedComment = Schema.Struct({ html_url: Schema.String });
|
|
146
|
+
const github = (token, path, init) => Effect.tryPromise({
|
|
147
|
+
try: async () => {
|
|
148
|
+
const response = await fetch(`${GITHUB_API}${path}`, {
|
|
149
|
+
...init,
|
|
150
|
+
headers: {
|
|
151
|
+
Accept: "application/vnd.github+json",
|
|
152
|
+
Authorization: `Bearer ${token}`,
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
158
|
+
return await response.json();
|
|
159
|
+
},
|
|
160
|
+
catch: (error) => new StackCommentError({ reason: `GitHub ${init.method} ${path} failed (${error instanceof Error ? error.message : String(error)})` })
|
|
161
|
+
});
|
|
162
|
+
const decode = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => new StackCommentError({ reason: `GitHub gave a bad ${what}` })));
|
|
163
|
+
const findComment = (token, path) => Effect.gen(function* () {
|
|
164
|
+
for (let page = 1;; page += 1) {
|
|
165
|
+
const comments = yield* github(token, `${path}?per_page=100&page=${page}`, { method: "GET" }).pipe(Effect.flatMap(decode(Comments, "comment list")));
|
|
166
|
+
const comment = comments.find(({ body }) => body.startsWith(MARKER));
|
|
167
|
+
if (comment !== void 0 || comments.length < 100) return comment;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
/** Replaces the body of the comment of this command, or posts it on the first run, and gives its URL. */
|
|
171
|
+
const saveStackComment = (args) => Effect.gen(function* () {
|
|
172
|
+
const { body, pullRequest, repository, token } = args;
|
|
173
|
+
const comments = `/repos/${repository}/issues/${pullRequest}/comments`;
|
|
174
|
+
const comment = yield* findComment(token, comments);
|
|
175
|
+
const reply = yield* comment === void 0 ? github(token, comments, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: JSON.stringify({ body })
|
|
178
|
+
}) : github(token, `/repos/${repository}/issues/comments/${comment.id}`, {
|
|
179
|
+
method: "PATCH",
|
|
180
|
+
body: JSON.stringify({ body })
|
|
181
|
+
});
|
|
182
|
+
return (yield* decode(SavedComment, "comment")(reply)).html_url;
|
|
183
|
+
});
|
|
184
|
+
/** The step of a workflow: the run record names the repository and the pull request of the run. */
|
|
185
|
+
const updateStackComment = Effect.gen(function* () {
|
|
186
|
+
const { pullRequest, repository } = yield* runRecord({
|
|
187
|
+
cwd: process.cwd(),
|
|
188
|
+
env: process.env
|
|
189
|
+
});
|
|
190
|
+
if (pullRequest === null) return yield* new StackCommentError({ reason: "this run has no pull request" });
|
|
191
|
+
const token = process.env["GITHUB_TOKEN"];
|
|
192
|
+
if (token === void 0 || token === "") return yield* new StackCommentError({ reason: "GITHUB_TOKEN is not set" });
|
|
193
|
+
const cards = yield* readStackCards({
|
|
194
|
+
repository,
|
|
195
|
+
stage: `pr-${pullRequest}`
|
|
196
|
+
});
|
|
197
|
+
return yield* saveStackComment({
|
|
198
|
+
token,
|
|
199
|
+
repository,
|
|
200
|
+
pullRequest,
|
|
201
|
+
body: renderStackComment(cards, repository)
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
//#endregion
|
|
7
205
|
//#region src/cli.ts
|
|
8
206
|
const { positionals, values } = parseArgs({
|
|
9
207
|
allowPositionals: true,
|
|
@@ -14,16 +212,13 @@ const { positionals, values } = parseArgs({
|
|
|
14
212
|
}
|
|
15
213
|
});
|
|
16
214
|
const { team, app, "callback-path": callbackPath } = values;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const program = Effect.gen(function* () {
|
|
215
|
+
const USAGE = `Usage:
|
|
216
|
+
alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
|
|
217
|
+
alchemy-convex stack-comment [--team <team>]`;
|
|
218
|
+
const hubClient = (hub) => Effect.gen(function* () {
|
|
22
219
|
const [command, ...prefix] = yield* installedCli(process.cwd());
|
|
23
220
|
const callbackUrl = yield* registerDevelopmentClient({
|
|
24
|
-
|
|
25
|
-
app,
|
|
26
|
-
callbackPath,
|
|
221
|
+
...hub,
|
|
27
222
|
convex: (args, input) => execFileSync(command, [...prefix, ...args], {
|
|
28
223
|
encoding: "utf8",
|
|
29
224
|
input,
|
|
@@ -36,6 +231,17 @@ const program = Effect.gen(function* () {
|
|
|
36
231
|
});
|
|
37
232
|
console.log(callbackUrl === void 0 ? "Hub client already registered." : `Hub client registered for ${callbackUrl}.`);
|
|
38
233
|
}).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
|
|
234
|
+
const stackComment = (options) => updateStackComment.pipe(Effect.tap((url) => Effect.sync(() => console.log(url))), Effect.provide(state(options).pipe(Layer.provide(NodeServices.layer))));
|
|
235
|
+
const subcommand = positionals.join(" ");
|
|
236
|
+
const program = subcommand === "stack-comment" ? stackComment(team === void 0 ? {} : { team }) : subcommand === "hub-client" && team !== void 0 && app !== void 0 && callbackPath !== void 0 ? hubClient({
|
|
237
|
+
team,
|
|
238
|
+
app,
|
|
239
|
+
callbackPath
|
|
240
|
+
}) : void 0;
|
|
241
|
+
if (program === void 0) {
|
|
242
|
+
console.error(USAGE);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
39
245
|
await Effect.runPromise(program).catch((error) => {
|
|
40
246
|
console.error(error instanceof Error ? error.message : String(error));
|
|
41
247
|
process.exit(1);
|
package/lib/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n// The command of the package:\n//\n// alchemy-convex hub-client --team <team> --app <app> --callback-path <path>\n//\n// It runs in the directory of a Convex app, and gives the development deployment of that directory\n// a client of the identity hub of the team: see registerDevelopmentClient. It uses\n// CONVEX_ACCESS_TOKEN or the Convex CLI login.\nimport { execFileSync } from \"node:child_process\";\nimport { parseArgs } from \"node:util\";\nimport * as Effect from \"effect/Effect\";\nimport * as Layer from \"effect/Layer\";\nimport { fromConfig } from \"./Credentials.ts\";\nimport { registerDevelopmentClient } from \"./HubClient.ts\";\nimport { ManagementApiLive } from \"./ManagementApi.ts\";\nimport { installedCli } from \"./Push.ts\";\n\nconst { positionals, values } = parseArgs({\n allowPositionals: true,\n options: {\n team: { type: \"string\" },\n app: { type: \"string\" },\n \"callback-path\": { type: \"string\" },\n },\n});\nconst { team, app, \"callback-path\": callbackPath } = values;\nif (\n positionals.join(\" \") !== \"hub-client\" ||\n team === undefined ||\n app === undefined ||\n callbackPath === undefined\n) {\n console.error(\n \"Usage: alchemy-convex hub-client --team <team> --app <app> --callback-path <path>\",\n );\n process.exit(1);\n}\n\nconst program = Effect.gen(function* () {\n const [command, ...prefix] = yield* installedCli(process.cwd());\n const callbackUrl = yield* registerDevelopmentClient({\n team,\n app,\n callbackPath,\n convex: (args, input) =>\n execFileSync(command, [...prefix, ...args], {\n encoding: \"utf8\",\n input,\n stdio: [\"pipe\", \"pipe\", \"inherit\"],\n }).trim(),\n });\n console.log(\n callbackUrl === undefined\n ? \"Hub client already registered.\"\n : `Hub client registered for ${callbackUrl}.`,\n );\n}).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));\n\nawait Effect.runPromise(program).catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : String(error));\n process.exit(1);\n});\n"],"mappings":";;;;;;;AAiBA,MAAM,EAAE,aAAa,WAAW,UAAU;CACxC,kBAAkB;CAClB,SAAS;EACP,MAAM,EAAE,MAAM,SAAS;EACvB,KAAK,EAAE,MAAM,SAAS;EACtB,iBAAiB,EAAE,MAAM,SAAS;CACpC;AACF,CAAC;AACD,MAAM,EAAE,MAAM,KAAK,iBAAiB,iBAAiB;AACrD,IACE,YAAY,KAAK,GAAG,MAAM,gBAC1B,SAAS,KAAA,KACT,QAAQ,KAAA,KACR,iBAAiB,KAAA,GACjB;CACA,QAAQ,MACN,mFACF;CACA,QAAQ,KAAK,CAAC;AAChB;AAEA,MAAM,UAAU,OAAO,IAAI,aAAa;CACtC,MAAM,CAAC,SAAS,GAAG,UAAU,OAAO,aAAa,QAAQ,IAAI,CAAC;CAC9D,MAAM,cAAc,OAAO,0BAA0B;EACnD;EACA;EACA;EACA,SAAS,MAAM,UACb,aAAa,SAAS,CAAC,GAAG,QAAQ,GAAG,IAAI,GAAG;GAC1C,UAAU;GACV;GACA,OAAO;IAAC;IAAQ;IAAQ;GAAS;EACnC,CAAC,CAAC,CAAC,KAAK;CACZ,CAAC;CACD,QAAQ,IACN,gBAAgB,KAAA,IACZ,mCACA,6BAA6B,YAAY,EAC/C;AACF,CAAC,CAAC,CAAC,KAAK,OAAO,QAAQ,kBAAkB,CAAC,CAAC,KAAK,MAAM,QAAQ,WAAW,CAAC,CAAC,CAAC,CAAC;AAE7E,MAAM,OAAO,WAAW,OAAO,CAAC,CAAC,OAAO,UAAmB;CACzD,QAAQ,MAAM,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;CACpE,QAAQ,KAAK,CAAC;AAChB,CAAC"}
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/StackComment.ts","../src/cli.ts"],"sourcesContent":["// The command `alchemy-convex stack-comment`: one comment on a pull request with the stacks of the\n// repository, read from the state store after the deploys.\n//\n// A stack sees only itself, so a comment that a stack declares cannot show the other stacks of the\n// repository. This command runs as a step after them and reads each stack named `owner/name` or\n// `owner/name/<folder>` that has the stage `pr-<number>`. A stack without that stage has no card. A\n// run replaces the whole body, so the last run shows the newest state.\n//\n// It reads only the fields of the rows below, because Alchemy state holds secrets in clear text.\n//\n// GitHub keeps a fixed set of HTML in a comment and removes `style` and `class`, so every choice of\n// the markup is a fact about the sanitizer or the CSS of github.com:\n// - A table has `display: block` and `width: max-content`, so a `width` on it does nothing. The\n// spacer, a floated image one pixel high in a block of its own, gives every card one width, and\n// it shrinks on a narrow page.\n// - The CSS colors the even rows of a group, so each row has its own `tbody`.\n// - An HTML block ends at a blank line, so a table has none and is not indented.\n// - A bare image links to its raw file, and one link around an image and text underlines the space\n// between them, so the icon and the name are two links to one place.\n// - An image with `height` gets a gray background, so each icon is square and has only `width`.\n// - `align=\"middle\"` is `-webkit-baseline-middle` in Chrome, which is below the text.\n// `align=\"absmiddle\"` is the CSS value `middle`.\nimport { State } from \"alchemy/State\";\nimport * as Effect from \"effect/Effect\";\nimport * as Schema from \"effect/Schema\";\nimport { RUN_RECORD_KEY, runRecord } from \"./RunRecord.ts\";\n\n/** The first text of the comment, which finds it again. */\nconst MARKER = \"<!-- samebase-stacks -->\";\n\n/** GitHub does not proxy this host, so a new icon needs no purge of its image cache. */\nconst ICONS = \"https://raw.githubusercontent.com/samebase/alchemy-convex/main/assets/stack-comment\";\n\nconst GITHUB_API = \"https://api.github.com\";\n\n/** The resources that a card shows. A row of any other type, or without `attr` from an interrupted deploy, is left out. */\nconst Resource = Schema.Union([\n Schema.Struct({\n resourceType: Schema.Literal(\"Convex.Deployment\"),\n attr: Schema.Struct({ name: Schema.String, siteUrl: Schema.String }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"Cloudflare.Worker\"),\n attr: Schema.Struct({ workerName: Schema.String, url: Schema.optional(Schema.String) }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"Cloudflare.R2.Bucket\"),\n attr: Schema.Struct({\n bucketName: Schema.String,\n accountId: Schema.String,\n jurisdiction: Schema.String,\n }),\n }),\n]);\ntype Resource = typeof Resource.Type;\n\n/** The fields of the run record that a card shows. */\nconst StageOutput = Schema.Struct({\n [RUN_RECORD_KEY]: Schema.Struct({\n headCommit: Schema.String,\n runUrl: Schema.NullOr(Schema.String),\n recordedAt: Schema.String,\n }),\n});\n\nexport interface StackCard {\n readonly name: string;\n readonly stage: string;\n readonly resources: readonly Resource[];\n readonly run: typeof StageOutput.Type | null;\n}\n\nexport class StackCommentError extends Schema.TaggedError<StackCommentError>()(\n \"StackCommentError\",\n { reason: Schema.String },\n) {\n override get message() {\n return `The stack comment cannot be updated: ${this.reason}`;\n }\n}\n\n/** The cards of the stacks of `repository` that have `stage`, in name order. */\nexport const readStackCards = ({ repository, stage }: { repository: string; stage: string }) =>\n Effect.gen(function* () {\n const state = yield* yield* State;\n const names = (yield* state.listStacks()).filter(\n (name) => name === repository || name.startsWith(`${repository}/`),\n );\n const cards: StackCard[] = [];\n for (const name of names) {\n if (!(yield* state.listStages(name)).includes(stage)) {\n continue;\n }\n const request = { stack: name, stage };\n const rows = yield* Effect.forEach(yield* state.list(request), (fqn) =>\n state.get({ ...request, fqn }),\n );\n const output = yield* state.getOutput(request);\n cards.push({\n name,\n stage,\n resources: rows.filter(Schema.is(Resource)),\n run: Schema.is(StageOutput)(output) ? output : null,\n });\n }\n return cards;\n });\n\n/** A stack can create a deployment in one resource and push to it in another: a card shows it once. */\nconst withoutRepeats = (resources: readonly Resource[]) => {\n const deployments = new Set<string>();\n return resources.filter((resource) => {\n if (resource.resourceType !== \"Convex.Deployment\") {\n return true;\n }\n const repeated = deployments.has(resource.attr.name);\n deployments.add(resource.attr.name);\n return !repeated;\n });\n};\n\nconst escapeHtml = (text: string) =>\n text\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\");\n\nconst link = (href: string, text: string) => `<a href=\"${escapeHtml(href)}\">${text}</a>`;\n\nconst named = (href: string, icon: string, alt: string, name: string) => {\n const image = `<img src=\"${ICONS}/${icon}.svg\" width=\"16\" align=\"absmiddle\" alt=\"${alt}\">`;\n return `${link(href, image)} ${link(href, escapeHtml(name))}`;\n};\n\nconst hostOf = (url: string) => escapeHtml(new URL(url).host);\n\n/** Where a stack serves: its Convex deployment, else its first Worker with a URL. */\nconst addressOf = (resources: readonly Resource[]) => {\n for (const resource of resources) {\n if (resource.resourceType === \"Convex.Deployment\") {\n return resource.attr.siteUrl;\n }\n }\n for (const resource of resources) {\n if (resource.resourceType === \"Cloudflare.Worker\" && resource.attr.url !== undefined) {\n return resource.attr.url;\n }\n }\n return null;\n};\n\nconst resourceRow = (resource: Resource, address: string | null) => {\n switch (resource.resourceType) {\n case \"Convex.Deployment\": {\n const { name } = resource.attr;\n return named(\n `https://dashboard.convex.dev/d/${encodeURIComponent(name)}`,\n \"convex\",\n \"Convex\",\n name,\n );\n }\n case \"Cloudflare.Worker\": {\n const { url, workerName } = resource.attr;\n const row = named(\n `https://dash.cloudflare.com/?to=/:account/workers/services/view/${encodeURIComponent(workerName)}`,\n \"worker\",\n \"Worker\",\n workerName,\n );\n return url === undefined || url === address\n ? row\n : `${row}<br><sub>${link(url, hostOf(url))}</sub>`;\n }\n case \"Cloudflare.R2.Bucket\": {\n const { accountId, bucketName, jurisdiction } = resource.attr;\n return named(\n `https://dash.cloudflare.com/${encodeURIComponent(accountId)}/r2/${encodeURIComponent(jurisdiction)}/buckets/${encodeURIComponent(bucketName)}`,\n \"r2\",\n \"R2 bucket\",\n bucketName,\n );\n }\n }\n};\n\nconst runLine = ({ run }: StackCard) => {\n if (run === null) {\n return \"\";\n }\n const { headCommit, recordedAt, runUrl } = run[RUN_RECORD_KEY];\n const commit = escapeHtml(headCommit.slice(0, 7));\n const time = `${recordedAt.slice(0, 16).replace(\"T\", \" \")} UTC`;\n return `<br><sub>${runUrl === null ? commit : link(runUrl, commit)} ${escapeHtml(time)}</sub>`;\n};\n\nconst SPACER = `<div><img src=\"${ICONS}/spacer.svg\" width=\"460\" align=\"left\" alt=\"\"></div>`;\n\n/** `alone`: the one card of the repository. A card at the root then has no folder, as on the repository page. */\nconst renderCard = (card: StackCard, repository: string, alone: boolean) => {\n const folder = card.name.slice(repository.length + 1);\n const heading = [\n folder === \"\" && alone ? \"\" : `<b>${escapeHtml(folder || \"/\")}</b> `,\n `<sub>${escapeHtml(card.stage)}</sub>`,\n ].join(\"\");\n const resources = withoutRepeats(card.resources);\n const address = addressOf(resources);\n const addressLine = address === null ? \"\" : `<br>${link(address, hostOf(address))}`;\n const rows = resources\n .map((resource) => `<tbody><tr><td>${resourceRow(resource, address)}</td></tr></tbody>`)\n .join(\"\\n\");\n return `<table>\n<tbody><tr><td>${SPACER}${heading}${addressLine}${runLine(card)}</td></tr></tbody>\n${rows}\n</table>`;\n};\n\nexport const renderStackComment = (cards: readonly StackCard[], repository: string) => `${MARKER}\n${cards.map((card) => renderCard(card, repository, cards.length === 1)).join(\"\\n\\n\")}\n`;\n\nconst Comments = Schema.Array(Schema.Struct({ id: Schema.Number, body: Schema.String }));\nconst SavedComment = Schema.Struct({ html_url: Schema.String });\n\nconst github = (token: string, path: string, init: { method: string; body?: string }) =>\n Effect.tryPromise({\n try: async (): Promise<unknown> => {\n const response = await fetch(`${GITHUB_API}${path}`, {\n ...init,\n headers: {\n Accept: \"application/vnd.github+json\",\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n });\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}`);\n }\n return await response.json();\n },\n catch: (error) =>\n new StackCommentError({\n reason: `GitHub ${init.method} ${path} failed (${error instanceof Error ? error.message : String(error)})`,\n }),\n });\n\nconst decode =\n <S extends Schema.Codec<unknown, unknown>>(schema: S, what: string) =>\n (value: unknown) =>\n Schema.decodeUnknownEffect(schema)(value).pipe(\n Effect.mapError(() => new StackCommentError({ reason: `GitHub gave a bad ${what}` })),\n );\n\nconst findComment = (token: string, path: string) =>\n Effect.gen(function* () {\n for (let page = 1; ; page += 1) {\n const comments = yield* github(token, `${path}?per_page=100&page=${page}`, {\n method: \"GET\",\n }).pipe(Effect.flatMap(decode(Comments, \"comment list\")));\n const comment = comments.find(({ body }) => body.startsWith(MARKER));\n if (comment !== undefined || comments.length < 100) {\n return comment;\n }\n }\n });\n\n/** Replaces the body of the comment of this command, or posts it on the first run, and gives its URL. */\nexport const saveStackComment = (args: {\n readonly token: string;\n readonly repository: string;\n readonly pullRequest: number;\n readonly body: string;\n}) =>\n Effect.gen(function* () {\n const { body, pullRequest, repository, token } = args;\n const comments = `/repos/${repository}/issues/${pullRequest}/comments`;\n const comment = yield* findComment(token, comments);\n const reply = yield* comment === undefined\n ? github(token, comments, { method: \"POST\", body: JSON.stringify({ body }) })\n : github(token, `/repos/${repository}/issues/comments/${comment.id}`, {\n method: \"PATCH\",\n body: JSON.stringify({ body }),\n });\n const saved = yield* decode(SavedComment, \"comment\")(reply);\n return saved.html_url;\n });\n\n/** The step of a workflow: the run record names the repository and the pull request of the run. */\nexport const updateStackComment = Effect.gen(function* () {\n const { pullRequest, repository } = yield* runRecord({ cwd: process.cwd(), env: process.env });\n if (pullRequest === null) {\n return yield* new StackCommentError({ reason: \"this run has no pull request\" });\n }\n const token = process.env[\"GITHUB_TOKEN\"];\n if (token === undefined || token === \"\") {\n return yield* new StackCommentError({ reason: \"GITHUB_TOKEN is not set\" });\n }\n const cards = yield* readStackCards({ repository, stage: `pr-${pullRequest}` });\n return yield* saveStackComment({\n token,\n repository,\n pullRequest,\n body: renderStackComment(cards, repository),\n });\n});\n","#!/usr/bin/env node\n// The commands of the package:\n//\n// alchemy-convex hub-client --team <team> --app <app> --callback-path <path>\n// alchemy-convex stack-comment [--team <team>]\n//\n// hub-client runs in the directory of a Convex app, and gives the development deployment of that\n// directory a client of the identity hub of the team: see registerDevelopmentClient. stack-comment\n// runs as a step of a pull request workflow after the deploys, and keeps one comment with the stacks\n// of the repository: see src/StackComment.ts. It needs GITHUB_TOKEN and, with a team access token,\n// no --team. Both use CONVEX_ACCESS_TOKEN or the Convex CLI login.\nimport { execFileSync } from \"node:child_process\";\nimport { parseArgs } from \"node:util\";\nimport * as NodeServices from \"@effect/platform-node/NodeServices\";\nimport * as Effect from \"effect/Effect\";\nimport * as Layer from \"effect/Layer\";\nimport { fromConfig } from \"./Credentials.ts\";\nimport { registerDevelopmentClient } from \"./HubClient.ts\";\nimport { ManagementApiLive } from \"./ManagementApi.ts\";\nimport { installedCli } from \"./Push.ts\";\nimport { updateStackComment } from \"./StackComment.ts\";\nimport { state } from \"./State.ts\";\n\nconst { positionals, values } = parseArgs({\n allowPositionals: true,\n options: {\n team: { type: \"string\" },\n app: { type: \"string\" },\n \"callback-path\": { type: \"string\" },\n },\n});\nconst { team, app, \"callback-path\": callbackPath } = values;\nconst USAGE = `Usage:\n alchemy-convex hub-client --team <team> --app <app> --callback-path <path>\n alchemy-convex stack-comment [--team <team>]`;\n\nconst hubClient = (hub: { team: string; app: string; callbackPath: string }) =>\n Effect.gen(function* () {\n const [command, ...prefix] = yield* installedCli(process.cwd());\n const callbackUrl = yield* registerDevelopmentClient({\n ...hub,\n convex: (args, input) =>\n execFileSync(command, [...prefix, ...args], {\n encoding: \"utf8\",\n input,\n stdio: [\"pipe\", \"pipe\", \"inherit\"],\n }).trim(),\n });\n console.log(\n callbackUrl === undefined\n ? \"Hub client already registered.\"\n : `Hub client registered for ${callbackUrl}.`,\n );\n }).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));\n\nconst stackComment = (options: { team?: string }) =>\n updateStackComment.pipe(\n Effect.tap((url) => Effect.sync(() => console.log(url))),\n Effect.provide(state(options).pipe(Layer.provide(NodeServices.layer))),\n );\n\nconst subcommand = positionals.join(\" \");\nconst program: Effect.Effect<unknown, unknown> | undefined =\n subcommand === \"stack-comment\"\n ? stackComment(team === undefined ? {} : { team })\n : subcommand === \"hub-client\" &&\n team !== undefined &&\n app !== undefined &&\n callbackPath !== undefined\n ? hubClient({ team, app, callbackPath })\n : undefined;\nif (program === undefined) {\n console.error(USAGE);\n process.exit(1);\n}\n\nawait Effect.runPromise(program).catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : String(error));\n process.exit(1);\n});\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// The command `alchemy-convex stack-comment`: one comment on a pull request with the stacks of the
|
|
2
|
+
// repository, read from the state store after the deploys.
|
|
3
|
+
//
|
|
4
|
+
// A stack sees only itself, so a comment that a stack declares cannot show the other stacks of the
|
|
5
|
+
// repository. This command runs as a step after them and reads each stack named `owner/name` or
|
|
6
|
+
// `owner/name/<folder>` that has the stage `pr-<number>`. A stack without that stage has no card. A
|
|
7
|
+
// run replaces the whole body, so the last run shows the newest state.
|
|
8
|
+
//
|
|
9
|
+
// It reads only the fields of the rows below, because Alchemy state holds secrets in clear text.
|
|
10
|
+
//
|
|
11
|
+
// GitHub keeps a fixed set of HTML in a comment and removes `style` and `class`, so every choice of
|
|
12
|
+
// the markup is a fact about the sanitizer or the CSS of github.com:
|
|
13
|
+
// - A table has `display: block` and `width: max-content`, so a `width` on it does nothing. The
|
|
14
|
+
// spacer, a floated image one pixel high in a block of its own, gives every card one width, and
|
|
15
|
+
// it shrinks on a narrow page.
|
|
16
|
+
// - The CSS colors the even rows of a group, so each row has its own `tbody`.
|
|
17
|
+
// - An HTML block ends at a blank line, so a table has none and is not indented.
|
|
18
|
+
// - A bare image links to its raw file, and one link around an image and text underlines the space
|
|
19
|
+
// between them, so the icon and the name are two links to one place.
|
|
20
|
+
// - An image with `height` gets a gray background, so each icon is square and has only `width`.
|
|
21
|
+
// - `align="middle"` is `-webkit-baseline-middle` in Chrome, which is below the text.
|
|
22
|
+
// `align="absmiddle"` is the CSS value `middle`.
|
|
23
|
+
import { State } from "alchemy/State";
|
|
24
|
+
import * as Effect from "effect/Effect";
|
|
25
|
+
import * as Schema from "effect/Schema";
|
|
26
|
+
import { RUN_RECORD_KEY, runRecord } from "./RunRecord.ts";
|
|
27
|
+
|
|
28
|
+
/** The first text of the comment, which finds it again. */
|
|
29
|
+
const MARKER = "<!-- samebase-stacks -->";
|
|
30
|
+
|
|
31
|
+
/** GitHub does not proxy this host, so a new icon needs no purge of its image cache. */
|
|
32
|
+
const ICONS = "https://raw.githubusercontent.com/samebase/alchemy-convex/main/assets/stack-comment";
|
|
33
|
+
|
|
34
|
+
const GITHUB_API = "https://api.github.com";
|
|
35
|
+
|
|
36
|
+
/** The resources that a card shows. A row of any other type, or without `attr` from an interrupted deploy, is left out. */
|
|
37
|
+
const Resource = Schema.Union([
|
|
38
|
+
Schema.Struct({
|
|
39
|
+
resourceType: Schema.Literal("Convex.Deployment"),
|
|
40
|
+
attr: Schema.Struct({ name: Schema.String, siteUrl: Schema.String }),
|
|
41
|
+
}),
|
|
42
|
+
Schema.Struct({
|
|
43
|
+
resourceType: Schema.Literal("Cloudflare.Worker"),
|
|
44
|
+
attr: Schema.Struct({ workerName: Schema.String, url: Schema.optional(Schema.String) }),
|
|
45
|
+
}),
|
|
46
|
+
Schema.Struct({
|
|
47
|
+
resourceType: Schema.Literal("Cloudflare.R2.Bucket"),
|
|
48
|
+
attr: Schema.Struct({
|
|
49
|
+
bucketName: Schema.String,
|
|
50
|
+
accountId: Schema.String,
|
|
51
|
+
jurisdiction: Schema.String,
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
]);
|
|
55
|
+
type Resource = typeof Resource.Type;
|
|
56
|
+
|
|
57
|
+
/** The fields of the run record that a card shows. */
|
|
58
|
+
const StageOutput = Schema.Struct({
|
|
59
|
+
[RUN_RECORD_KEY]: Schema.Struct({
|
|
60
|
+
headCommit: Schema.String,
|
|
61
|
+
runUrl: Schema.NullOr(Schema.String),
|
|
62
|
+
recordedAt: Schema.String,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export interface StackCard {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly stage: string;
|
|
69
|
+
readonly resources: readonly Resource[];
|
|
70
|
+
readonly run: typeof StageOutput.Type | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class StackCommentError extends Schema.TaggedError<StackCommentError>()(
|
|
74
|
+
"StackCommentError",
|
|
75
|
+
{ reason: Schema.String },
|
|
76
|
+
) {
|
|
77
|
+
override get message() {
|
|
78
|
+
return `The stack comment cannot be updated: ${this.reason}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The cards of the stacks of `repository` that have `stage`, in name order. */
|
|
83
|
+
export const readStackCards = ({ repository, stage }: { repository: string; stage: string }) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const state = yield* yield* State;
|
|
86
|
+
const names = (yield* state.listStacks()).filter(
|
|
87
|
+
(name) => name === repository || name.startsWith(`${repository}/`),
|
|
88
|
+
);
|
|
89
|
+
const cards: StackCard[] = [];
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
if (!(yield* state.listStages(name)).includes(stage)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const request = { stack: name, stage };
|
|
95
|
+
const rows = yield* Effect.forEach(yield* state.list(request), (fqn) =>
|
|
96
|
+
state.get({ ...request, fqn }),
|
|
97
|
+
);
|
|
98
|
+
const output = yield* state.getOutput(request);
|
|
99
|
+
cards.push({
|
|
100
|
+
name,
|
|
101
|
+
stage,
|
|
102
|
+
resources: rows.filter(Schema.is(Resource)),
|
|
103
|
+
run: Schema.is(StageOutput)(output) ? output : null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return cards;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** A stack can create a deployment in one resource and push to it in another: a card shows it once. */
|
|
110
|
+
const withoutRepeats = (resources: readonly Resource[]) => {
|
|
111
|
+
const deployments = new Set<string>();
|
|
112
|
+
return resources.filter((resource) => {
|
|
113
|
+
if (resource.resourceType !== "Convex.Deployment") {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
const repeated = deployments.has(resource.attr.name);
|
|
117
|
+
deployments.add(resource.attr.name);
|
|
118
|
+
return !repeated;
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const escapeHtml = (text: string) =>
|
|
123
|
+
text
|
|
124
|
+
.replaceAll("&", "&")
|
|
125
|
+
.replaceAll("<", "<")
|
|
126
|
+
.replaceAll(">", ">")
|
|
127
|
+
.replaceAll('"', """);
|
|
128
|
+
|
|
129
|
+
const link = (href: string, text: string) => `<a href="${escapeHtml(href)}">${text}</a>`;
|
|
130
|
+
|
|
131
|
+
const named = (href: string, icon: string, alt: string, name: string) => {
|
|
132
|
+
const image = `<img src="${ICONS}/${icon}.svg" width="16" align="absmiddle" alt="${alt}">`;
|
|
133
|
+
return `${link(href, image)} ${link(href, escapeHtml(name))}`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const hostOf = (url: string) => escapeHtml(new URL(url).host);
|
|
137
|
+
|
|
138
|
+
/** Where a stack serves: its Convex deployment, else its first Worker with a URL. */
|
|
139
|
+
const addressOf = (resources: readonly Resource[]) => {
|
|
140
|
+
for (const resource of resources) {
|
|
141
|
+
if (resource.resourceType === "Convex.Deployment") {
|
|
142
|
+
return resource.attr.siteUrl;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const resource of resources) {
|
|
146
|
+
if (resource.resourceType === "Cloudflare.Worker" && resource.attr.url !== undefined) {
|
|
147
|
+
return resource.attr.url;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const resourceRow = (resource: Resource, address: string | null) => {
|
|
154
|
+
switch (resource.resourceType) {
|
|
155
|
+
case "Convex.Deployment": {
|
|
156
|
+
const { name } = resource.attr;
|
|
157
|
+
return named(
|
|
158
|
+
`https://dashboard.convex.dev/d/${encodeURIComponent(name)}`,
|
|
159
|
+
"convex",
|
|
160
|
+
"Convex",
|
|
161
|
+
name,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
case "Cloudflare.Worker": {
|
|
165
|
+
const { url, workerName } = resource.attr;
|
|
166
|
+
const row = named(
|
|
167
|
+
`https://dash.cloudflare.com/?to=/:account/workers/services/view/${encodeURIComponent(workerName)}`,
|
|
168
|
+
"worker",
|
|
169
|
+
"Worker",
|
|
170
|
+
workerName,
|
|
171
|
+
);
|
|
172
|
+
return url === undefined || url === address
|
|
173
|
+
? row
|
|
174
|
+
: `${row}<br><sub>${link(url, hostOf(url))}</sub>`;
|
|
175
|
+
}
|
|
176
|
+
case "Cloudflare.R2.Bucket": {
|
|
177
|
+
const { accountId, bucketName, jurisdiction } = resource.attr;
|
|
178
|
+
return named(
|
|
179
|
+
`https://dash.cloudflare.com/${encodeURIComponent(accountId)}/r2/${encodeURIComponent(jurisdiction)}/buckets/${encodeURIComponent(bucketName)}`,
|
|
180
|
+
"r2",
|
|
181
|
+
"R2 bucket",
|
|
182
|
+
bucketName,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const runLine = ({ run }: StackCard) => {
|
|
189
|
+
if (run === null) {
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
192
|
+
const { headCommit, recordedAt, runUrl } = run[RUN_RECORD_KEY];
|
|
193
|
+
const commit = escapeHtml(headCommit.slice(0, 7));
|
|
194
|
+
const time = `${recordedAt.slice(0, 16).replace("T", " ")} UTC`;
|
|
195
|
+
return `<br><sub>${runUrl === null ? commit : link(runUrl, commit)} ${escapeHtml(time)}</sub>`;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const SPACER = `<div><img src="${ICONS}/spacer.svg" width="460" align="left" alt=""></div>`;
|
|
199
|
+
|
|
200
|
+
/** `alone`: the one card of the repository. A card at the root then has no folder, as on the repository page. */
|
|
201
|
+
const renderCard = (card: StackCard, repository: string, alone: boolean) => {
|
|
202
|
+
const folder = card.name.slice(repository.length + 1);
|
|
203
|
+
const heading = [
|
|
204
|
+
folder === "" && alone ? "" : `<b>${escapeHtml(folder || "/")}</b> `,
|
|
205
|
+
`<sub>${escapeHtml(card.stage)}</sub>`,
|
|
206
|
+
].join("");
|
|
207
|
+
const resources = withoutRepeats(card.resources);
|
|
208
|
+
const address = addressOf(resources);
|
|
209
|
+
const addressLine = address === null ? "" : `<br>${link(address, hostOf(address))}`;
|
|
210
|
+
const rows = resources
|
|
211
|
+
.map((resource) => `<tbody><tr><td>${resourceRow(resource, address)}</td></tr></tbody>`)
|
|
212
|
+
.join("\n");
|
|
213
|
+
return `<table>
|
|
214
|
+
<tbody><tr><td>${SPACER}${heading}${addressLine}${runLine(card)}</td></tr></tbody>
|
|
215
|
+
${rows}
|
|
216
|
+
</table>`;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const renderStackComment = (cards: readonly StackCard[], repository: string) => `${MARKER}
|
|
220
|
+
${cards.map((card) => renderCard(card, repository, cards.length === 1)).join("\n\n")}
|
|
221
|
+
`;
|
|
222
|
+
|
|
223
|
+
const Comments = Schema.Array(Schema.Struct({ id: Schema.Number, body: Schema.String }));
|
|
224
|
+
const SavedComment = Schema.Struct({ html_url: Schema.String });
|
|
225
|
+
|
|
226
|
+
const github = (token: string, path: string, init: { method: string; body?: string }) =>
|
|
227
|
+
Effect.tryPromise({
|
|
228
|
+
try: async (): Promise<unknown> => {
|
|
229
|
+
const response = await fetch(`${GITHUB_API}${path}`, {
|
|
230
|
+
...init,
|
|
231
|
+
headers: {
|
|
232
|
+
Accept: "application/vnd.github+json",
|
|
233
|
+
Authorization: `Bearer ${token}`,
|
|
234
|
+
"Content-Type": "application/json",
|
|
235
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
throw new Error(`HTTP ${response.status}`);
|
|
240
|
+
}
|
|
241
|
+
return await response.json();
|
|
242
|
+
},
|
|
243
|
+
catch: (error) =>
|
|
244
|
+
new StackCommentError({
|
|
245
|
+
reason: `GitHub ${init.method} ${path} failed (${error instanceof Error ? error.message : String(error)})`,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const decode =
|
|
250
|
+
<S extends Schema.Codec<unknown, unknown>>(schema: S, what: string) =>
|
|
251
|
+
(value: unknown) =>
|
|
252
|
+
Schema.decodeUnknownEffect(schema)(value).pipe(
|
|
253
|
+
Effect.mapError(() => new StackCommentError({ reason: `GitHub gave a bad ${what}` })),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const findComment = (token: string, path: string) =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
for (let page = 1; ; page += 1) {
|
|
259
|
+
const comments = yield* github(token, `${path}?per_page=100&page=${page}`, {
|
|
260
|
+
method: "GET",
|
|
261
|
+
}).pipe(Effect.flatMap(decode(Comments, "comment list")));
|
|
262
|
+
const comment = comments.find(({ body }) => body.startsWith(MARKER));
|
|
263
|
+
if (comment !== undefined || comments.length < 100) {
|
|
264
|
+
return comment;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/** Replaces the body of the comment of this command, or posts it on the first run, and gives its URL. */
|
|
270
|
+
export const saveStackComment = (args: {
|
|
271
|
+
readonly token: string;
|
|
272
|
+
readonly repository: string;
|
|
273
|
+
readonly pullRequest: number;
|
|
274
|
+
readonly body: string;
|
|
275
|
+
}) =>
|
|
276
|
+
Effect.gen(function* () {
|
|
277
|
+
const { body, pullRequest, repository, token } = args;
|
|
278
|
+
const comments = `/repos/${repository}/issues/${pullRequest}/comments`;
|
|
279
|
+
const comment = yield* findComment(token, comments);
|
|
280
|
+
const reply = yield* comment === undefined
|
|
281
|
+
? github(token, comments, { method: "POST", body: JSON.stringify({ body }) })
|
|
282
|
+
: github(token, `/repos/${repository}/issues/comments/${comment.id}`, {
|
|
283
|
+
method: "PATCH",
|
|
284
|
+
body: JSON.stringify({ body }),
|
|
285
|
+
});
|
|
286
|
+
const saved = yield* decode(SavedComment, "comment")(reply);
|
|
287
|
+
return saved.html_url;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/** The step of a workflow: the run record names the repository and the pull request of the run. */
|
|
291
|
+
export const updateStackComment = Effect.gen(function* () {
|
|
292
|
+
const { pullRequest, repository } = yield* runRecord({ cwd: process.cwd(), env: process.env });
|
|
293
|
+
if (pullRequest === null) {
|
|
294
|
+
return yield* new StackCommentError({ reason: "this run has no pull request" });
|
|
295
|
+
}
|
|
296
|
+
const token = process.env["GITHUB_TOKEN"];
|
|
297
|
+
if (token === undefined || token === "") {
|
|
298
|
+
return yield* new StackCommentError({ reason: "GITHUB_TOKEN is not set" });
|
|
299
|
+
}
|
|
300
|
+
const cards = yield* readStackCards({ repository, stage: `pr-${pullRequest}` });
|
|
301
|
+
return yield* saveStackComment({
|
|
302
|
+
token,
|
|
303
|
+
repository,
|
|
304
|
+
pullRequest,
|
|
305
|
+
body: renderStackComment(cards, repository),
|
|
306
|
+
});
|
|
307
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The
|
|
2
|
+
// The commands of the package:
|
|
3
3
|
//
|
|
4
4
|
// alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
|
|
5
|
+
// alchemy-convex stack-comment [--team <team>]
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
// a client of the identity hub of the team: see registerDevelopmentClient.
|
|
8
|
-
//
|
|
7
|
+
// hub-client runs in the directory of a Convex app, and gives the development deployment of that
|
|
8
|
+
// directory a client of the identity hub of the team: see registerDevelopmentClient. stack-comment
|
|
9
|
+
// runs as a step of a pull request workflow after the deploys, and keeps one comment with the stacks
|
|
10
|
+
// of the repository: see src/StackComment.ts. It needs GITHUB_TOKEN and, with a team access token,
|
|
11
|
+
// no --team. Both use CONVEX_ACCESS_TOKEN or the Convex CLI login.
|
|
9
12
|
import { execFileSync } from "node:child_process";
|
|
10
13
|
import { parseArgs } from "node:util";
|
|
14
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
11
15
|
import * as Effect from "effect/Effect";
|
|
12
16
|
import * as Layer from "effect/Layer";
|
|
13
17
|
import { fromConfig } from "./Credentials.ts";
|
|
14
18
|
import { registerDevelopmentClient } from "./HubClient.ts";
|
|
15
19
|
import { ManagementApiLive } from "./ManagementApi.ts";
|
|
16
20
|
import { installedCli } from "./Push.ts";
|
|
21
|
+
import { updateStackComment } from "./StackComment.ts";
|
|
22
|
+
import { state } from "./State.ts";
|
|
17
23
|
|
|
18
24
|
const { positionals, values } = parseArgs({
|
|
19
25
|
allowPositionals: true,
|
|
@@ -24,38 +30,50 @@ const { positionals, values } = parseArgs({
|
|
|
24
30
|
},
|
|
25
31
|
});
|
|
26
32
|
const { team, app, "callback-path": callbackPath } = values;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const USAGE = `Usage:
|
|
34
|
+
alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
|
|
35
|
+
alchemy-convex stack-comment [--team <team>]`;
|
|
36
|
+
|
|
37
|
+
const hubClient = (hub: { team: string; app: string; callbackPath: string }) =>
|
|
38
|
+
Effect.gen(function* () {
|
|
39
|
+
const [command, ...prefix] = yield* installedCli(process.cwd());
|
|
40
|
+
const callbackUrl = yield* registerDevelopmentClient({
|
|
41
|
+
...hub,
|
|
42
|
+
convex: (args, input) =>
|
|
43
|
+
execFileSync(command, [...prefix, ...args], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
input,
|
|
46
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
47
|
+
}).trim(),
|
|
48
|
+
});
|
|
49
|
+
console.log(
|
|
50
|
+
callbackUrl === undefined
|
|
51
|
+
? "Hub client already registered."
|
|
52
|
+
: `Hub client registered for ${callbackUrl}.`,
|
|
53
|
+
);
|
|
54
|
+
}).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
|
|
55
|
+
|
|
56
|
+
const stackComment = (options: { team?: string }) =>
|
|
57
|
+
updateStackComment.pipe(
|
|
58
|
+
Effect.tap((url) => Effect.sync(() => console.log(url))),
|
|
59
|
+
Effect.provide(state(options).pipe(Layer.provide(NodeServices.layer))),
|
|
35
60
|
);
|
|
61
|
+
|
|
62
|
+
const subcommand = positionals.join(" ");
|
|
63
|
+
const program: Effect.Effect<unknown, unknown> | undefined =
|
|
64
|
+
subcommand === "stack-comment"
|
|
65
|
+
? stackComment(team === undefined ? {} : { team })
|
|
66
|
+
: subcommand === "hub-client" &&
|
|
67
|
+
team !== undefined &&
|
|
68
|
+
app !== undefined &&
|
|
69
|
+
callbackPath !== undefined
|
|
70
|
+
? hubClient({ team, app, callbackPath })
|
|
71
|
+
: undefined;
|
|
72
|
+
if (program === undefined) {
|
|
73
|
+
console.error(USAGE);
|
|
36
74
|
process.exit(1);
|
|
37
75
|
}
|
|
38
76
|
|
|
39
|
-
const program = Effect.gen(function* () {
|
|
40
|
-
const [command, ...prefix] = yield* installedCli(process.cwd());
|
|
41
|
-
const callbackUrl = yield* registerDevelopmentClient({
|
|
42
|
-
team,
|
|
43
|
-
app,
|
|
44
|
-
callbackPath,
|
|
45
|
-
convex: (args, input) =>
|
|
46
|
-
execFileSync(command, [...prefix, ...args], {
|
|
47
|
-
encoding: "utf8",
|
|
48
|
-
input,
|
|
49
|
-
stdio: ["pipe", "pipe", "inherit"],
|
|
50
|
-
}).trim(),
|
|
51
|
-
});
|
|
52
|
-
console.log(
|
|
53
|
-
callbackUrl === undefined
|
|
54
|
-
? "Hub client already registered."
|
|
55
|
-
: `Hub client registered for ${callbackUrl}.`,
|
|
56
|
-
);
|
|
57
|
-
}).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
|
|
58
|
-
|
|
59
77
|
await Effect.runPromise(program).catch((error: unknown) => {
|
|
60
78
|
console.error(error instanceof Error ? error.message : String(error));
|
|
61
79
|
process.exit(1);
|