@storyshelf/notify-git 0.0.0-stage → 0.6.2

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -2
  3. package/dist/describe.mjs +21 -0
  4. package/dist/describe.mjs.map +1 -0
  5. package/dist/github-api.mjs +25 -0
  6. package/dist/github-api.mjs.map +1 -0
  7. package/dist/github-app.mjs +67 -0
  8. package/dist/github-app.mjs.map +1 -0
  9. package/dist/github-check-mapper.mjs +69 -0
  10. package/dist/github-check-mapper.mjs.map +1 -0
  11. package/dist/github-check.mjs +86 -0
  12. package/dist/github-check.mjs.map +1 -0
  13. package/dist/github-comment.mjs +58 -0
  14. package/dist/github-comment.mjs.map +1 -0
  15. package/dist/github-config.d.mts +24 -0
  16. package/dist/github-config.mjs +31 -0
  17. package/dist/github-config.mjs.map +1 -0
  18. package/dist/github-mapper.mjs +22 -0
  19. package/dist/github-mapper.mjs.map +1 -0
  20. package/dist/github-pr.mjs +20 -0
  21. package/dist/github-pr.mjs.map +1 -0
  22. package/dist/github-status.mjs +44 -0
  23. package/dist/github-status.mjs.map +1 -0
  24. package/dist/github.d.mts +13 -0
  25. package/dist/github.mjs +66 -0
  26. package/dist/github.mjs.map +1 -0
  27. package/dist/gitlab-api.mjs +23 -0
  28. package/dist/gitlab-api.mjs.map +1 -0
  29. package/dist/gitlab-comment.mjs +65 -0
  30. package/dist/gitlab-comment.mjs.map +1 -0
  31. package/dist/gitlab-config.d.mts +14 -0
  32. package/dist/gitlab-config.mjs +17 -0
  33. package/dist/gitlab-config.mjs.map +1 -0
  34. package/dist/gitlab-mapper.mjs +19 -0
  35. package/dist/gitlab-mapper.mjs.map +1 -0
  36. package/dist/gitlab-pr.mjs +19 -0
  37. package/dist/gitlab-pr.mjs.map +1 -0
  38. package/dist/gitlab-status.mjs +47 -0
  39. package/dist/gitlab-status.mjs.map +1 -0
  40. package/dist/gitlab.d.mts +9 -0
  41. package/dist/gitlab.mjs +50 -0
  42. package/dist/gitlab.mjs.map +1 -0
  43. package/dist/index.d.mts +13 -0
  44. package/dist/index.mjs +23 -0
  45. package/dist/index.mjs.map +1 -0
  46. package/dist/log.d.mts +12 -0
  47. package/dist/log.mjs +42 -0
  48. package/dist/log.mjs.map +1 -0
  49. package/dist/prefix.mjs +14 -0
  50. package/dist/prefix.mjs.map +1 -0
  51. package/dist/review-comment.mjs +54 -0
  52. package/dist/review-comment.mjs.map +1 -0
  53. package/dist/state-adapter.mjs +43 -0
  54. package/dist/state-adapter.mjs.map +1 -0
  55. package/dist/version.mjs +12 -0
  56. package/dist/version.mjs.map +1 -0
  57. package/package.json +80 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siddhant Gupta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @storyshelf/notify-git
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Git notification providers for StoryShelf: a `state` channel makes GitHub or GitLab show the current state of a build as a commit status (the merge gate) and a single pull-request or merge-request comment. Zero runtime dependencies — every call goes through `@storyshelf/core/utils` (`httpJson`: timeout + retry with `Retry-After`).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ nub add @storyshelf/notify-git
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createShelfApp } from "@storyshelf/app";
15
+ import { gitNotifiers } from "@storyshelf/notify-git";
16
+
17
+ const app = createShelfApp({ database, storage, notifications: { notifiers: gitNotifiers } });
18
+ ```
19
+
20
+ `gitNotifiers` lists `gitHubNotifier` (`github`), `gitLabNotifier` (`gitlab`) and `gitLogNotifier` (`git-log`, writes to the server log). Combine it with other families: `notifiers: [...chatNotifiers, ...gitNotifiers]`.
21
+
22
+ Project admins then add a git channel on the project's **Notifications** settings page (or through `POST /api/v1/projects/{slug}/notification-channels`):
23
+
24
+ - `config`: `{ owner, repo, prefix? }` for GitHub; GitLab also takes `host` (self-managed hosts). `prefix` names the statuses (`<prefix>/testing`, default `storyshelf`); give each project that shares a repository its own prefix.
25
+ - `secret`: the token (GitHub: `repo:status`; GitLab: `api`), stored encrypted and decrypted in memory only at delivery time.
26
+ - GitHub only, `mode`: `statuses` (default; commit statuses with the token) or `checks` (check runs posted by a GitHub App). In `checks` mode `config` also holds `appId` and `installationId`, and the secret is the App's **private key** (PEM) instead of a token. The key is never returned by the API or shown in the UI. GitLab always uses commit statuses.
27
+
28
+ A git channel is project-scoped and its event filter is fixed: it follows the build's state, not a topic list.
29
+
30
+ ## Providers
31
+
32
+ `gitNotifiers` aggregates all three providers:
33
+
34
+ | Provider kind | Writes to | Use for |
35
+ | ------------- | ------------------------------------------------ | --------------------------- |
36
+ | `github` | GitHub commit statuses and pull-request comments | GitHub.com repositories |
37
+ | `gitlab` | GitLab commit statuses and merge-request notes | GitLab.com or self-managed |
38
+ | `git-log` | The server log | Tests and local development |
39
+
40
+ The log provider's kind is `git-log` because `notify-chat` already owns `log`; provider kinds are unique across packages.
41
+
42
+ ## Delivery contract
43
+
44
+ A `state` provider (`metadata.delivery: "state"`) creates an adapter with one method, `setState({ channel, target, state, url })`:
45
+
46
+ - `target: { kind: "status", sha, context, description? }` — set the commit status named exactly `context` (the caller owns the prefix; the provider adds nothing).
47
+ - `target: { kind: "comment", sha, markdown }` — create or update the one comment per build (keyed by a hidden marker built from `url`) on the pull request of the commit; skipped when the commit has no pull request.
48
+ - A status target may carry `progress` (`queued` | `running`, for a `pending` state), `informational`, `annotations` and `omitted` (see below). Providers that cannot show them ignore them.
49
+ - `state`: `pending`, `success`, `failure` or `cancelled`.
50
+
51
+ ### GitHub check runs
52
+
53
+ In `checks` mode a status target becomes a **check run** named exactly `context` (so `storyshelf/testing` is still the name you require). The run mirrors the build: `queued` (not captured yet), `in_progress` (capturing, comparing, review open), then `completed` with `success`, `failure` or `cancelled`; the informational `<prefix>/storybook` run completes `neutral` instead of `failure`. Runs are keyed by commit SHA and the stable `external_id` `storyshelf:<context>`: an existing run is updated, never duplicated. The run's `details_url` is the absolute build URL. `<prefix>/testing` carries one **annotation** per `new` or `changed` snapshot, anchored at the story file, whose `raw_details` is that snapshot's review URL; they are posted in batches of 50 through check-run updates, at most 200 in total, and the summary says `and N more changed snapshots` with a link to the build. Annotations already on the run are skipped, so a repeated delivery adds nothing. The pure mapping lives in `github-check-mapper.ts`; nothing posts from a call site.
54
+
55
+ Authentication has no dependencies: the provider signs an RS256 JWT with `node:crypto`, exchanges it at `POST /app/installations/{id}/access_tokens` through `httpJson`, and caches the installation token per channel until five minutes before it expires. The pull-request comment uses the same token.
56
+
57
+ Writes are idempotent and the latest call wins. A rejected write throws, and the host records the error on the channel row. Nothing is read from the git host during capture.
58
+
59
+ ## Direct use
60
+
61
+ Each provider is a `StateNotifierProvider` from `@storyshelf/core/adapter/notifier`. Bind one to a channel and call `setState`:
62
+
63
+ ```ts
64
+ import { gitHubNotifier } from "@storyshelf/notify-git";
65
+
66
+ const adapter = gitHubNotifier.create({
67
+ config: { owner: "acme", repo: "widgets" },
68
+ secret: process.env.GITHUB_TOKEN,
69
+ });
70
+
71
+ await adapter.setState({
72
+ channel,
73
+ target: { kind: "status", sha, context: "storyshelf/testing", description: "Review closed" },
74
+ state: "success",
75
+ url: "https://shelf.example.com/projects/web/builds/01J...",
76
+ });
77
+ ```
78
+
79
+ The status `context` is used verbatim: the caller owns the prefix, the provider adds nothing. StoryShelf derives the contexts, descriptions and links itself; direct use is for custom hosts. A `{ kind: "comment", sha, markdown }` target upserts the review comment instead.
80
+
81
+ ## Development
82
+
83
+ ```sh
84
+ nub run build # bundle with tsdown
85
+ nub run fmt # format with oxfmt
86
+ nub run lint # type-aware lint with oxlint
87
+ nub run test # vitest suite (the *.live.test.ts files need LIVE_CLOUD=1 and tokens)
88
+ ```
89
+
90
+ See [ADR 0014](../../docs/adr/0014-git-hosts-and-notifications.md).
@@ -0,0 +1,21 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/describe.ts
5
+ /** Short description shown beside a commit status. */
6
+ function describeState(state) {
7
+ switch (state) {
8
+ case "pending": return "Visual tests pending";
9
+ case "success": return "Visual tests passed";
10
+ case "cancelled": return "Cancelled";
11
+ default: return "Visual changes detected or tests failed";
12
+ }
13
+ }
14
+ /** Hidden marker that keys the single comment per build (idempotent upsert). */
15
+ function commentMarker(url) {
16
+ return `<!-- storyshelf:${url} -->`;
17
+ }
18
+ //#endregion
19
+ export { commentMarker, describeState };
20
+
21
+ //# sourceMappingURL=describe.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"describe.mjs","names":[],"sources":["../src/describe.ts"],"sourcesContent":["/** Human text for a build state and the comment marker shared by GitHub and GitLab. */\nimport type { NotifierState } from \"@storyshelf/core/adapter/notifier\";\n\n/** Short description shown beside a commit status. */\nexport function describeState(state: NotifierState): string {\n switch (state) {\n case \"pending\": {\n return \"Visual tests pending\";\n }\n case \"success\": {\n return \"Visual tests passed\";\n }\n case \"cancelled\": {\n return \"Cancelled\";\n }\n default: {\n return \"Visual changes detected or tests failed\";\n }\n }\n}\n\n/** Hidden marker that keys the single comment per build (idempotent upsert). */\nexport function commentMarker(url: string): string {\n return `<!-- storyshelf:${url} -->`;\n}\n"],"mappings":";;;;;AAIA,SAAgB,cAAc,OAA8B;CAC1D,QAAQ,OAAR;EACE,KAAK,WACH,OAAO;EAET,KAAK,WACH,OAAO;EAET,KAAK,aACH,OAAO;EAET,SACE,OAAO;CAEX;AACF;;AAGA,SAAgB,cAAc,KAAqB;CACjD,OAAO,mBAAmB,IAAI;AAChC"}
@@ -0,0 +1,25 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/github-api.ts
5
+ /** GitHub REST API helpers (base URL + auth headers). */
6
+ function apiBase() {
7
+ return "https://api.github.com";
8
+ }
9
+ /** Join a `/repos/{owner}/{repo}` path with a suffix. */
10
+ function repoPath(owner, repo, suffix = "") {
11
+ return `${apiBase()}/repos/${owner}/${repo}${suffix}`;
12
+ }
13
+ /** Headers for GitHub REST API requests. */
14
+ function githubHeaders(token) {
15
+ return {
16
+ accept: "application/vnd.github+json",
17
+ authorization: `Bearer ${token}`,
18
+ "content-type": "application/json",
19
+ "x-github-api-version": "2022-11-28"
20
+ };
21
+ }
22
+ //#endregion
23
+ export { apiBase, githubHeaders, repoPath };
24
+
25
+ //# sourceMappingURL=github-api.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-api.mjs","names":[],"sources":["../src/github-api.ts"],"sourcesContent":["/** GitHub REST API helpers (base URL + auth headers). */\nexport function apiBase(): string {\n return \"https://api.github.com\";\n}\n\n/** Join a `/repos/{owner}/{repo}` path with a suffix. */\nexport function repoPath(owner: string, repo: string, suffix = \"\"): string {\n return `${apiBase()}/repos/${owner}/${repo}${suffix}`;\n}\n\n/** Headers for GitHub REST API requests. */\nexport function githubHeaders(token: string): Record<string, string> {\n return {\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"],"mappings":";;;;;AACA,SAAgB,UAAkB;CAChC,OAAO;AACT;;AAGA,SAAgB,SAAS,OAAe,MAAc,SAAS,IAAY;CACzE,OAAO,GAAG,QAAQ,EAAE,SAAS,MAAM,GAAG,OAAO;AAC/C;;AAGA,SAAgB,cAAc,OAAuC;CACnE,OAAO;EACL,QAAQ;EACR,eAAe,UAAU;EACzB,gBAAgB;EAChB,wBAAwB;CAC1B;AACF"}
@@ -0,0 +1,67 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { apiBase, githubHeaders } from "./github-api.mjs";
5
+ import { httpJson } from "@storyshelf/core/utils";
6
+ import { createHash, createSign } from "node:crypto";
7
+ //#region src/github-app.ts
8
+ /**
9
+ * GitHub App authentication without dependencies: an RS256 JWT signed with `node:crypto`, traded
10
+ * for an installation token that is cached per channel until shortly before it expires.
11
+ */
12
+ /** A cached installation token is replaced this long before GitHub expires it. */
13
+ const TOKEN_REFRESH_MARGIN_MS = 3e5;
14
+ const cache = /* @__PURE__ */ new Map();
15
+ const inflight = /* @__PURE__ */ new Map();
16
+ /** An installation token for the channel: cached, or exchanged with a fresh App JWT. */
17
+ async function installationToken(creds) {
18
+ const key = cacheKey(creds);
19
+ const cached = cache.get(key);
20
+ if (cached && cached.expiresAt - 3e5 > Date.now()) return cached.token;
21
+ const pending = inflight.get(key) ?? exchange(creds, key);
22
+ inflight.set(key, pending);
23
+ try {
24
+ return await pending;
25
+ } finally {
26
+ inflight.delete(key);
27
+ }
28
+ }
29
+ async function exchange(creds, key) {
30
+ const jwt = signAppJwt(creds, Date.now());
31
+ const response = await httpJson(`${apiBase()}/app/installations/${encodeURIComponent(creds.installationId)}/access_tokens`, {
32
+ method: "POST",
33
+ headers: githubHeaders(jwt)
34
+ });
35
+ if (typeof response.token !== "string" || typeof response.expires_at !== "string") throw new TypeError("GitHub did not return an installation token");
36
+ cache.set(key, {
37
+ token: response.token,
38
+ expiresAt: Date.parse(response.expires_at)
39
+ });
40
+ return response.token;
41
+ }
42
+ /** The cache key never holds the private key itself, only its digest. */
43
+ function cacheKey(creds) {
44
+ const digest = createHash("sha256").update(creds.privateKey).digest("hex").slice(0, 16);
45
+ return `${creds.appId}:${creds.installationId}:${digest}`;
46
+ }
47
+ /** An App JWT (RS256), valid for nine minutes; `now` is in milliseconds. */
48
+ function signAppJwt(creds, now) {
49
+ const seconds = Math.floor(now / 1e3);
50
+ const header = base64url(JSON.stringify({
51
+ alg: "RS256",
52
+ typ: "JWT"
53
+ }));
54
+ const claims = base64url(JSON.stringify({
55
+ iat: seconds - 60,
56
+ exp: seconds + 540,
57
+ iss: creds.appId
58
+ }));
59
+ return `${header}.${claims}.${base64url(createSign("RSA-SHA256").update(`${header}.${claims}`).sign(creds.privateKey.replaceAll(String.raw`\n`, "\n")))}`;
60
+ }
61
+ function base64url(value) {
62
+ return Buffer.from(value).toString("base64url");
63
+ }
64
+ //#endregion
65
+ export { TOKEN_REFRESH_MARGIN_MS, installationToken, signAppJwt };
66
+
67
+ //# sourceMappingURL=github-app.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-app.mjs","names":[],"sources":["../src/github-app.ts"],"sourcesContent":["import { httpJson } from \"@storyshelf/core/utils\";\n/**\n * GitHub App authentication without dependencies: an RS256 JWT signed with `node:crypto`, traded\n * for an installation token that is cached per channel until shortly before it expires.\n */\nimport { createHash, createSign } from \"node:crypto\";\nimport { apiBase, githubHeaders } from \"./github-api.ts\";\n\n/** The App credentials of one channel. */\nexport interface AppCredentials {\n appId: string;\n installationId: string;\n /** The App's private key (PEM); a one-line key with literal `\\n` is accepted. */\n privateKey: string;\n}\n\n/** A cached installation token is replaced this long before GitHub expires it. */\nexport const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;\n\ninterface CachedToken {\n token: string;\n expiresAt: number;\n}\n\nconst cache = new Map<string, CachedToken>();\nconst inflight = new Map<string, Promise<string>>();\n\n/** An installation token for the channel: cached, or exchanged with a fresh App JWT. */\nexport async function installationToken(creds: AppCredentials): Promise<string> {\n const key = cacheKey(creds);\n const cached = cache.get(key);\n if (cached && cached.expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now()) {\n return cached.token;\n }\n const pending = inflight.get(key) ?? exchange(creds, key);\n inflight.set(key, pending);\n try {\n return await pending;\n } finally {\n inflight.delete(key);\n }\n}\n\n/** Forget every cached token (tests). */\nexport function clearInstallationTokens(): void {\n cache.clear();\n inflight.clear();\n}\n\nasync function exchange(creds: AppCredentials, key: string): Promise<string> {\n const jwt = signAppJwt(creds, Date.now());\n const response = await httpJson<{ token?: unknown; expires_at?: unknown }>(\n `${apiBase()}/app/installations/${encodeURIComponent(creds.installationId)}/access_tokens`,\n { method: \"POST\", headers: githubHeaders(jwt) },\n );\n if (typeof response.token !== \"string\" || typeof response.expires_at !== \"string\") {\n throw new TypeError(\"GitHub did not return an installation token\");\n }\n cache.set(key, { token: response.token, expiresAt: Date.parse(response.expires_at) });\n return response.token;\n}\n\n/** The cache key never holds the private key itself, only its digest. */\nfunction cacheKey(creds: AppCredentials): string {\n const digest = createHash(\"sha256\").update(creds.privateKey).digest(\"hex\").slice(0, 16);\n return `${creds.appId}:${creds.installationId}:${digest}`;\n}\n\n/** An App JWT (RS256), valid for nine minutes; `now` is in milliseconds. */\nexport function signAppJwt(creds: AppCredentials, now: number): string {\n const seconds = Math.floor(now / 1000);\n const header = base64url(JSON.stringify({ alg: \"RS256\", typ: \"JWT\" }));\n // `iat` is backdated a minute to allow for clock drift, as GitHub recommends.\n const claims = base64url(\n JSON.stringify({ iat: seconds - 60, exp: seconds + 9 * 60, iss: creds.appId }),\n );\n const signature = createSign(\"RSA-SHA256\")\n .update(`${header}.${claims}`)\n .sign(creds.privateKey.replaceAll(String.raw`\\n`, \"\\n\"));\n return `${header}.${claims}.${base64url(signature)}`;\n}\n\nfunction base64url(value: string | Buffer): string {\n return Buffer.from(value).toString(\"base64url\");\n}\n"],"mappings":";;;;;;;;;;;;AAiBA,MAAa,0BAA0B;AAOvC,MAAM,wBAAQ,IAAI,IAAyB;AAC3C,MAAM,2BAAW,IAAI,IAA6B;;AAGlD,eAAsB,kBAAkB,OAAwC;CAC9E,MAAM,MAAM,SAAS,KAAK;CAC1B,MAAM,SAAS,MAAM,IAAI,GAAG;CAC5B,IAAI,UAAU,OAAO,YAAA,MAAsC,KAAK,IAAI,GAClE,OAAO,OAAO;CAEhB,MAAM,UAAU,SAAS,IAAI,GAAG,KAAK,SAAS,OAAO,GAAG;CACxD,SAAS,IAAI,KAAK,OAAO;CACzB,IAAI;EACF,OAAO,MAAM;CACf,UAAU;EACR,SAAS,OAAO,GAAG;CACrB;AACF;AAQA,eAAe,SAAS,OAAuB,KAA8B;CAC3E,MAAM,MAAM,WAAW,OAAO,KAAK,IAAI,CAAC;CACxC,MAAM,WAAW,MAAM,SACrB,GAAG,QAAQ,EAAE,qBAAqB,mBAAmB,MAAM,cAAc,EAAE,iBAC3E;EAAE,QAAQ;EAAQ,SAAS,cAAc,GAAG;CAAE,CAChD;CACA,IAAI,OAAO,SAAS,UAAU,YAAY,OAAO,SAAS,eAAe,UACvE,MAAM,IAAI,UAAU,6CAA6C;CAEnE,MAAM,IAAI,KAAK;EAAE,OAAO,SAAS;EAAO,WAAW,KAAK,MAAM,SAAS,UAAU;CAAE,CAAC;CACpF,OAAO,SAAS;AAClB;;AAGA,SAAS,SAAS,OAA+B;CAC/C,MAAM,SAAS,WAAW,QAAQ,CAAC,CAAC,OAAO,MAAM,UAAU,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EAAE;CACtF,OAAO,GAAG,MAAM,MAAM,GAAG,MAAM,eAAe,GAAG;AACnD;;AAGA,SAAgB,WAAW,OAAuB,KAAqB;CACrE,MAAM,UAAU,KAAK,MAAM,MAAM,GAAI;CACrC,MAAM,SAAS,UAAU,KAAK,UAAU;EAAE,KAAK;EAAS,KAAK;CAAM,CAAC,CAAC;CAErE,MAAM,SAAS,UACb,KAAK,UAAU;EAAE,KAAK,UAAU;EAAI,KAAK,UAAU;EAAQ,KAAK,MAAM;CAAM,CAAC,CAC/E;CAIA,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,UAHZ,WAAW,YAAY,CAAC,CACvC,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC,CAC7B,KAAK,MAAM,WAAW,WAAW,OAAO,GAAG,MAAM,IAAI,CACR,CAAC;AACnD;AAEA,SAAS,UAAU,OAAgC;CACjD,OAAO,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,WAAW;AAChD"}
@@ -0,0 +1,69 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ /** The `external_id` that keys a check run: the same name always finds the same run. */
5
+ function externalIdOf(context) {
6
+ return `storyshelf:${context}`;
7
+ }
8
+ /** `pending` is queued or in progress; a final state completes the run; a cancel is `cancelled`. */
9
+ function phaseOf(input) {
10
+ switch (input.state) {
11
+ case "pending": return { status: input.progress === "queued" ? "queued" : "in_progress" };
12
+ case "success": return {
13
+ status: "completed",
14
+ conclusion: "success"
15
+ };
16
+ case "cancelled": return {
17
+ status: "completed",
18
+ conclusion: "cancelled"
19
+ };
20
+ default: return {
21
+ status: "completed",
22
+ conclusion: input.informational === true ? "neutral" : "failure"
23
+ };
24
+ }
25
+ }
26
+ /** The check run to show for a desired status. */
27
+ function checkRunBody(input) {
28
+ return {
29
+ name: input.context,
30
+ external_id: externalIdOf(input.context),
31
+ ...phaseOf(input),
32
+ ...input.url === void 0 ? {} : { details_url: input.url },
33
+ output: {
34
+ title: input.description,
35
+ summary: summaryOf(input)
36
+ }
37
+ };
38
+ }
39
+ /** The summary: the description, then the link to the build, then what the annotations leave out. */
40
+ function summaryOf(input) {
41
+ const lines = [input.description];
42
+ const omitted = input.omitted ?? 0;
43
+ if (omitted > 0 && input.url !== void 0) lines.push(`and ${String(omitted)} more changed snapshots: [open the build](${input.url})`);
44
+ else if (omitted > 0) lines.push(`and ${String(omitted)} more changed snapshots`);
45
+ else if (input.url !== void 0) lines.push(`[Open the build](${input.url})`);
46
+ return lines.join("\n\n");
47
+ }
48
+ /** Annotations as batches of at most {@link ANNOTATIONS_PER_REQUEST}, after the `skip` already on the run. */
49
+ function annotationBatches(annotations, skip = 0) {
50
+ const pending = annotations.slice(skip).map((annotation) => annotationOf(annotation));
51
+ const batches = [];
52
+ for (let at = 0; at < pending.length; at += 50) batches.push(pending.slice(at, at + 50));
53
+ return batches;
54
+ }
55
+ function annotationOf(annotation) {
56
+ return {
57
+ path: annotation.path,
58
+ start_line: 1,
59
+ end_line: 1,
60
+ annotation_level: annotation.level,
61
+ title: annotation.title.slice(0, 255),
62
+ message: annotation.message,
63
+ raw_details: annotation.url
64
+ };
65
+ }
66
+ //#endregion
67
+ export { annotationBatches, checkRunBody, externalIdOf, phaseOf, summaryOf };
68
+
69
+ //# sourceMappingURL=github-check-mapper.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-check-mapper.mjs","names":[],"sources":["../src/github-check-mapper.ts"],"sourcesContent":["/** Pure mapping from a desired status to GitHub check-run payloads (never posts anything). */\nimport type { NotifierState, StateAnnotation } from \"@storyshelf/core/adapter/notifier\";\n\n/** GitHub accepts at most this many annotations per request. */\nexport const ANNOTATIONS_PER_REQUEST = 50;\n\n/** What one check run shows. */\nexport interface CheckRunInput {\n /** Full status name: the check run's name, so a required check keeps its `<prefix>/testing` name. */\n context: string;\n state: NotifierState;\n description: string;\n /** Absolute link to the build page. */\n url?: string | undefined;\n progress?: \"queued\" | \"running\" | undefined;\n informational?: boolean | undefined;\n annotations?: readonly StateAnnotation[] | undefined;\n omitted?: number | undefined;\n}\n\n/** The lifecycle fields of a check run. */\nexport interface CheckRunPhase {\n status: \"queued\" | \"in_progress\" | \"completed\";\n conclusion?: \"success\" | \"failure\" | \"cancelled\" | \"neutral\";\n}\n\n/** One annotation as the Checks API takes it. */\nexport interface CheckAnnotation {\n path: string;\n start_line: number;\n end_line: number;\n annotation_level: \"notice\" | \"warning\" | \"failure\";\n title: string;\n message: string;\n raw_details: string;\n}\n\n/** The body of a create or update of a check run, without the annotations. */\nexport interface CheckRunBody extends CheckRunPhase {\n name: string;\n external_id: string;\n details_url?: string;\n output: { title: string; summary: string };\n}\n\n/** The `external_id` that keys a check run: the same name always finds the same run. */\nexport function externalIdOf(context: string): string {\n return `storyshelf:${context}`;\n}\n\n/** `pending` is queued or in progress; a final state completes the run; a cancel is `cancelled`. */\nexport function phaseOf(\n input: Pick<CheckRunInput, \"state\" | \"progress\" | \"informational\">,\n): CheckRunPhase {\n switch (input.state) {\n case \"pending\": {\n return { status: input.progress === \"queued\" ? \"queued\" : \"in_progress\" };\n }\n case \"success\": {\n return { status: \"completed\", conclusion: \"success\" };\n }\n case \"cancelled\": {\n return { status: \"completed\", conclusion: \"cancelled\" };\n }\n default: {\n return {\n status: \"completed\",\n conclusion: input.informational === true ? \"neutral\" : \"failure\",\n };\n }\n }\n}\n\n/** The check run to show for a desired status. */\nexport function checkRunBody(input: CheckRunInput): CheckRunBody {\n return {\n name: input.context,\n external_id: externalIdOf(input.context),\n ...phaseOf(input),\n ...(input.url === undefined ? {} : { details_url: input.url }),\n output: { title: input.description, summary: summaryOf(input) },\n };\n}\n\n/** The summary: the description, then the link to the build, then what the annotations leave out. */\nexport function summaryOf(input: CheckRunInput): string {\n const lines = [input.description];\n const omitted = input.omitted ?? 0;\n if (omitted > 0 && input.url !== undefined) {\n lines.push(`and ${String(omitted)} more changed snapshots: [open the build](${input.url})`);\n } else if (omitted > 0) {\n lines.push(`and ${String(omitted)} more changed snapshots`);\n } else if (input.url !== undefined) {\n lines.push(`[Open the build](${input.url})`);\n }\n return lines.join(\"\\n\\n\");\n}\n\n/** Annotations as batches of at most {@link ANNOTATIONS_PER_REQUEST}, after the `skip` already on the run. */\nexport function annotationBatches(\n annotations: readonly StateAnnotation[],\n skip = 0,\n): CheckAnnotation[][] {\n const pending = annotations.slice(skip).map((annotation) => annotationOf(annotation));\n const batches: CheckAnnotation[][] = [];\n for (let at = 0; at < pending.length; at += ANNOTATIONS_PER_REQUEST) {\n batches.push(pending.slice(at, at + ANNOTATIONS_PER_REQUEST));\n }\n return batches;\n}\n\nfunction annotationOf(annotation: StateAnnotation): CheckAnnotation {\n return {\n path: annotation.path,\n start_line: 1,\n end_line: 1,\n annotation_level: annotation.level,\n title: annotation.title.slice(0, 255),\n message: annotation.message,\n raw_details: annotation.url,\n };\n}\n"],"mappings":";;;;AA8CA,SAAgB,aAAa,SAAyB;CACpD,OAAO,cAAc;AACvB;;AAGA,SAAgB,QACd,OACe;CACf,QAAQ,MAAM,OAAd;EACE,KAAK,WACH,OAAO,EAAE,QAAQ,MAAM,aAAa,WAAW,WAAW,cAAc;EAE1E,KAAK,WACH,OAAO;GAAE,QAAQ;GAAa,YAAY;EAAU;EAEtD,KAAK,aACH,OAAO;GAAE,QAAQ;GAAa,YAAY;EAAY;EAExD,SACE,OAAO;GACL,QAAQ;GACR,YAAY,MAAM,kBAAkB,OAAO,YAAY;EACzD;CAEJ;AACF;;AAGA,SAAgB,aAAa,OAAoC;CAC/D,OAAO;EACL,MAAM,MAAM;EACZ,aAAa,aAAa,MAAM,OAAO;EACvC,GAAG,QAAQ,KAAK;EAChB,GAAI,MAAM,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,aAAa,MAAM,IAAI;EAC5D,QAAQ;GAAE,OAAO,MAAM;GAAa,SAAS,UAAU,KAAK;EAAE;CAChE;AACF;;AAGA,SAAgB,UAAU,OAA8B;CACtD,MAAM,QAAQ,CAAC,MAAM,WAAW;CAChC,MAAM,UAAU,MAAM,WAAW;CACjC,IAAI,UAAU,KAAK,MAAM,QAAQ,KAAA,GAC/B,MAAM,KAAK,OAAO,OAAO,OAAO,EAAE,4CAA4C,MAAM,IAAI,EAAE;MACrF,IAAI,UAAU,GACnB,MAAM,KAAK,OAAO,OAAO,OAAO,EAAE,wBAAwB;MACrD,IAAI,MAAM,QAAQ,KAAA,GACvB,MAAM,KAAK,oBAAoB,MAAM,IAAI,EAAE;CAE7C,OAAO,MAAM,KAAK,MAAM;AAC1B;;AAGA,SAAgB,kBACd,aACA,OAAO,GACc;CACrB,MAAM,UAAU,YAAY,MAAM,IAAI,CAAC,CAAC,KAAK,eAAe,aAAa,UAAU,CAAC;CACpF,MAAM,UAA+B,CAAC;CACtC,KAAK,IAAI,KAAK,GAAG,KAAK,QAAQ,QAAQ,MAAA,IACpC,QAAQ,KAAK,QAAQ,MAAM,IAAI,KAAA,EAA4B,CAAC;CAE9D,OAAO;AACT;AAEA,SAAS,aAAa,YAA8C;CAClE,OAAO;EACL,MAAM,WAAW;EACjB,YAAY;EACZ,UAAU;EACV,kBAAkB,WAAW;EAC7B,OAAO,WAAW,MAAM,MAAM,GAAG,GAAG;EACpC,SAAS,WAAW;EACpB,aAAa,WAAW;CAC1B;AACF"}
@@ -0,0 +1,86 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { githubHeaders, repoPath } from "./github-api.mjs";
5
+ import { annotationBatches, checkRunBody, externalIdOf } from "./github-check-mapper.mjs";
6
+ import { httpJson } from "@storyshelf/core/utils";
7
+ //#region src/github-check.ts
8
+ /**
9
+ * Show a status as a check run of the commit `sha`: update the run that carries the status's
10
+ * `external_id`, or create it, so a build has one run per name and its lifecycle (queued,
11
+ * in progress, completed) moves that run. The annotations follow in batches of 50; the ones
12
+ * already on the run are skipped, so posting the same state again adds nothing.
13
+ */
14
+ async function postCheckRun(repo, input) {
15
+ const body = checkRunBody(input);
16
+ const existing = await findRun(repo, input);
17
+ const batches = annotationBatches(input.annotations ?? [], existing?.output?.annotations_count ?? 0);
18
+ repo.logger?.debug({
19
+ context: input.context,
20
+ sha: input.sha,
21
+ status: body.status
22
+ }, "posting check run");
23
+ try {
24
+ await appendBatches(repo, (existing ? await update(repo, existing.id, withBatch(body, batches[0])) : await create(repo, {
25
+ ...withBatch(body, batches[0]),
26
+ head_sha: input.sha
27
+ })).id, {
28
+ output: body.output,
29
+ batches: batches.slice(1)
30
+ });
31
+ } catch (error) {
32
+ repo.logger?.error({
33
+ err: error,
34
+ context: input.context,
35
+ sha: input.sha
36
+ }, "failed to post check run");
37
+ throw error;
38
+ }
39
+ }
40
+ function withBatch(body, batch) {
41
+ return batch === void 0 ? body : {
42
+ ...body,
43
+ output: {
44
+ ...body.output,
45
+ annotations: batch
46
+ }
47
+ };
48
+ }
49
+ /** The run of this commit that carries the status's `external_id`, if there is one. */
50
+ async function findRun(repo, input) {
51
+ const url = repoPath(repo.owner, repo.repo, `/commits/${encodeURIComponent(input.sha)}/check-runs?filter=latest&per_page=100&check_name=${encodeURIComponent(input.context)}`);
52
+ return (await httpJson(url, { headers: githubHeaders(repo.token) })).check_runs?.find((run) => run.external_id === externalIdOf(input.context));
53
+ }
54
+ async function create(repo, body) {
55
+ return await httpJson(repoPath(repo.owner, repo.repo, "/check-runs"), {
56
+ method: "POST",
57
+ headers: githubHeaders(repo.token),
58
+ json: body,
59
+ logger: repo.logger
60
+ });
61
+ }
62
+ async function update(repo, id, body) {
63
+ return await httpJson(repoPath(repo.owner, repo.repo, `/check-runs/${String(id)}`), {
64
+ method: "PATCH",
65
+ headers: githubHeaders(repo.token),
66
+ json: body,
67
+ logger: repo.logger
68
+ });
69
+ }
70
+ /** Append the remaining annotation batches to the run, one request after the other (in order). */
71
+ async function appendBatches(repo, id, rest) {
72
+ const [next, ...later] = rest.batches;
73
+ if (next === void 0) return;
74
+ await update(repo, id, { output: {
75
+ ...rest.output,
76
+ annotations: next
77
+ } });
78
+ await appendBatches(repo, id, {
79
+ output: rest.output,
80
+ batches: later
81
+ });
82
+ }
83
+ //#endregion
84
+ export { postCheckRun };
85
+
86
+ //# sourceMappingURL=github-check.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-check.mjs","names":[],"sources":["../src/github-check.ts"],"sourcesContent":["import type { Logger } from \"@storyshelf/core/logger\";\nimport { httpJson } from \"@storyshelf/core/utils\";\nimport { githubHeaders, repoPath } from \"./github-api.ts\";\nimport {\n annotationBatches,\n checkRunBody,\n externalIdOf,\n type CheckAnnotation,\n type CheckRunBody,\n type CheckRunInput,\n} from \"./github-check-mapper.ts\";\n\n/** Coordinates and installation token of one repository. */\nexport interface CheckRepo {\n token: string;\n owner: string;\n repo: string;\n logger?: Logger | undefined;\n}\n\ninterface ExistingRun {\n id: number;\n external_id?: string | null;\n output?: { annotations_count?: number | null } | null;\n}\n\n/**\n * Show a status as a check run of the commit `sha`: update the run that carries the status's\n * `external_id`, or create it, so a build has one run per name and its lifecycle (queued,\n * in progress, completed) moves that run. The annotations follow in batches of 50; the ones\n * already on the run are skipped, so posting the same state again adds nothing.\n */\nexport async function postCheckRun(\n repo: CheckRepo,\n input: CheckRunInput & { sha: string },\n): Promise<void> {\n const body = checkRunBody(input);\n const existing = await findRun(repo, input);\n const batches = annotationBatches(\n input.annotations ?? [],\n existing?.output?.annotations_count ?? 0,\n );\n repo.logger?.debug(\n { context: input.context, sha: input.sha, status: body.status },\n \"posting check run\",\n );\n try {\n const run = existing\n ? await update(repo, existing.id, withBatch(body, batches[0]))\n : await create(repo, { ...withBatch(body, batches[0]), head_sha: input.sha });\n await appendBatches(repo, run.id, { output: body.output, batches: batches.slice(1) });\n } catch (error) {\n repo.logger?.error(\n { err: error, context: input.context, sha: input.sha },\n \"failed to post check run\",\n );\n throw error;\n }\n}\n\ntype WithAnnotations = Omit<CheckRunBody, \"output\"> & {\n output: CheckRunBody[\"output\"] & { annotations?: CheckAnnotation[] };\n};\n\nfunction withBatch(body: CheckRunBody, batch: CheckAnnotation[] | undefined): WithAnnotations {\n return batch === undefined ? body : { ...body, output: { ...body.output, annotations: batch } };\n}\n\n/** The run of this commit that carries the status's `external_id`, if there is one. */\nasync function findRun(\n repo: CheckRepo,\n input: { sha: string; context: string },\n): Promise<ExistingRun | undefined> {\n const url = repoPath(\n repo.owner,\n repo.repo,\n `/commits/${encodeURIComponent(input.sha)}/check-runs?filter=latest&per_page=100&check_name=${encodeURIComponent(input.context)}`,\n );\n const listed = await httpJson<{ check_runs?: ExistingRun[] }>(url, {\n headers: githubHeaders(repo.token),\n });\n return listed.check_runs?.find((run) => run.external_id === externalIdOf(input.context));\n}\n\nasync function create(repo: CheckRepo, body: unknown): Promise<{ id: number }> {\n return await httpJson<{ id: number }>(repoPath(repo.owner, repo.repo, \"/check-runs\"), {\n method: \"POST\",\n headers: githubHeaders(repo.token),\n json: body,\n logger: repo.logger,\n });\n}\n\nasync function update(repo: CheckRepo, id: number, body: unknown): Promise<{ id: number }> {\n return await httpJson<{ id: number }>(\n repoPath(repo.owner, repo.repo, `/check-runs/${String(id)}`),\n {\n method: \"PATCH\",\n headers: githubHeaders(repo.token),\n json: body,\n logger: repo.logger,\n },\n );\n}\n\n/** Append the remaining annotation batches to the run, one request after the other (in order). */\nasync function appendBatches(\n repo: CheckRepo,\n id: number,\n rest: { output: CheckRunBody[\"output\"]; batches: CheckAnnotation[][] },\n): Promise<void> {\n const [next, ...later] = rest.batches;\n if (next === undefined) {\n return;\n }\n await update(repo, id, { output: { ...rest.output, annotations: next } });\n await appendBatches(repo, id, { output: rest.output, batches: later });\n}\n"],"mappings":";;;;;;;;;;;;;AAgCA,eAAsB,aACpB,MACA,OACe;CACf,MAAM,OAAO,aAAa,KAAK;CAC/B,MAAM,WAAW,MAAM,QAAQ,MAAM,KAAK;CAC1C,MAAM,UAAU,kBACd,MAAM,eAAe,CAAC,GACtB,UAAU,QAAQ,qBAAqB,CACzC;CACA,KAAK,QAAQ,MACX;EAAE,SAAS,MAAM;EAAS,KAAK,MAAM;EAAK,QAAQ,KAAK;CAAO,GAC9D,mBACF;CACA,IAAI;EAIF,MAAM,cAAc,OAHR,WACR,MAAM,OAAO,MAAM,SAAS,IAAI,UAAU,MAAM,QAAQ,EAAE,CAAC,IAC3D,MAAM,OAAO,MAAM;GAAE,GAAG,UAAU,MAAM,QAAQ,EAAE;GAAG,UAAU,MAAM;EAAI,CAAC,EAAA,CAChD,IAAI;GAAE,QAAQ,KAAK;GAAQ,SAAS,QAAQ,MAAM,CAAC;EAAE,CAAC;CACtF,SAAS,OAAO;EACd,KAAK,QAAQ,MACX;GAAE,KAAK;GAAO,SAAS,MAAM;GAAS,KAAK,MAAM;EAAI,GACrD,0BACF;EACA,MAAM;CACR;AACF;AAMA,SAAS,UAAU,MAAoB,OAAuD;CAC5F,OAAO,UAAU,KAAA,IAAY,OAAO;EAAE,GAAG;EAAM,QAAQ;GAAE,GAAG,KAAK;GAAQ,aAAa;EAAM;CAAE;AAChG;;AAGA,eAAe,QACb,MACA,OACkC;CAClC,MAAM,MAAM,SACV,KAAK,OACL,KAAK,MACL,YAAY,mBAAmB,MAAM,GAAG,EAAE,oDAAoD,mBAAmB,MAAM,OAAO,GAChI;CAIA,QAAO,MAHc,SAAyC,KAAK,EACjE,SAAS,cAAc,KAAK,KAAK,EACnC,CAAC,EAAA,CACa,YAAY,MAAM,QAAQ,IAAI,gBAAgB,aAAa,MAAM,OAAO,CAAC;AACzF;AAEA,eAAe,OAAO,MAAiB,MAAwC;CAC7E,OAAO,MAAM,SAAyB,SAAS,KAAK,OAAO,KAAK,MAAM,aAAa,GAAG;EACpF,QAAQ;EACR,SAAS,cAAc,KAAK,KAAK;EACjC,MAAM;EACN,QAAQ,KAAK;CACf,CAAC;AACH;AAEA,eAAe,OAAO,MAAiB,IAAY,MAAwC;CACzF,OAAO,MAAM,SACX,SAAS,KAAK,OAAO,KAAK,MAAM,eAAe,OAAO,EAAE,GAAG,GAC3D;EACE,QAAQ;EACR,SAAS,cAAc,KAAK,KAAK;EACjC,MAAM;EACN,QAAQ,KAAK;CACf,CACF;AACF;;AAGA,eAAe,cACb,MACA,IACA,MACe;CACf,MAAM,CAAC,MAAM,GAAG,SAAS,KAAK;CAC9B,IAAI,SAAS,KAAA,GACX;CAEF,MAAM,OAAO,MAAM,IAAI,EAAE,QAAQ;EAAE,GAAG,KAAK;EAAQ,aAAa;CAAK,EAAE,CAAC;CACxE,MAAM,cAAc,MAAM,IAAI;EAAE,QAAQ,KAAK;EAAQ,SAAS;CAAM,CAAC;AACvE"}
@@ -0,0 +1,58 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { githubHeaders, repoPath } from "./github-api.mjs";
5
+ import { findPrNumber } from "./github-pr.mjs";
6
+ import { upsertReviewComment } from "./review-comment.mjs";
7
+ import { httpJson } from "@storyshelf/core/utils";
8
+ //#region src/github-comment.ts
9
+ /** Fetch-backed review thread (comments) for a pull request. */
10
+ function createThread(repo, prNumber) {
11
+ const threadUrl = repoPath(repo.owner, repo.repo, `/issues/${prNumber}/comments`);
12
+ const headers = githubHeaders(repo.token);
13
+ return {
14
+ list: async () => {
15
+ return (await httpJson(`${threadUrl}?per_page=100`, { headers })).map((comment) => ({
16
+ id: comment.id,
17
+ body: comment.body ?? ""
18
+ }));
19
+ },
20
+ update: async (id, body) => {
21
+ const updated = await httpJson(repoPath(repo.owner, repo.repo, `/issues/comments/${String(id)}`), {
22
+ method: "PATCH",
23
+ headers,
24
+ json: { body }
25
+ });
26
+ return String(updated.id);
27
+ },
28
+ create: async (body) => {
29
+ const created = await httpJson(threadUrl, {
30
+ method: "POST",
31
+ headers,
32
+ json: { body }
33
+ });
34
+ return String(created.id);
35
+ }
36
+ };
37
+ }
38
+ /** Create or update the StoryShelf review comment on the pull request of a commit. */
39
+ async function upsertPrComment(repo, input) {
40
+ const { token, owner } = repo;
41
+ return await upsertReviewComment({
42
+ url: input.url,
43
+ markdown: input.markdown,
44
+ sha: input.sha,
45
+ logger: input.logger,
46
+ resolveNumber: async () => await findPrNumber({
47
+ token,
48
+ owner,
49
+ repo: repo.repo,
50
+ sha: input.sha
51
+ }),
52
+ thread: (prNumber) => createThread(repo, prNumber)
53
+ });
54
+ }
55
+ //#endregion
56
+ export { upsertPrComment };
57
+
58
+ //# sourceMappingURL=github-comment.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-comment.mjs","names":[],"sources":["../src/github-comment.ts"],"sourcesContent":["import type { Logger } from \"@storyshelf/core/logger\";\nimport { httpJson } from \"@storyshelf/core/utils\";\nimport { githubHeaders, repoPath } from \"./github-api.ts\";\nimport { findPrNumber } from \"./github-pr.ts\";\nimport type { GitHubRepo } from \"./github-status.ts\";\nimport { upsertReviewComment, type ReviewThread } from \"./review-comment.ts\";\n\ninterface IssueComment {\n id: number;\n body?: string | null;\n}\n\n/** Fetch-backed review thread (comments) for a pull request. */\nfunction createThread(repo: GitHubRepo, prNumber: number): ReviewThread {\n const threadUrl = repoPath(repo.owner, repo.repo, `/issues/${prNumber}/comments`);\n const headers = githubHeaders(repo.token);\n return {\n list: async () => {\n const comments = await httpJson<IssueComment[]>(`${threadUrl}?per_page=100`, { headers });\n return comments.map((comment) => ({ id: comment.id, body: comment.body ?? \"\" }));\n },\n update: async (id, body) => {\n const updated = await httpJson<IssueComment>(\n repoPath(repo.owner, repo.repo, `/issues/comments/${String(id)}`),\n { method: \"PATCH\", headers, json: { body } },\n );\n return String(updated.id);\n },\n create: async (body) => {\n const created = await httpJson<IssueComment>(threadUrl, {\n method: \"POST\",\n headers,\n json: { body },\n });\n return String(created.id);\n },\n };\n}\n\n/** Create or update the StoryShelf review comment on the pull request of a commit. */\nexport async function upsertPrComment(\n repo: GitHubRepo,\n input: { sha: string; url: string; markdown: string; logger?: Logger },\n): Promise<string> {\n const { token, owner } = repo;\n return await upsertReviewComment({\n url: input.url,\n markdown: input.markdown,\n sha: input.sha,\n logger: input.logger,\n resolveNumber: async () =>\n await findPrNumber({ token, owner, repo: repo.repo, sha: input.sha }),\n thread: (prNumber) => createThread(repo, prNumber),\n });\n}\n"],"mappings":";;;;;;;;;AAaA,SAAS,aAAa,MAAkB,UAAgC;CACtE,MAAM,YAAY,SAAS,KAAK,OAAO,KAAK,MAAM,WAAW,SAAS,UAAU;CAChF,MAAM,UAAU,cAAc,KAAK,KAAK;CACxC,OAAO;EACL,MAAM,YAAY;GAEhB,QAAO,MADgB,SAAyB,GAAG,UAAU,gBAAgB,EAAE,QAAQ,CAAC,EAAA,CACxE,KAAK,aAAa;IAAE,IAAI,QAAQ;IAAI,MAAM,QAAQ,QAAQ;GAAG,EAAE;EACjF;EACA,QAAQ,OAAO,IAAI,SAAS;GAC1B,MAAM,UAAU,MAAM,SACpB,SAAS,KAAK,OAAO,KAAK,MAAM,oBAAoB,OAAO,EAAE,GAAG,GAChE;IAAE,QAAQ;IAAS;IAAS,MAAM,EAAE,KAAK;GAAE,CAC7C;GACA,OAAO,OAAO,QAAQ,EAAE;EAC1B;EACA,QAAQ,OAAO,SAAS;GACtB,MAAM,UAAU,MAAM,SAAuB,WAAW;IACtD,QAAQ;IACR;IACA,MAAM,EAAE,KAAK;GACf,CAAC;GACD,OAAO,OAAO,QAAQ,EAAE;EAC1B;CACF;AACF;;AAGA,eAAsB,gBACpB,MACA,OACiB;CACjB,MAAM,EAAE,OAAO,UAAU;CACzB,OAAO,MAAM,oBAAoB;EAC/B,KAAK,MAAM;EACX,UAAU,MAAM;EAChB,KAAK,MAAM;EACX,QAAQ,MAAM;EACd,eAAe,YACb,MAAM,aAAa;GAAE;GAAO;GAAO,MAAM,KAAK;GAAM,KAAK,MAAM;EAAI,CAAC;EACtE,SAAS,aAAa,aAAa,MAAM,QAAQ;CACnD,CAAC;AACH"}
@@ -0,0 +1,24 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { z } from "zod";
4
+ //#region src/github-config.d.ts
5
+ /**
6
+ * Validation schema for a GitHub channel config: `owner` + `repo`, an optional status `prefix` and
7
+ * how the build is shown. `statuses` (default) posts commit statuses with a token; `checks` posts
8
+ * check runs as a GitHub App and needs `appId` and `installationId` (the private key is the
9
+ * channel's secret).
10
+ */
11
+ declare const githubConfigSchema: z.ZodObject<{
12
+ owner: z.ZodString;
13
+ repo: z.ZodString;
14
+ prefix: z.ZodOptional<z.ZodString>;
15
+ mode: z.ZodOptional<z.ZodEnum<{
16
+ checks: "checks";
17
+ statuses: "statuses";
18
+ }>>;
19
+ appId: z.ZodOptional<z.ZodString>;
20
+ installationId: z.ZodOptional<z.ZodString>;
21
+ }, z.core.$strip>;
22
+ //#endregion
23
+ export { githubConfigSchema };
24
+ //# sourceMappingURL=github-config.d.mts.map
@@ -0,0 +1,31 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { statusPrefixSchema } from "./prefix.mjs";
5
+ import { z } from "zod";
6
+ //#region src/github-config.ts
7
+ /**
8
+ * Validation schema for a GitHub channel config: `owner` + `repo`, an optional status `prefix` and
9
+ * how the build is shown. `statuses` (default) posts commit statuses with a token; `checks` posts
10
+ * check runs as a GitHub App and needs `appId` and `installationId` (the private key is the
11
+ * channel's secret).
12
+ */
13
+ const githubConfigSchema = z.object({
14
+ owner: z.string().min(1),
15
+ repo: z.string().min(1),
16
+ prefix: statusPrefixSchema.optional(),
17
+ mode: z.enum(["statuses", "checks"]).optional(),
18
+ appId: z.string().regex(/^\d+$/u, "Use the numeric App ID").optional(),
19
+ installationId: z.string().regex(/^\d+$/u, "Use the numeric installation ID").optional()
20
+ }).superRefine((cfg, ctx) => {
21
+ if (cfg.mode !== "checks") return;
22
+ for (const field of ["appId", "installationId"]) if (cfg[field] === void 0) ctx.addIssue({
23
+ code: "custom",
24
+ path: [field],
25
+ message: `Checks mode needs ${field}`
26
+ });
27
+ });
28
+ //#endregion
29
+ export { githubConfigSchema };
30
+
31
+ //# sourceMappingURL=github-config.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-config.mjs","names":[],"sources":["../src/github-config.ts"],"sourcesContent":["import { z } from \"zod\";\nimport { statusPrefixSchema } from \"./prefix.ts\";\n\n/**\n * Validation schema for a GitHub channel config: `owner` + `repo`, an optional status `prefix` and\n * how the build is shown. `statuses` (default) posts commit statuses with a token; `checks` posts\n * check runs as a GitHub App and needs `appId` and `installationId` (the private key is the\n * channel's secret).\n */\nexport const githubConfigSchema = z\n .object({\n owner: z.string().min(1),\n repo: z.string().min(1),\n prefix: statusPrefixSchema.optional(),\n mode: z.enum([\"statuses\", \"checks\"]).optional(),\n appId: z.string().regex(/^\\d+$/u, \"Use the numeric App ID\").optional(),\n installationId: z.string().regex(/^\\d+$/u, \"Use the numeric installation ID\").optional(),\n })\n .superRefine((cfg, ctx) => {\n if (cfg.mode !== \"checks\") {\n return;\n }\n for (const field of [\"appId\", \"installationId\"] as const) {\n if (cfg[field] === undefined) {\n ctx.addIssue({ code: \"custom\", path: [field], message: `Checks mode needs ${field}` });\n }\n }\n });\n\n/** The parsed config of a GitHub channel. */\nexport type GitHubConfig = z.infer<typeof githubConfigSchema>;\n"],"mappings":";;;;;;;;;;;;AASA,MAAa,qBAAqB,EAC/B,OAAO;CACN,OAAO,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC;CACvB,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC;CACtB,QAAQ,mBAAmB,SAAS;CACpC,MAAM,EAAE,KAAK,CAAC,YAAY,QAAQ,CAAC,CAAC,CAAC,SAAS;CAC9C,OAAO,EAAE,OAAO,CAAC,CAAC,MAAM,UAAU,wBAAwB,CAAC,CAAC,SAAS;CACrE,gBAAgB,EAAE,OAAO,CAAC,CAAC,MAAM,UAAU,iCAAiC,CAAC,CAAC,SAAS;AACzF,CAAC,CAAC,CACD,aAAa,KAAK,QAAQ;CACzB,IAAI,IAAI,SAAS,UACf;CAEF,KAAK,MAAM,SAAS,CAAC,SAAS,gBAAgB,GAC5C,IAAI,IAAI,WAAW,KAAA,GACjB,IAAI,SAAS;EAAE,MAAM;EAAU,MAAM,CAAC,KAAK;EAAG,SAAS,qBAAqB;CAAQ,CAAC;AAG3F,CAAC"}
@@ -0,0 +1,22 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/github-mapper.ts
5
+ /**
6
+ * GitHub's commit states. It has no cancelled state, so a cancelled build shows `error`, its
7
+ * "did not complete" state.
8
+ */
9
+ const STATES = {
10
+ pending: "pending",
11
+ success: "success",
12
+ failure: "failure",
13
+ cancelled: "error"
14
+ };
15
+ /** Map a StoryShelf build state to a GitHub commit state; an unknown state is `error`. */
16
+ function mapState(state) {
17
+ return STATES[state] ?? "error";
18
+ }
19
+ //#endregion
20
+ export { mapState };
21
+
22
+ //# sourceMappingURL=github-mapper.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-mapper.mjs","names":[],"sources":["../src/github-mapper.ts"],"sourcesContent":["import type { NotifierState } from \"@storyshelf/core/adapter/notifier\";\n\n/**\n * GitHub's commit states. It has no cancelled state, so a cancelled build shows `error`, its\n * \"did not complete\" state.\n */\nconst STATES: Record<NotifierState, \"pending\" | \"success\" | \"failure\" | \"error\"> = {\n pending: \"pending\",\n success: \"success\",\n failure: \"failure\",\n cancelled: \"error\",\n};\n\n/** Map a StoryShelf build state to a GitHub commit state; an unknown state is `error`. */\nexport function mapState(state: NotifierState): \"pending\" | \"success\" | \"failure\" | \"error\" {\n return STATES[state] ?? \"error\";\n}\n"],"mappings":";;;;;;;;AAMA,MAAM,SAA6E;CACjF,SAAS;CACT,SAAS;CACT,SAAS;CACT,WAAW;AACb;;AAGA,SAAgB,SAAS,OAAmE;CAC1F,OAAO,OAAO,UAAU;AAC1B"}
@@ -0,0 +1,20 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { githubHeaders, repoPath } from "./github-api.mjs";
5
+ import { httpJson } from "@storyshelf/core/utils";
6
+ //#region src/github-pr.ts
7
+ /** Find the pull request number associated with a commit SHA, if any. */
8
+ async function findPrNumber(opts) {
9
+ try {
10
+ const [pr] = await httpJson(repoPath(opts.owner, opts.repo, `/commits/${encodeURIComponent(opts.sha)}/pulls`), { headers: githubHeaders(opts.token) });
11
+ if (pr && typeof pr.number === "number") return pr.number;
12
+ return;
13
+ } catch {
14
+ return;
15
+ }
16
+ }
17
+ //#endregion
18
+ export { findPrNumber };
19
+
20
+ //# sourceMappingURL=github-pr.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-pr.mjs","names":[],"sources":["../src/github-pr.ts"],"sourcesContent":["import { httpJson } from \"@storyshelf/core/utils\";\nimport { githubHeaders, repoPath } from \"./github-api.ts\";\n\ninterface AssociatedPull {\n number?: unknown;\n}\n\n/** Find the pull request number associated with a commit SHA, if any. */\nexport async function findPrNumber(opts: {\n token: string;\n owner: string;\n repo: string;\n sha: string;\n}): Promise<number | undefined> {\n try {\n const pulls = await httpJson<AssociatedPull[]>(\n repoPath(opts.owner, opts.repo, `/commits/${encodeURIComponent(opts.sha)}/pulls`),\n { headers: githubHeaders(opts.token) },\n );\n const [pr] = pulls;\n if (pr && typeof pr.number === \"number\") {\n return pr.number;\n }\n return undefined;\n } catch {\n return undefined;\n }\n}\n"],"mappings":";;;;;;;AAQA,eAAsB,aAAa,MAKH;CAC9B,IAAI;EAKF,MAAM,CAAC,MAAM,MAJO,SAClB,SAAS,KAAK,OAAO,KAAK,MAAM,YAAY,mBAAmB,KAAK,GAAG,EAAE,OAAO,GAChF,EAAE,SAAS,cAAc,KAAK,KAAK,EAAE,CACvC;EAEA,IAAI,MAAM,OAAO,GAAG,WAAW,UAC7B,OAAO,GAAG;EAEZ;CACF,QAAQ;EACN;CACF;AACF"}