@bakers-build/addon-forge 0.9.1-next.5 → 0.9.1-next.7

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';
@@ -40,13 +47,26 @@ export interface ForgeBackend {
40
47
  body: string;
41
48
  }): Promise<ForgePr>;
42
49
  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[]>;
50
+ /**
51
+ * Checks on `ref` (a SHA or branch), `required` as `base`'s rules say. Only
52
+ * the newest run per workflow unless `allRuns`, where the forge has several.
53
+ */
54
+ checks(ref: string, base: string, opts?: {
55
+ allRuns?: boolean;
56
+ }): Promise<ForgeCheck[]>;
45
57
  /** A conflict or a not yet computed mergeability is a reason. */
46
58
  mergeBlocker(pr: ForgePr): Promise<string | undefined>;
47
59
  /** Merges with every option sent explicitly; deletes the source when asked. */
48
60
  merge(pr: ForgePr, options: ForgeMerge): Promise<void>;
49
61
  isMerged(pr: ForgePr): Promise<boolean>;
62
+ /** The [tail] of a job's log; refuses a job outside the repository. */
63
+ jobLog(jobId: number, maxBytes: number): Promise<LogTail>;
64
+ }
65
+ export interface LogTail {
66
+ readonly text: string;
67
+ /** The whole log's size. */
68
+ readonly bytes: number;
69
+ readonly truncated: boolean;
50
70
  }
51
71
  export type ForgeBackendFactory = (settings: unknown, context: {
52
72
  readonly home: string;
@@ -60,7 +80,16 @@ export declare function assertForgeBackend(value: unknown, spec: string): assert
60
80
  * Every value read is pushed to `seen`, for the backend's `secrets()`.
61
81
  */
62
82
  export declare function cliToken(cmd: string, args: string[], seen: string[]): () => Promise<string>;
83
+ /** Text over HTTP for backends: throws status and body text on failure. */
84
+ export declare function requestText(doFetch: typeof fetch, url: string, init: RequestInit & {
85
+ allow404?: boolean;
86
+ }): Promise<string | undefined>;
63
87
  /** JSON over HTTP for backends: throws status and body text on failure. */
64
88
  export declare function requestJson(doFetch: typeof fetch, url: string, init: RequestInit & {
65
89
  allow404?: boolean;
66
90
  }): Promise<unknown>;
91
+ /**
92
+ * The last `maxBytes` bytes of `log`, from the first whole line in them; when
93
+ * the last line alone is longer, from the first whole character.
94
+ */
95
+ 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
@@ -1,7 +1,7 @@
1
1
  // The forge tools. Every call takes the ticket, branch_name and base_branch
2
2
  // from the calling run; the token stays with the backend and is redacted
3
3
  // from every result and error.
4
- import { homeBbDir } from '@bakers-build/core';
4
+ import { homeBbDir, loadRunClone, refreshRunTarget, saveRunClone, transferIsolatedCommit, } from '@bakers-build/core';
5
5
  import { FORGE_NEEDS_BACKEND, loadForgeBackend, mergeRule } from './config.js';
6
6
  import { fetchBranches, pushBranch } from './git.js';
7
7
  import { derivedBase, isConventionalTitle, isOwned, ownershipRefusal, redact, TITLE_REFUSAL, } from './rules.js';
@@ -23,13 +23,20 @@ function ownedBranch(req, run) {
23
23
  throw new Error(ownershipRefusal(branch, run.branchName));
24
24
  return branch;
25
25
  }
26
+ export const LOG_DEFAULT_BYTES = 16_384;
27
+ export const LOG_MAX_BYTES = 262_144;
26
28
  const json = (value) => ({
27
29
  text: JSON.stringify(value, null, 2),
28
30
  });
29
- /** Failure first, then pending, over the required checks. */
31
+ /** Required by the forge, or the aggregate check a workflow names `required`. */
32
+ export const isRequired = (c) => c.required || c.name === 'required';
33
+ const isRed = (c) => c.status === 'failure' || c.conclusion === 'skipped';
34
+ /** Failure first, then pending, over the required checks; none required is never green. */
30
35
  export function overall(checks) {
31
- const required = checks.filter((c) => c.required);
32
- if (required.some((c) => c.status === 'failure'))
36
+ const required = checks.filter(isRequired);
37
+ if (required.length === 0)
38
+ return 'no_required_checks';
39
+ if (required.some(isRed))
33
40
  return 'failure';
34
41
  if (required.some((c) => c.status !== 'success'))
35
42
  return 'pending';
@@ -57,23 +64,30 @@ export function forgeTools(deps) {
57
64
  .split(/\s+/)
58
65
  .filter(Boolean);
59
66
  const b = await backend();
60
- const refs = await fetchBranches(req.worktreePath, await b.gitAuth(), [
61
- run.baseBranch,
62
- run.branchName,
63
- ...extra,
64
- ]);
67
+ // A run clone has no Git on the host: the parent repository is fetched.
68
+ const clone = loadRunClone(req.worktreePath);
69
+ const refs = await fetchBranches(clone?.parentGitDir ?? req.worktreePath, await b.gitAuth(), [run.baseBranch, run.branchName, ...extra]);
70
+ // The run sees what the parent got, written without Git in its directory.
71
+ if (clone)
72
+ refreshRunTarget(clone, refs.map((ref) => ref.name));
65
73
  return json({ fetched: refs });
66
74
  },
67
75
  forge_push: async (req) => {
68
76
  const run = runOf(req);
69
77
  const branch = ownedBranch(req, run);
70
78
  const b = await backend();
71
- const sha = await pushBranch(req.worktreePath, await b.gitAuth(), branch, {
79
+ const clone = loadRunClone(req.worktreePath);
80
+ // Only checked commits leave the run: transfer first, push from the parent.
81
+ if (clone && branch === clone.branch)
82
+ saveRunClone({ ...clone, expected: transferIsolatedCommit(clone) });
83
+ const sha = await pushBranch(clone?.parentGitDir ?? req.worktreePath, await b.gitAuth(), branch, {
72
84
  forceWithLease: req.args.force_with_lease === true,
73
85
  ...(typeof req.args.expected_sha === 'string'
74
86
  ? { expectedSha: req.args.expected_sha }
75
87
  : {}),
76
88
  });
89
+ if (clone)
90
+ refreshRunTarget(clone, [branch]);
77
91
  return json({ branch, sha });
78
92
  },
79
93
  forge_pr_upsert: async (req) => {
@@ -115,7 +129,9 @@ export function forgeTools(deps) {
115
129
  ref = pr?.headSha ?? branch;
116
130
  base = pr?.base ?? derivedBase(branch, run);
117
131
  }
118
- const checks = await b.checks(ref, base);
132
+ const checks = await b.checks(ref, base, {
133
+ allRuns: req.args.all_runs === true,
134
+ });
119
135
  return json({ ref, base, overall: overall(checks), checks });
120
136
  },
121
137
  forge_merge: async (req) => {
@@ -134,9 +150,11 @@ export function forgeTools(deps) {
134
150
  if (blocker)
135
151
  throw new Error(`PR/MR ${pr.url} cannot merge: ${blocker}`);
136
152
  const checks = await b.checks(pr.headSha, pr.base);
137
- const notGreen = checks.filter((c) => c.required && c.status !== 'success');
153
+ if (overall(checks) === 'no_required_checks')
154
+ throw new Error(`PR/MR ${pr.url} cannot merge: no required checks reported`);
155
+ const notGreen = checks.filter((c) => isRequired(c) && (isRed(c) || c.status !== 'success'));
138
156
  if (notGreen.length > 0)
139
- throw new Error(`PR/MR ${pr.url} has required checks that are not green: ${notGreen.map((c) => `${c.name} (${c.status})`).join(', ')}`);
157
+ throw new Error(`PR/MR ${pr.url} has required checks that are not green: ${notGreen.map((c) => `${c.name} (${c.conclusion ?? c.status})`).join(', ')}`);
140
158
  const rule = mergeRule(deps.settings, base);
141
159
  const dependents = (await b.openPrsWithBase(branch)).filter((p) => p.number !== pr.number);
142
160
  const deleteSource = rule.delete_source && dependents.length === 0;
@@ -159,6 +177,24 @@ export function forgeTools(deps) {
159
177
  : {}),
160
178
  });
161
179
  },
180
+ forge_job_log: async (req) => {
181
+ runOf(req);
182
+ const jobId = req.args.job_id;
183
+ if (typeof jobId !== 'number' || !Number.isInteger(jobId) || jobId <= 0)
184
+ throw new Error('job_id must be a job_id from forge_checks');
185
+ const asked = req.args.max_bytes;
186
+ const maxBytes = typeof asked === 'number' && asked >= 1
187
+ ? Math.min(Math.floor(asked), LOG_MAX_BYTES)
188
+ : LOG_DEFAULT_BYTES;
189
+ const log = await (await backend()).jobLog(jobId, maxBytes);
190
+ return json({
191
+ job_id: jobId,
192
+ bytes: log.bytes,
193
+ truncated: log.truncated,
194
+ max_bytes: maxBytes,
195
+ log: log.text,
196
+ });
197
+ },
162
198
  };
163
199
  return Object.fromEntries(Object.entries(tools).map(([n, h]) => [n, guarded(h)]));
164
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-forge",
3
- "version": "0.9.1-next.5",
3
+ "version": "0.9.1-next.7",
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.9.1-next.5",
26
- "@bakers-build/core": "^0.9.1-next.5"
25
+ "@bakers-build/conductor": "^0.9.1-next.7",
26
+ "@bakers-build/core": "^0.9.1-next.7"
27
27
  },
28
28
  "bakersBuild": {
29
- "commit": "d9242cad6cabee1faf010b5d46ac1bac33f6e0a1",
29
+ "commit": "03781c6f8dc8c4c3d87fdcf12ae37df35abc6286",
30
30
  "channel": "next"
31
31
  }
32
32
  }