@procedures/gh 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +28 -1
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/module.d.ts +2 -1
  5. package/dist/module.d.ts.map +1 -1
  6. package/dist/module.js +43 -1
  7. package/dist/module.js.map +1 -1
  8. package/dist/procedures/gh/api.d.ts +27 -0
  9. package/dist/procedures/gh/api.d.ts.map +1 -0
  10. package/dist/procedures/gh/api.js +114 -0
  11. package/dist/procedures/gh/api.js.map +1 -0
  12. package/dist/procedures/gh/index.d.ts +3 -0
  13. package/dist/procedures/gh/index.d.ts.map +1 -1
  14. package/dist/procedures/gh/index.js +3 -0
  15. package/dist/procedures/gh/index.js.map +1 -1
  16. package/dist/procedures/gh/run/download.d.ts +14 -0
  17. package/dist/procedures/gh/run/download.d.ts.map +1 -0
  18. package/dist/procedures/gh/run/download.js +23 -0
  19. package/dist/procedures/gh/run/download.js.map +1 -0
  20. package/dist/procedures/gh/shared.d.ts +10 -2
  21. package/dist/procedures/gh/shared.d.ts.map +1 -1
  22. package/dist/procedures/gh/shared.js +16 -2
  23. package/dist/procedures/gh/shared.js.map +1 -1
  24. package/dist/procedures/gh/workflow/run.d.ts +13 -0
  25. package/dist/procedures/gh/workflow/run.d.ts.map +1 -0
  26. package/dist/procedures/gh/workflow/run.js +29 -0
  27. package/dist/procedures/gh/workflow/run.js.map +1 -0
  28. package/dist/types.d.ts +51 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/package.json +4 -4
  31. package/src/module.ts +67 -1
  32. package/src/procedures/gh/api.ts +99 -0
  33. package/src/procedures/gh/index.ts +3 -0
  34. package/src/procedures/gh/run/download.ts +23 -0
  35. package/src/procedures/gh/shared.ts +16 -2
  36. package/src/procedures/gh/workflow/run.ts +30 -0
  37. package/src/types.ts +57 -0
package/src/module.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Each procedure runs the GitHub CLI (`gh`) through `shell.run`, with an argument list and no
5
5
  * shell (roadmap R42). The reads declare `reads: ["network"]`. The procedures that make or change
6
- * something on GitHub declare `writes: ["network"]`.
6
+ * something on GitHub declare `writes: ["network"]`. `gh.run.download` writes files, so it
7
+ * declares `writes: ["fs"]`.
7
8
  *
8
9
  * Scaffold a procedure with: mark procedure new <name> --path packages/gh
9
10
  */
@@ -24,11 +25,23 @@ import { ghIssueList } from "./procedures/gh/issue/list.js";
24
25
  import { ghIssueView } from "./procedures/gh/issue/view.js";
25
26
  import { ghIssueCreate } from "./procedures/gh/issue/create.js";
26
27
  import { ghIssueComment } from "./procedures/gh/issue/comment.js";
28
+ import { ghWorkflowRun } from "./procedures/gh/workflow/run.js";
29
+ import { ghRunDownload } from "./procedures/gh/run/download.js";
30
+ import { ghApi } from "./procedures/gh/api.js";
27
31
 
28
32
  /** A read of GitHub: it changes nothing. */
29
33
  const READS: Effects = { reads: ["network"], writes: [] };
30
34
  /** A write to GitHub that makes something new: not destructive, and a second call makes a second one. */
31
35
  const MAKES: Effects = { reads: ["network"], writes: ["network"], destructive: false, idempotent: false };
36
+ /** A download from GitHub into files of the host. gh does not replace a file that exists, so it is not destructive. */
37
+ const DOWNLOADS: Effects = { reads: ["network"], writes: ["fs"], destructive: false, idempotent: false };
38
+ /**
39
+ * Any call of the GitHub API. The method is an input, and a procedure that writes by an input
40
+ * declares the writes (CAPABILITIES-2026-10, decision 14). PUT and DELETE can replace or remove
41
+ * data, so the declaration keeps the default: destructive. The temporary file of a body is not
42
+ * a file of the host that the call changes.
43
+ */
44
+ const CALLS_API: Effects = { reads: ["network"], writes: ["network"] };
32
45
 
33
46
  const common = {
34
47
  repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "owner/name").optional().describe("The repository as owner/name (default: the repository of the folder)"),
@@ -152,6 +165,59 @@ const procedures: AnyProcedure[] = [
152
165
  ghIssueComment,
153
166
  ["issue"],
154
167
  ),
168
+ gh(
169
+ ["gh", "workflow", "run"],
170
+ "Start a workflow run (a workflow_dispatch event) on a branch or a tag, with the inputs of the workflow",
171
+ z.object({
172
+ ...common,
173
+ workflow: z.string().min(1).describe("The name, the file name or the id of the workflow"),
174
+ ref: z.string().min(1).optional().describe("The branch or the tag (default: the default branch)"),
175
+ inputs: z.record(z.string(), z.string()).optional().describe("The inputs of the workflow, each one as -f key=value"),
176
+ }),
177
+ z.object({
178
+ workflow: z.string().describe("The workflow"),
179
+ ref: z.string().optional().describe("The branch or the tag"),
180
+ url: z.string().optional().describe("The URL of the new run, when gh prints it"),
181
+ run: z.number().optional().describe("The id of the new run, when gh prints its URL"),
182
+ }),
183
+ MAKES,
184
+ ghWorkflowRun,
185
+ ["workflow"],
186
+ ),
187
+ gh(
188
+ ["gh", "run", "download"],
189
+ "Download the artifacts of a workflow run into a folder",
190
+ z.object({
191
+ ...common,
192
+ run: z.number().int().min(1).describe("The id of the run"),
193
+ names: z.array(z.string().min(1)).optional().describe("Only the artifacts with these names"),
194
+ patterns: z.array(z.string().min(1)).optional().describe("Only the artifacts with names that match these glob patterns"),
195
+ dir: z.string().min(1).optional().describe("The folder for the artifacts (default: the folder where gh runs)"),
196
+ }),
197
+ z.object({ run: z.number().describe("The id of the run"), dir: z.string().describe("The folder of the artifacts") }),
198
+ DOWNLOADS,
199
+ ghRunDownload,
200
+ ["run"],
201
+ ),
202
+ gh(
203
+ ["gh", "api"],
204
+ "Call the GitHub API and give the parsed JSON. A method other than GET can change or delete data",
205
+ z.object({
206
+ ...common,
207
+ endpoint: z.string().min(1).describe("The endpoint, for example repos/{owner}/{repo}/pulls or graphql"),
208
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional().describe("The HTTP method (default GET)"),
209
+ fields: z
210
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
211
+ .optional()
212
+ .describe("The parameters: a string goes as -f, another value as -F. GET sends them in the query"),
213
+ body: z.unknown().optional().describe("A JSON body, sent with --input from a temporary file"),
214
+ paginate: z.boolean().optional().describe("Get all the pages. The pages of an array become one array"),
215
+ }),
216
+ z.object({ data: z.unknown().describe("The parsed JSON of the response (null when it is empty)") }),
217
+ CALLS_API,
218
+ ghApi,
219
+ ["endpoint"],
220
+ ),
155
221
  ];
156
222
 
157
223
  export const ghModule: ProcedureModule = defineModule("@procedures/gh", procedures, [shellModule]);
@@ -0,0 +1,99 @@
1
+ /**
2
+ * gh.api procedure
3
+ *
4
+ * A request to the GitHub API, and its parsed JSON
5
+ */
6
+
7
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import type { ProcedureContext } from "@procedures/client";
11
+ import type { GhApiInput, GhApiOutput } from "../../types.js";
12
+ import { fieldArg, ghArg, GhError, runGh } from "./shared.js";
13
+
14
+ /**
15
+ * This function splits text that has JSON objects and arrays one after the other, as
16
+ * `gh api --paginate` writes one value for each page. It throws for other text.
17
+ */
18
+ export function jsonValues(text: string): unknown[] {
19
+ const values: unknown[] = [];
20
+ let depth = 0;
21
+ let start = 0;
22
+ let inString = false;
23
+ let escaped = false;
24
+ for (let at = 0; at < text.length; at++) {
25
+ const char = text[at]!;
26
+ if (inString) {
27
+ if (escaped) escaped = false;
28
+ else if (char === "\\") escaped = true;
29
+ else if (char === '"') inString = false;
30
+ } else if (char === "{" || char === "[") {
31
+ if (depth === 0) start = at;
32
+ depth++;
33
+ } else if (char === "}" || char === "]") {
34
+ depth--;
35
+ if (depth < 0) throw new Error(`Unexpected "${char}" at ${at}`);
36
+ if (depth === 0) values.push(JSON.parse(text.slice(start, at + 1)));
37
+ } else if (depth === 0) {
38
+ if (!/\s/.test(char)) throw new Error(`Unexpected "${char}" at ${at}`);
39
+ } else if (char === '"') {
40
+ inString = true;
41
+ }
42
+ }
43
+ if (depth !== 0 || inString) throw new Error("The JSON ends too early");
44
+ return values;
45
+ }
46
+
47
+ /**
48
+ * This function reads the output of `gh api`. An empty output (for example HTTP 204) gives
49
+ * null. With `paginate`, the pages of an array become one array, and the pages of an object
50
+ * become an array of the pages.
51
+ */
52
+ export function parseGhApiOutput(output: string, paginate = false): unknown {
53
+ const text = output.trim();
54
+ if (text.length === 0) return null;
55
+ let values: unknown[];
56
+ try {
57
+ values = [JSON.parse(text)];
58
+ } catch {
59
+ try {
60
+ if (!paginate) throw new Error("not one JSON value");
61
+ values = jsonValues(text);
62
+ } catch {
63
+ throw new GhError(`gh api gave no JSON: ${text.slice(0, 200)}`, 0, "");
64
+ }
65
+ }
66
+ if (!paginate) return values[0];
67
+ return values.every((value) => Array.isArray(value)) ? values.flat(1) : values;
68
+ }
69
+
70
+ /**
71
+ * This function sends a request to the GitHub API with `gh api` and gives the parsed JSON. It always sends
72
+ * `--method`, because without it gh changes GET to POST when the call has fields. A string field
73
+ * goes as `-f` (gh sends it as text). Another value goes as `-F`, and gh converts its type. A
74
+ * `body` goes to gh as `--input` from a temporary file, which the call removes. A `repo` goes to
75
+ * gh as `GH_REPO`: gh replaces `{owner}` and `{repo}` in the endpoint with it.
76
+ */
77
+ export async function ghApi(input: GhApiInput, ctx: ProcedureContext): Promise<GhApiOutput> {
78
+ const args = ["api", ghArg("endpoint", input.endpoint), "--method", input.method ?? "GET"];
79
+ for (const [key, value] of Object.entries(input.fields ?? {})) {
80
+ // -F reads a value that starts with "@" from a file. A number, a boolean and null cannot start with "@".
81
+ if (typeof value === "string") args.push("-f", fieldArg("field", key, value));
82
+ else args.push("-F", fieldArg("field", key, String(value)));
83
+ }
84
+ if (input.paginate) args.push("--paginate");
85
+ const env = input.repo === undefined ? undefined : { GH_REPO: input.repo };
86
+
87
+ if (input.body === undefined) {
88
+ return { data: parseGhApiOutput(await runGh(args, input, ctx, env), input.paginate) };
89
+ }
90
+ const folder = await mkdtemp(join(tmpdir(), "procedures-gh-api-"));
91
+ try {
92
+ const file = join(folder, "body.json");
93
+ await writeFile(file, JSON.stringify(input.body), "utf8");
94
+ args.push("--input", file);
95
+ return { data: parseGhApiOutput(await runGh(args, input, ctx, env), input.paginate) };
96
+ } finally {
97
+ await rm(folder, { recursive: true, force: true });
98
+ }
99
+ }
@@ -11,3 +11,6 @@ export { ghIssueList } from "./issue/list.js";
11
11
  export { ghIssueView } from "./issue/view.js";
12
12
  export { ghIssueCreate } from "./issue/create.js";
13
13
  export { ghIssueComment } from "./issue/comment.js";
14
+ export { ghWorkflowRun } from "./workflow/run.js";
15
+ export { ghRunDownload } from "./run/download.js";
16
+ export { ghApi } from "./api.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * gh.run.download procedure
3
+ *
4
+ * Download the artifacts of a workflow run
5
+ */
6
+
7
+ import type { ProcedureContext } from "@procedures/client";
8
+ import type { GhRunDownloadInput, GhRunDownloadOutput } from "../../../types.js";
9
+ import { ghArg, repoArgs, runGh } from "../shared.js";
10
+
11
+ /**
12
+ * This function downloads the artifacts of a run with `gh run download`. gh puts each artifact in
13
+ * a folder with its name. When the input selects one artifact, gh puts its files directly in `dir`.
14
+ * gh does not replace a file that exists: the download then fails.
15
+ */
16
+ export async function ghRunDownload(input: GhRunDownloadInput, ctx: ProcedureContext): Promise<GhRunDownloadOutput> {
17
+ const args = ["run", "download", String(input.run), ...repoArgs(input)];
18
+ for (const name of input.names ?? []) args.push("--name", ghArg("name", name));
19
+ for (const pattern of input.patterns ?? []) args.push("--pattern", ghArg("pattern", pattern));
20
+ if (input.dir !== undefined) args.push("--dir", ghArg("dir", input.dir));
21
+ await runGh(args, input, ctx);
22
+ return { run: input.run, dir: input.dir ?? "." };
23
+ }
@@ -13,6 +13,7 @@ interface ShellRunInput {
13
13
  args: string[];
14
14
  cwd?: string;
15
15
  timeout?: number;
16
+ env?: Record<string, string>;
16
17
  }
17
18
 
18
19
  /** A failed gh command: its exit code and its error text. */
@@ -38,11 +39,15 @@ export function repoArgs(input: GhCommonInput): string[] {
38
39
  return input.repo === undefined ? [] : ["--repo", ghArg("repo", input.repo)];
39
40
  }
40
41
 
41
- /** This function runs `gh <args>` and gives its output. It throws a GhError for a failed command. */
42
- export async function runGh(args: string[], input: GhCommonInput, ctx: ProcedureContext): Promise<string> {
42
+ /**
43
+ * This function runs `gh <args>` and gives its output. It throws a GhError for a failed command.
44
+ * The `env` variables go to gh in addition to the environment of the host.
45
+ */
46
+ export async function runGh(args: string[], input: GhCommonInput, ctx: ProcedureContext, env?: Record<string, string>): Promise<string> {
43
47
  const shellInput: ShellRunInput = { command: "gh", args };
44
48
  if (input.cwd !== undefined) shellInput.cwd = input.cwd;
45
49
  if (input.timeoutMs !== undefined) shellInput.timeout = input.timeoutMs;
50
+ if (env !== undefined) shellInput.env = env;
46
51
  const result = await ctx.client.call<ShellRunInput, { exitCode: number; stdout: string; stderr: string }>(["shell", "run"], shellInput);
47
52
  if (result.exitCode !== 0) {
48
53
  const text = result.stderr.trim() || result.stdout.trim();
@@ -68,6 +73,15 @@ export function listArgs(input: { limit?: number | undefined; search?: string |
68
73
  return args;
69
74
  }
70
75
 
76
+ /**
77
+ * This function gives the `key=value` argument of a field (`-f`, `-F`). The key cannot be empty,
78
+ * contain "=" or start with "-".
79
+ */
80
+ export function fieldArg(name: string, key: string, value: string): string {
81
+ if (key.length === 0 || key.includes("=") || key.startsWith("-")) throw new Error(`Invalid ${name} name (empty, with "=", or starts with "-"): ${key}`);
82
+ return `${key}=${value}`;
83
+ }
84
+
71
85
  /** The last line of an output that is a URL: gh prints the URL of what it made. */
72
86
  export function urlOf(output: string): string {
73
87
  const lines = output.trim().split(/\r?\n/);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * gh.workflow.run procedure
3
+ *
4
+ * Start a workflow run: a `workflow_dispatch` event
5
+ */
6
+
7
+ import type { ProcedureContext } from "@procedures/client";
8
+ import type { GhWorkflowRunInput, GhWorkflowRunOutput } from "../../../types.js";
9
+ import { fieldArg, ghArg, repoArgs, runGh } from "../shared.js";
10
+
11
+ /**
12
+ * This function starts a workflow run with `gh workflow run`. Each input goes as `-f key=value`.
13
+ * When GitHub gives the new run, gh prints its URL, and the output has the URL and the run id.
14
+ */
15
+ export async function ghWorkflowRun(input: GhWorkflowRunInput, ctx: ProcedureContext): Promise<GhWorkflowRunOutput> {
16
+ const args = ["workflow", "run", ghArg("workflow", input.workflow), ...repoArgs(input)];
17
+ if (input.ref !== undefined) args.push("--ref", ghArg("ref", input.ref));
18
+ // -f gives each value as a string. -F reads a value that starts with "@" from a file, so it is not used.
19
+ for (const [key, value] of Object.entries(input.inputs ?? {})) args.push("-f", fieldArg("input", key, value));
20
+ const output = await runGh(args, input, ctx);
21
+
22
+ const result: GhWorkflowRunOutput = { workflow: input.workflow };
23
+ if (input.ref !== undefined) result.ref = input.ref;
24
+ const url = output.split(/\r?\n/).find((line) => /^https:\/\/\S+\/actions\/runs\/\d+/.test(line.trim()))?.trim();
25
+ if (url !== undefined) {
26
+ result.url = url;
27
+ result.run = Number(/\/actions\/runs\/(\d+)/.exec(url)![1]);
28
+ }
29
+ return result;
30
+ }
package/src/types.ts CHANGED
@@ -122,3 +122,60 @@ export interface GhIssueCommentInput extends GhCommonInput {
122
122
  body: string;
123
123
  }
124
124
  export type GhIssueCommentOutput = GhCreatedOutput;
125
+
126
+ export interface GhWorkflowRunInput extends GhCommonInput {
127
+ /** The name, the file name or the id of the workflow */
128
+ workflow: string;
129
+ /** The branch or the tag to run the workflow on (default: the default branch) */
130
+ ref?: string | undefined;
131
+ /** The inputs of the workflow. Each one goes to gh as `-f key=value`. */
132
+ inputs?: Record<string, string> | undefined;
133
+ }
134
+ export interface GhWorkflowRunOutput {
135
+ workflow: string;
136
+ ref?: string | undefined;
137
+ /** The URL of the new run, when gh prints it */
138
+ url?: string | undefined;
139
+ /** The id of the new run, when gh prints its URL */
140
+ run?: number | undefined;
141
+ }
142
+
143
+ export interface GhRunDownloadInput extends GhCommonInput {
144
+ /** The id of the run */
145
+ run: number;
146
+ /** Only the artifacts with these names */
147
+ names?: string[] | undefined;
148
+ /** Only the artifacts with names that match these glob patterns */
149
+ patterns?: string[] | undefined;
150
+ /** The folder for the artifacts (default: the folder where gh runs) */
151
+ dir?: string | undefined;
152
+ }
153
+ export interface GhRunDownloadOutput {
154
+ run: number;
155
+ /** The folder of the artifacts */
156
+ dir: string;
157
+ }
158
+
159
+ /** A value of a field of gh.api. A string goes as `-f`, another value as `-F` (gh converts its type). */
160
+ export type GhApiFieldValue = string | number | boolean | null;
161
+
162
+ /** The HTTP methods of gh.api. */
163
+ export type GhApiMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
164
+
165
+ export interface GhApiInput extends GhCommonInput {
166
+ /** The endpoint, for example `repos/{owner}/{repo}/pulls` or `graphql` */
167
+ endpoint: string;
168
+ /** The HTTP method (default GET) */
169
+ method?: GhApiMethod | undefined;
170
+ /** The parameters. GET sends them in the query, the other methods in the JSON body. */
171
+ fields?: Record<string, GhApiFieldValue> | undefined;
172
+ /** A JSON body. gh reads it from a temporary file (`--input`). */
173
+ body?: unknown;
174
+ /** Get all the pages of the result */
175
+ paginate?: boolean | undefined;
176
+ }
177
+ export interface GhApiOutput {
178
+ /** The parsed JSON of the response. An empty response gives null. */
179
+ data: unknown;
180
+ }
181
+