@bakers-build/addon-forge 0.10.0 → 0.10.1-next.1

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.
@@ -15,6 +15,13 @@ export interface ForgeCheck {
15
15
  readonly status: CheckStatus;
16
16
  /** Required by the base branch's rules (GitHub) or not allowed to fail (GitLab). */
17
17
  readonly required: boolean;
18
+ /** The forge's own verdict (`cancelled`, `skipped`, `timed_out`, ...); null while running. */
19
+ readonly conclusion: string | null;
20
+ /** GitHub Actions run / GitLab pipeline; null for a commit status. */
21
+ readonly run_id: number | null;
22
+ /** The id `forge_job_log` reads; null when the row is no job. */
23
+ readonly job_id: number | null;
24
+ readonly run_started_at: string | null;
18
25
  }
19
26
  export interface ForgeMerge {
20
27
  readonly method: 'squash' | 'merge';
@@ -35,18 +42,33 @@ export interface ForgeBackend {
35
42
  title: string;
36
43
  body: string;
37
44
  }): Promise<ForgePr>;
45
+ /** Also retargets the PR to `base`, which may have moved since it opened. */
38
46
  updatePr(pr: ForgePr, input: {
39
47
  title: string;
40
48
  body: string;
49
+ base: string;
41
50
  }): Promise<ForgePr>;
42
51
  openPrsWithBase(base: string): Promise<ForgePr[]>;
43
- /** Checks on `ref` (a SHA or branch), `required` as `base`'s rules say. */
44
- checks(ref: string, base: string): Promise<ForgeCheck[]>;
52
+ /**
53
+ * Checks on `ref` (a SHA or branch), `required` as `base`'s rules say. Only
54
+ * the newest run per workflow unless `allRuns`, where the forge has several.
55
+ */
56
+ checks(ref: string, base: string, opts?: {
57
+ allRuns?: boolean;
58
+ }): Promise<ForgeCheck[]>;
45
59
  /** A conflict or a not yet computed mergeability is a reason. */
46
60
  mergeBlocker(pr: ForgePr): Promise<string | undefined>;
47
61
  /** Merges with every option sent explicitly; deletes the source when asked. */
48
62
  merge(pr: ForgePr, options: ForgeMerge): Promise<void>;
49
63
  isMerged(pr: ForgePr): Promise<boolean>;
64
+ /** The [tail] of a job's log; refuses a job outside the repository. */
65
+ jobLog(jobId: number, maxBytes: number): Promise<LogTail>;
66
+ }
67
+ export interface LogTail {
68
+ readonly text: string;
69
+ /** The whole log's size. */
70
+ readonly bytes: number;
71
+ readonly truncated: boolean;
50
72
  }
51
73
  export type ForgeBackendFactory = (settings: unknown, context: {
52
74
  readonly home: string;
@@ -60,7 +82,16 @@ export declare function assertForgeBackend(value: unknown, spec: string): assert
60
82
  * Every value read is pushed to `seen`, for the backend's `secrets()`.
61
83
  */
62
84
  export declare function cliToken(cmd: string, args: string[], seen: string[]): () => Promise<string>;
85
+ /** Text over HTTP for backends: throws status and body text on failure. */
86
+ export declare function requestText(doFetch: typeof fetch, url: string, init: RequestInit & {
87
+ allow404?: boolean;
88
+ }): Promise<string | undefined>;
63
89
  /** JSON over HTTP for backends: throws status and body text on failure. */
64
90
  export declare function requestJson(doFetch: typeof fetch, url: string, init: RequestInit & {
65
91
  allow404?: boolean;
66
92
  }): Promise<unknown>;
93
+ /**
94
+ * The last `maxBytes` bytes of `log`, from the first whole line in them; when
95
+ * the last line alone is longer, from the first whole character.
96
+ */
97
+ export declare function tail(log: string, maxBytes: number): LogTail;
@@ -14,6 +14,7 @@ const METHODS = [
14
14
  'mergeBlocker',
15
15
  'merge',
16
16
  'isMerged',
17
+ 'jobLog',
17
18
  ];
18
19
  /** Reject a factory result that is not a `ForgeBackend`. */
19
20
  export function assertForgeBackend(value, spec) {
@@ -44,13 +45,39 @@ export function cliToken(cmd, args, seen) {
44
45
  return value;
45
46
  };
46
47
  }
47
- /** JSON over HTTP for backends: throws status and body text on failure. */
48
- export async function requestJson(doFetch, url, init) {
48
+ /** Text over HTTP for backends: throws status and body text on failure. */
49
+ export async function requestText(doFetch, url, init) {
49
50
  const res = await doFetch(url, init);
50
51
  if (res.status === 404 && init.allow404)
51
52
  return undefined;
52
53
  const text = await res.text();
53
54
  if (!res.ok)
54
55
  throw new Error(`${init.method ?? 'GET'} ${new URL(url).pathname} answered ${res.status}: ${text.slice(0, 500)}`);
56
+ return text;
57
+ }
58
+ /** JSON over HTTP for backends: throws status and body text on failure. */
59
+ export async function requestJson(doFetch, url, init) {
60
+ const text = await requestText(doFetch, url, init);
55
61
  return text ? JSON.parse(text) : undefined;
56
62
  }
63
+ /**
64
+ * The last `maxBytes` bytes of `log`, from the first whole line in them; when
65
+ * the last line alone is longer, from the first whole character.
66
+ */
67
+ export function tail(log, maxBytes) {
68
+ const buf = Buffer.from(log);
69
+ if (buf.length <= maxBytes)
70
+ return { text: log, bytes: buf.length, truncated: false };
71
+ let start = buf.length - maxBytes;
72
+ const newline = buf.indexOf(0x0a, start - 1);
73
+ if (newline !== -1 && newline < buf.length - 1)
74
+ start = newline + 1;
75
+ else
76
+ while (start < buf.length && (buf.readUInt8(start) & 0xc0) === 0x80)
77
+ start++;
78
+ return {
79
+ text: buf.subarray(start).toString(),
80
+ bytes: buf.length,
81
+ truncated: true,
82
+ };
83
+ }
@@ -1,4 +1,4 @@
1
- // The forge addon: five conductor tools a sandboxed run delivers through,
1
+ // The forge addon: six conductor tools a sandboxed run delivers through,
2
2
  // while the token stays with the conductor. Without a forge entry no tool is
3
3
  // served and runs deliver as before.
4
4
  import { FORGE_TOOL_DEFINITIONS } from './tool-definitions.js';
@@ -42,17 +42,36 @@ export const FORGE_TOOL_DEFINITIONS = {
42
42
  },
43
43
  },
44
44
  forge_checks: {
45
- description: "Status of each check or pipeline job, and whether it is required, on the head of an owned branch's PR/MR, or on any `ref` (branch or SHA); read only, one shot.",
45
+ description: "Status of each check or pipeline job on the head of an owned branch's PR/MR, or on any `ref` (branch or SHA); read only, one shot. Each row: status, required, the forge's raw conclusion (cancelled, skipped, timed_out, ...), run_id, job_id (for forge_job_log), run_started_at. overall is success only when every required check (flagged, or named required) is green; else failure, pending or no_required_checks.",
46
46
  input: {
47
47
  type: 'object',
48
48
  properties: {
49
49
  branch,
50
50
  ref: { type: 'string', description: 'any branch or SHA, read only' },
51
+ all_runs: {
52
+ type: 'boolean',
53
+ description: 'every run on the commit; default: only the newest run per workflow',
54
+ },
55
+ },
56
+ },
57
+ },
58
+ forge_job_log: {
59
+ description: "The end of one job's log, by a job_id from forge_checks, read with the conductor's token; read only. Starts at a whole line; truncated says whether earlier lines were cut.",
60
+ input: {
61
+ type: 'object',
62
+ properties: {
63
+ job_id: { type: 'integer', minimum: 1 },
64
+ max_bytes: {
65
+ type: 'integer',
66
+ minimum: 1,
67
+ description: 'bytes from the end; default 16384, at most 262144',
68
+ },
51
69
  },
70
+ required: ['job_id'],
52
71
  },
53
72
  },
54
73
  forge_merge: {
55
- description: "Merge the open PR/MR of an owned branch into its base, once it is conflict free and every required check is green. Human approval is the workflow's merge gate, not this tool's.",
74
+ description: "Merge the open PR/MR of an owned branch into its base, once it is conflict free and overall is success. Human approval is the workflow's merge gate, not this tool's.",
56
75
  input: { type: 'object', properties: { branch } },
57
76
  },
58
77
  };
@@ -6,8 +6,13 @@ export interface ForgeToolDeps {
6
6
  /** The forge entry's `with` (merge rules). */
7
7
  settings: unknown;
8
8
  }
9
- /** Failure first, then pending, over the required checks. */
10
- export declare function overall(checks: readonly ForgeCheck[]): ForgeCheck['status'];
9
+ export declare const LOG_DEFAULT_BYTES = 16384;
10
+ export declare const LOG_MAX_BYTES = 262144;
11
+ export type Overall = 'success' | 'failure' | 'pending' | 'no_required_checks';
12
+ /** Required by the forge, or the aggregate check a workflow names `required`. */
13
+ export declare const isRequired: (c: ForgeCheck) => boolean;
14
+ /** Failure first, then pending, over the required checks; none required is never green. */
15
+ export declare function overall(checks: readonly ForgeCheck[]): Overall;
11
16
  export declare function forgeTools(deps: ForgeToolDeps): Record<string, ToolHandler>;
12
17
  /** The live tools for the conductor's effective forge entry `with`. */
13
18
  export declare function toolsFor(options: unknown): Record<string, ToolHandler>;
package/dist/src/tools.js CHANGED
@@ -17,19 +17,45 @@ function runOf(req) {
17
17
  ...(t.targetBranch ? { targetBranch: t.targetBranch } : {}),
18
18
  };
19
19
  }
20
+ /**
21
+ * Where `branch` merges: a variant into the ticket branch, the ticket branch into
22
+ * the ticket's current target as the server returns it now. A failed read refuses;
23
+ * the run copy may predate an environment added during the run.
24
+ */
25
+ async function currentBase(req, branch, run) {
26
+ if (branch !== run.branchName)
27
+ return derivedBase(branch, run);
28
+ const refuse = (why) => new Error(`forge: the ticket's current target_branch could not be read from the server (${why}); nothing was sent`);
29
+ if (!req.currentTicket)
30
+ throw refuse('no server read for this call');
31
+ const t = await req.currentTicket().catch((err) => {
32
+ throw refuse(err instanceof Error ? err.message : String(err));
33
+ });
34
+ const target = t.targetBranch ?? t.baseBranch;
35
+ if (!target)
36
+ throw refuse('no target_branch or base_branch returned');
37
+ return target;
38
+ }
20
39
  function ownedBranch(req, run) {
21
40
  const branch = req.args.branch ?? run.branchName;
22
41
  if (!isOwned(branch, run.branchName))
23
42
  throw new Error(ownershipRefusal(branch, run.branchName));
24
43
  return branch;
25
44
  }
45
+ export const LOG_DEFAULT_BYTES = 16_384;
46
+ export const LOG_MAX_BYTES = 262_144;
26
47
  const json = (value) => ({
27
48
  text: JSON.stringify(value, null, 2),
28
49
  });
29
- /** Failure first, then pending, over the required checks. */
50
+ /** Required by the forge, or the aggregate check a workflow names `required`. */
51
+ export const isRequired = (c) => c.required || c.name === 'required';
52
+ const isRed = (c) => c.status === 'failure' || c.conclusion === 'skipped';
53
+ /** Failure first, then pending, over the required checks; none required is never green. */
30
54
  export function overall(checks) {
31
- const required = checks.filter((c) => c.required);
32
- if (required.some((c) => c.status === 'failure'))
55
+ const required = checks.filter(isRequired);
56
+ if (required.length === 0)
57
+ return 'no_required_checks';
58
+ if (required.some(isRed))
33
59
  return 'failure';
34
60
  if (required.some((c) => c.status !== 'success'))
35
61
  return 'pending';
@@ -91,13 +117,14 @@ export function forgeTools(deps) {
91
117
  const run = runOf(req);
92
118
  const branch = ownedBranch(req, run);
93
119
  const body = req.args.body ?? '';
120
+ const base = await currentBase(req, branch, run);
94
121
  const b = await backend();
95
122
  const open = await b.findOpenPr(branch);
96
123
  const pr = open
97
- ? await b.updatePr(open, { title, body })
124
+ ? await b.updatePr(open, { title, body, base })
98
125
  : await b.createPr({
99
126
  head: branch,
100
- base: derivedBase(branch, run),
127
+ base,
101
128
  title,
102
129
  body,
103
130
  });
@@ -122,13 +149,15 @@ export function forgeTools(deps) {
122
149
  ref = pr?.headSha ?? branch;
123
150
  base = pr?.base ?? derivedBase(branch, run);
124
151
  }
125
- const checks = await b.checks(ref, base);
152
+ const checks = await b.checks(ref, base, {
153
+ allRuns: req.args.all_runs === true,
154
+ });
126
155
  return json({ ref, base, overall: overall(checks), checks });
127
156
  },
128
157
  forge_merge: async (req) => {
129
158
  const run = runOf(req);
130
159
  const branch = ownedBranch(req, run);
131
- const base = derivedBase(branch, run);
160
+ const base = await currentBase(req, branch, run);
132
161
  const b = await backend();
133
162
  const pr = await b.findOpenPr(branch);
134
163
  if (!pr)
@@ -141,9 +170,11 @@ export function forgeTools(deps) {
141
170
  if (blocker)
142
171
  throw new Error(`PR/MR ${pr.url} cannot merge: ${blocker}`);
143
172
  const checks = await b.checks(pr.headSha, pr.base);
144
- const notGreen = checks.filter((c) => c.required && c.status !== 'success');
173
+ if (overall(checks) === 'no_required_checks')
174
+ throw new Error(`PR/MR ${pr.url} cannot merge: no required checks reported`);
175
+ const notGreen = checks.filter((c) => isRequired(c) && (isRed(c) || c.status !== 'success'));
145
176
  if (notGreen.length > 0)
146
- throw new Error(`PR/MR ${pr.url} has required checks that are not green: ${notGreen.map((c) => `${c.name} (${c.status})`).join(', ')}`);
177
+ throw new Error(`PR/MR ${pr.url} has required checks that are not green: ${notGreen.map((c) => `${c.name} (${c.conclusion ?? c.status})`).join(', ')}`);
147
178
  const rule = mergeRule(deps.settings, base);
148
179
  const dependents = (await b.openPrsWithBase(branch)).filter((p) => p.number !== pr.number);
149
180
  const deleteSource = rule.delete_source && dependents.length === 0;
@@ -166,6 +197,24 @@ export function forgeTools(deps) {
166
197
  : {}),
167
198
  });
168
199
  },
200
+ forge_job_log: async (req) => {
201
+ runOf(req);
202
+ const jobId = req.args.job_id;
203
+ if (typeof jobId !== 'number' || !Number.isInteger(jobId) || jobId <= 0)
204
+ throw new Error('job_id must be a job_id from forge_checks');
205
+ const asked = req.args.max_bytes;
206
+ const maxBytes = typeof asked === 'number' && asked >= 1
207
+ ? Math.min(Math.floor(asked), LOG_MAX_BYTES)
208
+ : LOG_DEFAULT_BYTES;
209
+ const log = await (await backend()).jobLog(jobId, maxBytes);
210
+ return json({
211
+ job_id: jobId,
212
+ bytes: log.bytes,
213
+ truncated: log.truncated,
214
+ max_bytes: maxBytes,
215
+ log: log.text,
216
+ });
217
+ },
169
218
  };
170
219
  return Object.fromEntries(Object.entries(tools).map(([n, h]) => [n, guarded(h)]));
171
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-forge",
3
- "version": "0.10.0",
3
+ "version": "0.10.1-next.1",
4
4
  "description": "bakers.build forge tools: a sandboxed run fetches, pushes its own branches, opens its PR/MR, reads checks and merges through the conductor, which holds the token. Backends implement the ForgeBackend contract (./backend).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,11 +22,11 @@
22
22
  "directory": "bakers-cli/addons/forge"
23
23
  },
24
24
  "peerDependencies": {
25
- "@bakers-build/conductor": "^0.10.0",
26
- "@bakers-build/core": "^0.10.0"
25
+ "@bakers-build/conductor": "^0.10.1-next.1",
26
+ "@bakers-build/core": "^0.10.1-next.1"
27
27
  },
28
28
  "bakersBuild": {
29
- "commit": "5857b00008bc0926fd52fdc8d9366d6d52444e35",
30
- "channel": "latest"
29
+ "commit": "f9cee7721df536cac94906e7278d60a13e202502",
30
+ "channel": "next"
31
31
  }
32
32
  }