harn-stdlib 0.10.32

Embedded Harn standard library source catalog
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
/**
 * std/bump/live — the live [`BumpAdapter`] that binds std/bump/runtime's pure
 * state machine to real effects: git and the caller's validation entrypoint via
 * `std/command`, and GitHub through the `gh` CLI (also via `std/command`).
 *
 * GitHub access goes through `gh api` rather than `std/connectors/github`: the
 * stdlib connector's `connector_call("github", …)` requires an ACTIVE connector
 * client, which is only installed in hosted/orchestrator runtimes — in the plain
 * `harn run` the reusable bump workflow uses, no github connector is active, so
 * every connector call throws `connector 'github' is not active`. `gh` is
 * present and works in that runtime, matching how the rest of the fleet
 * (`lib/github`) reaches GitHub.
 *
 * This module is deliberately branch-free. It performs the I/O the orchestrator
 * asks for and normalizes the result; ALL control flow lives in
 * `std/bump/runtime`, which is exercised by the in-process fixtures. That seam
 * is why the fixtures need no network: they replace this adapter wholesale, so
 * none of the `gh` calls below run under `harn test`. The live GitHub-mutation
 * surface here (ref lease reset, signed `createCommitOnBranch`, PR upsert,
 * auto-merge arm/disarm) is first exercised against real GitHub in CI.
 *
 * Auth is the GitHub App installation token, passed to `gh` via GH_TOKEN (see
 * `__gh_spec`). Signed commits use the GraphQL `createCommitOnBranch` mutation
 * rather than a local `git commit` + push: commits created through the App token
 * are signed by GitHub, satisfying an org `required_signatures` ruleset that an
 * unsigned local push would violate.
 */
import { BumpAdapter } from "std/bump/runtime"
import { command_run } from "std/command"
import { merge } from "std/json"
import { poll_until } from "std/poll"
import { is_v_semver } from "std/semver"

/** Everything the live adapter needs to reach git, GitHub, and validation. */
pub type LiveBumpConfig = {
  repo: string,
  token: string,
  branch: string,
  base: string,
  version_file: string,
  refresh_command: string,
  validation_command: string,
  readiness_max_attempts: int,
  readiness_interval_ms: int,
  readiness_timeout_ms: int,
}

/** Release finalization sidecars that prove a Harn release is fully published. */
const REQUIRED_RELEASE_ASSETS = ["SHA256SUMS", "release-assets.json"]

/**
 * Fill a caller's partial config with the canonical defaults used by the
 * reusable workflow.
 *
 * @effects: []
 * @errors: []
 */
pub fn live_bump_config(overrides: dict = {}) -> LiveBumpConfig {
  const o = overrides ?? {}
  return {
    repo: to_string(o.repo ?? ""),
    token: to_string(o.token ?? ""),
    branch: to_string(o.branch ?? "automation/bump-harn-runtime"),
    base: to_string(o.base ?? "main"),
    version_file: to_string(o.version_file ?? ".harn-version"),
    refresh_command: to_string(o.refresh_command ?? "harn install --locked || harn install"),
    validation_command: to_string(o.validation_command ?? ""),
    readiness_max_attempts: to_int(o.readiness_max_attempts ?? 1) ?? 1,
    readiness_interval_ms: to_int(o.readiness_interval_ms ?? 20000) ?? 20000,
    readiness_timeout_ms: to_int(o.readiness_timeout_ms ?? 20000) ?? 20000,
  }
}

/**
 * GitHub App installation token used to authenticate the `gh` CLI. Set once by
 * the live adapter builder. The bump runs one repo per process, so a
 * module-level value needs no per-call threading.
 */
let __bump_gh_token = ""

/**
 * Build a command_run spec for a `gh` invocation. GH_TOKEN is patched INTO the
 * inherited environment (env_mode "patch") so gh keeps PATH/HOME/its config —
 * the default env_mode is "replace", which would wipe them.
 */
fn __gh_spec(argv: list<string>, stdin_body = nil) {
  let spec = {argv: argv}
  if trim(__bump_gh_token) != "" {
    spec = merge(spec, {env: {GH_TOKEN: __bump_gh_token}, env_mode: "patch"})
  }
  if stdin_body != nil {
    spec = merge(spec, {stdin: stdin_body})
  }
  return spec
}

fn __json_or_nil(text) {
  const t = trim(to_string(text ?? ""))
  if t == "" {
    return nil
  }
  const parsed = try {
    json_parse(t)
  }
  return if is_err(parsed) {
    nil
  } else {
    parsed
  }
}

fn __git(argv: list<string>) {
  return command_run(argv)
}

fn __shell(command: string) {
  return command_run({mode: "shell", command: command})
}

/**
 * One REST call through the `gh` CLI, authenticated by the App token in
 * GH_TOKEN. Returns the parsed JSON body, or nil on any non-zero exit —
 * preserving the previous adapter's fail-soft contract. Unlike
 * std/connectors/github (whose connector client is NOT active in a plain
 * `harn run`), `gh` works in the bump workflow's runtime.
 */
fn __rest(method: string, path: string, body = nil) {
  const spec = if body == nil {
    __gh_spec(["gh", "api", path, "-X", method])
  } else {
    __gh_spec(["gh", "api", path, "-X", method, "--input", "-"], json_stringify(body))
  }
  const result = command_run(spec)
  return if result.success ?? false {
    __json_or_nil(result.stdout ?? "")
  } else {
    nil
  }
}

/** One GraphQL call through `gh api /graphql`; nil on failure. */
fn __graphql(query: string, variables: dict) {
  const spec = __gh_spec(
    ["gh", "api", "/graphql", "-X", "POST", "--input", "-"],
    json_stringify({query: query, variables: variables}),
  )
  const result = command_run(spec)
  return if result.success ?? false {
    __json_or_nil(result.stdout ?? "")
  } else {
    nil
  }
}

/** Release lookup normalized to the `{ok, ...release}` shape the adapter reads. */
fn __release_view(repo: string, tag: string) {
  const release = __rest("GET", "/repos/${repo}/releases/tags/${tag}")
  return if release == nil {
    {ok: false}
  } else {
    merge(release, {ok: true})
  }
}

/** Close a PR (commenting first when a comment is given); `{ok}` mirrors the old helper. */
fn __close_pr(repo: string, number, comment) {
  if trim(to_string(comment ?? "")) != "" {
    __rest("POST", "/repos/${repo}/issues/${number}/comments", {body: comment})
  }
  const closed = __rest("PATCH", "/repos/${repo}/pulls/${number}", {state: "closed"})
  return {ok: closed != nil}
}

/** Arm squash auto-merge via GraphQL; `{ok, state, error}` mirrors the old helper. */
fn __enable_auto_merge(repo: string, number) {
  const pr = __rest("GET", "/repos/${repo}/pulls/${number}")
  const node_id = to_string(pr?.node_id ?? "")
  if node_id == "" {
    return {ok: false, state: "", error: "could not resolve PR node id"}
  }
  const mutation =
    "mutation($id: ID!) { enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: SQUASH}) { pullRequest { autoMergeRequest { enabledAt } } } }"
  const result = __graphql(mutation, {id: node_id})
  const enabled = result?.data?.enablePullRequestAutoMerge?.pullRequest?.autoMergeRequest?.enabledAt
    != nil
  return {
    ok: enabled,
    state: if enabled {
      "armed"
    } else {
      ""
    },
    error: if enabled {
      nil
    } else {
      result?.errors ?? "auto-merge not enabled"
    },
  }
}

fn __release_finalized(release) -> bool {
  const draft = release?.draft ?? true
  const prerelease = release?.prerelease ?? true
  const asset_names = release?.asset_names ?? (release?.assets ?? []).map({ a -> a.name }).to_list()
  const missing = REQUIRED_RELEASE_ASSETS.filter({ name -> !contains(asset_names, name) }).to_list()
  return !draft && !prerelease && len(missing) == 0
}

/** Fetch the open bump PR via the REST list endpoint (there is no typed helper). */
fn __find_open_pr(cfg: LiveBumpConfig, owner: string) {
  const list = __rest(
    "GET",
    "/repos/${cfg.repo}/pulls?head=${owner}:${cfg.branch}&base=${cfg.base}&state=open",
  )
  return (list ?? [])[0]
}

/**
 * Build the live BumpAdapter for one repository and token. Pass the result
 * straight to `run_bump`.
 *
 * @effects: [net, process]
 * @errors: []
 */
pub fn live_bump_adapter(config: dict = {}) -> BumpAdapter {
  const cfg = live_bump_config(config)
  const owner = split(cfg.repo, "/")[0] ?? ""
  // Authenticate every `gh` CLI call as the GitHub App for this run.
  __bump_gh_token = cfg.token
  return {
    resolve_target_tag: fn(tag) {
      const requested = trim(tag ?? "")
      if requested != "" {
        if !is_v_semver(requested) {
          throw "std/bump/live: --tag must look like vX.Y.Z, got '${requested}'"
        }
        return requested
      }
      const latest = __rest("GET", "/repos/${cfg.repo}/releases/latest")
      const resolved = trim(to_string(latest?.tag_name ?? ""))
      if !is_v_semver(resolved) {
        throw "std/bump/live: could not resolve a semver latest release for ${cfg.repo}"
      }
      return resolved
    },
    release_ready: fn(target) {
      let attempts = 0
      let lines = []
      const found = poll_until(
        { ->
          attempts = attempts + 1
          const view = __release_view(cfg.repo, target)
          const ready = view.ok && __release_finalized(view)
          lines = lines + ["attempt ${attempts}: ${target} finalized=${ready}"]
          return if ready {
            view
          } else {
            nil
          }
        },
        {
          max_attempts: cfg.readiness_max_attempts,
          interval_ms: cfg.readiness_interval_ms,
          timeout_ms: cfg.readiness_timeout_ms,
        },
      )
      const ready = found != nil
      return {
        ready: ready,
        detail: if ready {
          "${target} finalized with required assets present"
        } else {
          "${target} is not fully published yet"
        },
        attempts: attempts,
        attempt_lines: lines,
      }
    },
    read_current_version: fn() {
      const result = __shell("cat ${cfg.version_file} 2>/dev/null || true")
      return trim(result.stdout ?? "")
    },
    base_sha: fn() { return trim(__git(["git", "rev-parse", "HEAD"]).stdout ?? "") },
    apply_version: fn(target) {
      __shell("printf '%s\\n' '${target}' > ${cfg.version_file}")
      const refresh = __shell(cfg.refresh_command)
      const changed = !(__git(["git", "diff", "--quiet"]).success ?? false)
      const names = trim(__git(["git", "diff", "--name-only"]).stdout ?? "")
      const refreshed = if names == "" {
        []
      } else {
        split(names, "\n").filter({ n -> trim(n) != "" }).to_list()
      }
      return {
        changed: changed,
        refreshed: refreshed,
        detail: if refresh.success ?? false {
          "refresh ok"
        } else {
          trim(refresh.stderr ?? "")
        },
      }
    },
    run_validation: fn() {
      if trim(cfg.validation_command) == "" {
        return {ok: true, steps: [], detail: "no validation command declared"}
      }
      const result = __shell(cfg.validation_command)
      const ok = result.success ?? false
      return {
        ok: ok,
        steps: [{name: "validation", ok: ok, detail: cfg.validation_command}],
        detail: if ok {
          "validation passed"
        } else {
          trim((result.stderr ?? "") + (result.stdout ?? ""))
        },
      }
    },
    working_changes: fn() {
      const adds = trim(
        __git(["git", "diff", "--name-only", "--diff-filter=ACMRTUXB", "HEAD"]).stdout ?? "",
      )
      const dels = trim(
        __git(["git", "diff", "--name-only", "--diff-filter=D", "HEAD"]).stdout ?? "",
      )
      return {
        additions: if adds == "" {
          []
        } else {
          split(adds, "\n").filter({ n -> trim(n) != "" }).to_list()
        },
        deletions: if dels == "" {
          []
        } else {
          split(dels, "\n").filter({ n -> trim(n) != "" }).to_list()
        },
      }
    },
    find_bump_pr: fn() {
      const first = __find_open_pr(cfg, owner)
      if first == nil {
        return nil
      }
      return {
        number: first.number,
        head_oid: first.head?.sha ?? "",
        auto_merge_enabled: first.auto_merge != nil,
        url: first.html_url ?? "",
        state: first.state ?? "open",
      }
    },
    disable_auto_merge: fn(number) {
      const detail = __rest("GET", "/repos/${cfg.repo}/pulls/${number}")
      const node_id = detail?.node_id ?? ""
      if node_id == "" {
        return false
      }
      const result = __graphql(
        "mutation($id: ID!) { disablePullRequestAutoMerge(input: {pullRequestId: $id}) { clientMutationId } }",
        {id: node_id},
      )
      return result != nil && len(result?.errors ?? []) == 0
    },
    reset_branch: fn(base_sha) {
      const ref = "/repos/${cfg.repo}/git/refs/heads/${cfg.branch}"
      const existing = __rest("GET", ref)
      if existing != nil {
        return __rest("PATCH", ref, {sha: base_sha, force: true}) != nil
      }
      return __rest(
        "POST",
        "/repos/${cfg.repo}/git/refs",
        {ref: "refs/heads/${cfg.branch}", sha: base_sha},
      )
        != nil
    },
    create_signed_commit: fn(request) {
      let additions = []
      for path in request.additions {
        const encoded = __shell("base64 -w0 < '${path}' || base64 < '${path}' | tr -d '\\n'")
        additions = additions + [{path: path, contents: trim(encoded.stdout ?? "")}]
      }
      const deletions = request.deletions.map({ p -> {path: p} }).to_list()
      const result = __graphql(
        "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }",
        {
          input: {
            branch: {repositoryNameWithOwner: cfg.repo, branchName: request.branch},
            message: {headline: request.headline},
            fileChanges: {additions: additions, deletions: deletions},
            expectedHeadOid: request.base_sha,
          },
        },
      )
      const commit = result?.data?.createCommitOnBranch?.commit
      if commit == nil {
        throw "std/bump/live: createCommitOnBranch returned no commit; errors="
          + json_stringify(
          result?.errors ?? [],
        )
      }
      return {oid: commit.oid, url: commit.url ?? ""}
    },
    branch_matches_base: fn() {
      const branch = __rest("GET", "/repos/${cfg.repo}/commits/${cfg.branch}")
      const base = __rest("GET", "/repos/${cfg.repo}/commits/${cfg.base}")
      const branch_tree = branch?.commit?.tree?.sha ?? "branch"
      const base_tree = base?.commit?.tree?.sha ?? "base"
      return branch_tree == base_tree
    },
    upsert_pr: fn(existing) {
      const title = "Bump Harn runtime"
      const body =
        "Automated Harn runtime bump. Regenerated the lockfile and ran the declared validation entrypoint. Merges through the normal queue.\n\nProduced by the reusable `bump-harn` workflow."
      if existing != nil {
        __rest("PATCH", "/repos/${cfg.repo}/pulls/${existing.number}", {title: title, body: body})
        const detail = __rest("GET", "/repos/${cfg.repo}/pulls/${existing.number}")
        return {
          number: existing.number,
          head_oid: detail?.head?.sha ?? existing.head_oid,
          url: detail?.html_url ?? existing.url,
          created: false,
        }
      }
      const created = __rest(
        "POST",
        "/repos/${cfg.repo}/pulls",
        {title: title, head: cfg.branch, base: cfg.base, body: body},
      )
      return {
        number: created?.number ?? 0,
        head_oid: created?.head?.sha ?? "",
        url: created?.html_url ?? "",
        created: true,
      }
    },
    close_pr: fn(number, comment) {
      const result = __close_pr(cfg.repo, number, comment)
      return result.ok
    },
    enable_auto_merge: fn(number, _head) {
      const result = __enable_auto_merge(cfg.repo, number)
      const ok = result.ok
      return {
        enabled: ok,
        state: result.state,
        detail: if ok {
          ""
        } else {
          json_stringify(result.error ?? "auto-merge not enabled")
        },
      }
    },
  }
}