diff --git a/.changeset/resilient-code-host-reads.md b/.changeset/resilient-code-host-reads.md new file mode 100644 index 0000000..55e70d9 --- /dev/null +++ b/.changeset/resilient-code-host-reads.md @@ -0,0 +1,7 @@ +--- +"@kitlangton/stack": patch +--- + +Retry recognized transient GitHub and GitLab read failures up to twice with jittered exponential backoff, without retrying mutations. Do not retain failed GitLab source-project lookups in the cache. Reuse known GitLab titles, avoid rereading already-titled history, and preserve historical stack entries if optional title enrichment fails. + +Drain subprocess stdout and stderr concurrently to prevent hangs when a child fills its stderr pipe before closing stdout. diff --git a/README.md b/README.md index a19e865..b3a1ce1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,11 @@ it refuses to overwrite a remote tip changed since sync. GitHub stack blocks use compact `#101` references. GitLab blocks use `!101` references plus titles because bare GitLab MR links only show titles on hover. +Recognized transient code-host read failures are retried up to twice, with +jittered backoff around one and two seconds. Writes are not automatically retried: +a timed-out create, merge, or update may already have succeeded. GitLab reuses +known titles and preserves historical stack entries if optional title lookup fails. + If a repair fails, run: ```bash diff --git a/src/platform/proc.ts b/src/platform/proc.ts index 9c7018e..0f0ffd1 100644 --- a/src/platform/proc.ts +++ b/src/platform/proc.ts @@ -47,13 +47,17 @@ export const live = Layer.effect( .spawn(cmd) .pipe(Effect.mapError((err) => new ExecError(tool, Array.from(args), 1, String(err)))); - const [stdout, stderr, exit] = yield* Effect.all([ - text(handle.stdout), - text(handle.stderr), - handle.exitCode.pipe( - Effect.mapError((err) => new ExecError(tool, Array.from(args), 1, String(err))), - ), - ]); + // Drain both pipes while waiting for exit so a full pipe cannot block the child. + const [stdout, stderr, exit] = yield* Effect.all( + [ + text(handle.stdout), + text(handle.stderr), + handle.exitCode.pipe( + Effect.mapError((err) => new ExecError(tool, Array.from(args), 1, String(err))), + ), + ], + { concurrency: "unbounded" }, + ); const code = Number(exit); if (!ok.includes(code)) { diff --git a/src/services/CodeHost.ts b/src/services/CodeHost.ts index d8cad62..6c949d9 100644 --- a/src/services/CodeHost.ts +++ b/src/services/CodeHost.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import type { CodeHostError, PullMeta, PullRef } from "../domain/model.ts"; +import * as Schedule from "effect/Schedule"; +import type { CodeHostError, ExecError, PullMeta, PullRef } from "../domain/model.ts"; export type Provider = "github" | "gitlab"; @@ -43,6 +44,19 @@ export type AdapterProperties = Pick< export class Service extends Context.Service()("@stack/CodeHost") {} +// Only apply at read call sites: a timed-out write may already have succeeded. +export const retryRead = (read: Effect.Effect) => + read.pipe( + Effect.retry({ + schedule: Schedule.exponential("1 second").pipe(Schedule.jittered), + times: 2, + while: (error) => + /\b(?:i\/o timeout|TLS handshake timeout|context deadline exceeded|Client\.Timeout exceeded|connection reset by peer|unexpected EOF)\b|\bHTTP[ :]+(?:502|503|504)\b/i.test( + error.stderr, + ), + }), + ); + export interface RemoteInfo { readonly host: string; readonly owner: string; diff --git a/src/services/Stack.ts b/src/services/Stack.ts index 762eadd..3842336 100644 --- a/src/services/Stack.ts +++ b/src/services/Stack.ts @@ -1467,24 +1467,30 @@ ${note}`; ...new Set( info .filter((item): item is PullMeta => item !== null) - .flatMap((item) => StackBlock.references(item.body)), + .flatMap((item) => StackBlock.untitledReferences(item.body)), ), - ]; + ].filter((number) => !metasByNumber.has(number)); const completed = yield* Effect.all( numbers.map((number) => - codeHost - .change(number) - .pipe( - Effect.catchTag("CodeHostChangeNotFoundError", () => Effect.succeed(null)), + codeHost.change(number).pipe( + Effect.catchTag("CodeHostChangeNotFoundError", () => Effect.succeed(null)), + Effect.catch(() => + Effect.logWarning( + `Could not read the title for ${reference(number)}; keeping the existing stack entry.`, + ).pipe(Effect.as(null)), ), + ), ), { concurrency: cfg.codeHostConcurrency }, ); - return new Map( - completed + return new Map([ + ...[...metasByNumber.values()].map( + (item) => [Number(item.number), item.title] as const, + ), + ...completed .filter((item): item is PullMeta => item !== null) - .map((item) => [Number(item.number), item.title]), - ); + .map((item) => [Number(item.number), item.title] as const), + ]); }); const graph = StackGraph.make({ state, diff --git a/src/services/code-host/GitHub.ts b/src/services/code-host/GitHub.ts index 97a3834..6630159 100644 --- a/src/services/code-host/GitHub.ts +++ b/src/services/code-host/GitHub.ts @@ -126,7 +126,7 @@ export const layer = Layer.effect( "--paginate", "--slurp", ]; - const out = yield* run(args); + const out = yield* run(args).pipe(CodeHost.retryRead); const rows = yield* decodePullList(args, out); return rows.flatMap((page) => page.map(listRef)); }); @@ -134,6 +134,7 @@ export const layer = Layer.effect( const change = Effect.fn("CodeHost.github.change")((pr: number) => { const args = ["api", `repos/{owner}/{repo}/pulls/${pr}`]; return run(args).pipe( + CodeHost.retryRead, Effect.catchIf(missingPull, () => Effect.fail(new CodeHostChangeNotFoundError(pr))), Effect.flatMap((out) => decodePullDetails(args, out)), Effect.map(meta), @@ -155,7 +156,7 @@ export const layer = Layer.effect( Effect.gen(function* () { for (;;) { const args = ["pr", "view", `${pr}`, "--json", "state,mergedAt"]; - const out = yield* run(args); + const out = yield* run(args).pipe(CodeHost.retryRead); const row = yield* decodePullWatch(args, out); if (row.mergedAt) return; diff --git a/src/services/code-host/GitLab.ts b/src/services/code-host/GitLab.ts index a32496b..8312937 100644 --- a/src/services/code-host/GitLab.ts +++ b/src/services/code-host/GitLab.ts @@ -1,5 +1,7 @@ import * as Cache from "effect/Cache"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { @@ -135,15 +137,18 @@ export const layer = Layer.effect( return yield* proc.exec(cfg.root, "glab", args, ok); }); - const repositories = yield* Cache.make({ - capacity: 256, - lookup: Effect.fn("CodeHost.gitlab.sourceRepository.lookup")(function* (id: number) { + const repositories = yield* Cache.makeWith( + Effect.fn("CodeHost.gitlab.sourceRepository.lookup")(function* (id: number) { const args = ["api", `projects/${id}`]; - const out = yield* run(args); + const out = yield* run(args).pipe(CodeHost.retryRead); const project = yield* decodeProjectData(args, out); return project.path_with_namespace; }), - }); + { + capacity: 256, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }, + ); const sourceRepository = Effect.fn("CodeHost.gitlab.sourceRepository")(function* ( id: number | null, @@ -159,7 +164,7 @@ export const layer = Layer.effect( "--output", "ndjson", ]; - const out = yield* run(args); + const out = yield* run(args).pipe(CodeHost.retryRead); const rows = yield* decodeMRList(args, out); return yield* Effect.forEach( rows, @@ -171,6 +176,7 @@ export const layer = Layer.effect( const change = Effect.fn("CodeHost.gitlab.change")((pr: number) => { const args = ["mr", "view", `${pr}`, "-F", "json"]; return run(args).pipe( + CodeHost.retryRead, Effect.catchIf(missingPull, () => Effect.fail(new CodeHostChangeNotFoundError(pr))), Effect.flatMap((out) => decodeMRView(args, out)), Effect.flatMap((row) => @@ -206,7 +212,7 @@ export const layer = Layer.effect( Effect.gen(function* () { for (;;) { const args = ["mr", "view", `${pr}`, "-F", "json"]; - const out = yield* run(args); + const out = yield* run(args).pipe(CodeHost.retryRead); const row = yield* decodeMRWatch(args, out); if (row.merged_at || row.state === "merged") return; diff --git a/src/stackBlock.ts b/src/stackBlock.ts index 21a14a6..00cbc5b 100644 --- a/src/stackBlock.ts +++ b/src/stackBlock.ts @@ -67,12 +67,14 @@ const completedLines = ( }); }; -export const references = (body: string) => { +export const untitledReferences = (body: string) => { const prior = body.match(new RegExp(`${start}([\\s\\S]*?)${end}`))?.[1]; if (!prior) return []; - return [...new Set([...prior.matchAll(/[#!](\d+)/g)].map((match) => Number(match[1])))] - .filter((number) => Number.isInteger(number)) - .sort((a, b) => a - b); + const numbers = prior.split("\n").flatMap((line) => { + const entry = line.match(/^\s*(?:\d+\.|- \[[ x]\])\s+(?:\*\*)?[#!](\d+)(.*)$/); + return entry && !/\s+-\s+\S/.test(entry[2] ?? "") ? [Number(entry[1])] : []; + }); + return [...new Set(numbers)].filter((number) => Number.isInteger(number)).sort((a, b) => a - b); }; export const render = (opts: { diff --git a/tests/codeHostReads.test.ts b/tests/codeHostReads.test.ts new file mode 100644 index 0000000..138ccf4 --- /dev/null +++ b/tests/codeHostReads.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Fiber, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { CodeHostDecodeError, ExecError } from "../src/domain/model.ts"; +import * as Proc from "../src/platform/proc.ts"; +import { CodeHost } from "../src/services/CodeHost.ts"; +import { StackConfig } from "../src/services/Config.ts"; +import { CodeHostGitHub } from "../src/services/code-host/GitHub.ts"; +import { CodeHostGitLab } from "../src/services/code-host/GitLab.ts"; + +const github = { + number: 1, + title: "topic", + body: "", + head: { ref: "topic", repo: null }, + base: { ref: "main" }, + html_url: "https://example.com/1", + draft: false, + labels: [], +}; +const gitlab = { + iid: 1, + title: "topic", + description: "", + source_branch: "topic", + target_branch: "main", + web_url: "https://example.com/1", + draft: false, + state: "opened", + labels: [], + source_project_id: null, +}; +const cfg = StackConfig.layer({ root: "/repo" }).pipe(Layer.provide(NodeServices.layer)); + +for (const [provider, adapter] of [ + ["github", CodeHostGitHub.layer], + ["gitlab", CodeHostGitLab.layer], +] as const) { + describe(`${provider} read recovery`, () => { + for (const operation of ["changes", "change", "wait"] as const) { + it.effect(`retries a timed-out ${operation} read after backoff`, () => { + let calls = 0; + const proc = Layer.succeed(Proc.Service, { + exec: (_cwd, tool, args) => + Effect.gen(function* () { + calls += 1; + if (calls === 1) + return yield* Effect.fail( + new ExecError(tool, args, 1, "net/http: TLS handshake timeout"), + ); + return JSON.stringify( + operation === "wait" + ? provider === "github" + ? { state: "MERGED", mergedAt: "now" } + : { state: "merged", merged_at: "now" } + : operation === "changes" + ? provider === "github" + ? [[github]] + : gitlab + : provider === "github" + ? github + : gitlab, + ); + }), + }); + return Effect.gen(function* () { + const host = yield* CodeHost.Service; + const request = + operation === "changes" + ? host.changes() + : operation === "change" + ? host.change(1) + : host.wait(1); + const fiber = yield* request.pipe(Effect.forkChild({ startImmediately: true })); + expect(calls).toBe(1); + yield* TestClock.adjust("500 millis"); + expect(calls).toBe(1); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(fiber); + expect(calls).toBe(2); + }).pipe(Effect.provide(adapter.pipe(Layer.provide(cfg), Layer.provide(proc)))); + }); + } + + it.effect("stops after three failed read attempts and preserves the last error", () => { + let calls = 0; + const error = new ExecError(provider, ["api"], 1, "i/o timeout"); + const proc = Layer.succeed(Proc.Service, { + exec: () => + Effect.gen(function* () { + calls += 1; + return yield* Effect.fail(error); + }), + }); + return Effect.gen(function* () { + const host = yield* CodeHost.Service; + const fiber = yield* Effect.flip(host.changes()).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(fiber)).toBe(error); + expect(calls).toBe(3); + }).pipe(Effect.provide(adapter.pipe(Layer.provide(cfg), Layer.provide(proc)))); + }); + + it.effect("does not retry authentication errors or invalid JSON", () => { + let calls = 0; + let invalidJson = false; + const error = new ExecError(provider, ["api"], 1, "HTTP 403: forbidden"); + const proc = Layer.succeed(Proc.Service, { + exec: () => + Effect.gen(function* () { + calls += 1; + return invalidJson ? "invalid JSON" : yield* Effect.fail(error); + }), + }); + return Effect.gen(function* () { + const host = yield* CodeHost.Service; + expect(yield* Effect.flip(host.changes())).toBe(error); + expect(calls).toBe(1); + invalidJson = true; + expect(yield* Effect.flip(host.changes())).toBeInstanceOf(CodeHostDecodeError); + expect(calls).toBe(2); + }).pipe(Effect.provide(adapter.pipe(Layer.provide(cfg), Layer.provide(proc)))); + }); + + it.effect("never retries mutations whose timeout could follow a successful write", () => { + let calls = 0; + const error = new ExecError(provider, ["write"], 1, "i/o timeout"); + const proc = Layer.succeed(Proc.Service, { + exec: () => + Effect.gen(function* () { + calls += 1; + return yield* Effect.fail(error); + }), + }); + return Effect.gen(function* () { + const host = yield* CodeHost.Service; + const writes = [ + host.auto(1), + host.merge(1), + host.edit(1, "main"), + host.body(1, "body"), + host.close(1), + host.create("topic", "main", "title", "body", []), + ]; + for (const write of writes) expect(yield* Effect.flip(write)).toBe(error); + expect(calls).toBe(writes.length); + }).pipe(Effect.provide(adapter.pipe(Layer.provide(cfg), Layer.provide(proc)))); + }); + + it.effect("cancelling a delayed read prevents further attempts", () => { + let calls = 0; + const proc = Layer.succeed(Proc.Service, { + exec: () => + Effect.gen(function* () { + calls += 1; + return yield* Effect.fail(new ExecError(provider, ["api"], 1, "i/o timeout")); + }), + }); + return Effect.gen(function* () { + const host = yield* CodeHost.Service; + const fiber = yield* host.changes().pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.interrupt(fiber); + yield* TestClock.adjust("10 seconds"); + expect(calls).toBe(1); + }).pipe(Effect.provide(adapter.pipe(Layer.provide(cfg), Layer.provide(proc)))); + }); + }); +} + +it.effect("does not retain failed GitLab source-project lookups in the cache", () => { + let calls = 0; + const error = new ExecError("glab", ["api", "projects/7"], 1, "HTTP 403: forbidden"); + const proc = Layer.succeed(Proc.Service, { + exec: (_cwd, _tool, args) => + Effect.gen(function* () { + if (args[1] !== "projects/7") return JSON.stringify({ ...gitlab, source_project_id: 7 }); + calls += 1; + if (calls === 1) return yield* Effect.fail(error); + return JSON.stringify({ path_with_namespace: "owner/project" }); + }), + }); + return Effect.gen(function* () { + const host = yield* CodeHost.Service; + expect(yield* Effect.flip(host.changes())).toBe(error); + expect((yield* host.changes())[0]?.headRepository).toBe("owner/project"); + yield* host.changes(); + expect(calls).toBe(2); + }).pipe(Effect.provide(CodeHostGitLab.layer.pipe(Layer.provide(cfg), Layer.provide(proc)))); +}); + +it.effect.each([ + "HTTP 502: Bad Gateway", + "HTTP 503: Service Unavailable", + "HTTP 504: Gateway Timeout", + "context deadline exceeded", + "Client.Timeout exceeded while awaiting headers", + "read: connection reset by peer", + "unexpected EOF", +])("retries a recognized transient read failure: %s", (stderr) => + Effect.gen(function* () { + let calls = 0; + const read = Effect.gen(function* () { + calls += 1; + if (calls === 1) return yield* Effect.fail(new ExecError("gh", ["api"], 1, stderr)); + return "ok"; + }).pipe(CodeHost.retryRead); + const fiber = yield* read.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(fiber)).toBe("ok"); + expect(calls).toBe(2); + }), +); + +it.effect("GitLab project retries stay within the existing concurrency budget", () => { + let active = 0; + let peak = 0; + const calls = new Map(); + const rows = Array.from({ length: 8 }, (_, index) => ({ + ...gitlab, + iid: index + 1, + source_project_id: index + 1, + })); + const proc = Layer.succeed(Proc.Service, { + exec: (_cwd, tool, args) => + Effect.gen(function* () { + const endpoint = args[1] ?? ""; + if (endpoint.includes("merge_requests")) + return rows.map((row) => JSON.stringify(row)).join("\n"); + const attempts = (calls.get(endpoint) ?? 0) + 1; + calls.set(endpoint, attempts); + active += 1; + peak = Math.max(peak, active); + yield* Effect.yieldNow; + active -= 1; + if (attempts === 1) return yield* Effect.fail(new ExecError(tool, args, 1, "i/o timeout")); + return JSON.stringify({ path_with_namespace: "owner/project" }); + }), + }); + return Effect.gen(function* () { + const host = yield* CodeHost.Service; + const fiber = yield* host.changes().pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(fiber)).toHaveLength(8); + expect(peak).toBe(2); + expect([...calls.values()]).toEqual(Array(8).fill(2)); + }).pipe( + Effect.provide( + CodeHostGitLab.layer.pipe( + Layer.provide( + StackConfig.layer({ root: "/repo", codeHostConcurrency: 2 }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provide(proc), + ), + ), + ); +}); diff --git a/tests/proc.test.ts b/tests/proc.test.ts new file mode 100644 index 0000000..91d6ee5 --- /dev/null +++ b/tests/proc.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Latch, Layer, Sink, Stream } from "effect"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { ExecError } from "../src/domain/model.ts"; +import * as Proc from "../src/platform/proc.ts"; + +it.effect.each([ + { code: 0, ok: undefined }, + { code: 7, ok: undefined }, + { code: 7, ok: [0, 7] }, +])( + "exec drains interdependent pipes (exit $code, accepted $ok)", + ({ code, ok }) => + Effect.gen(function* () { + const stderrDrained = yield* Latch.make(); + const stdoutDrained = yield* Latch.make(); + const encode = (text: string) => new TextEncoder().encode(text); + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + // Model a child blocked on stderr before it can finish stdout and exit. + stdout: Stream.fromEffect(stderrDrained.await.pipe(Effect.as(encode(" ready\n")))).pipe( + Stream.ensuring(stdoutDrained.open), + ), + stderr: Stream.make(encode(" first\n"), encode("second \n")).pipe( + Stream.ensuring(stderrDrained.open), + ), + exitCode: stdoutDrained.await.pipe(Effect.as(ChildProcessSpawner.ExitCode(code))), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + const layer = Proc.live.pipe( + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(handle)), + ), + ), + ); + const proc = yield* Proc.Service.asEffect().pipe(Effect.provide(layer)); + const exec = proc.exec("/repo", "child", ["arg"], ok); + if ((ok ?? [0]).includes(code)) { + expect(yield* exec).toBe("ready"); + } else { + const error = yield* Effect.flip(exec); + expect(error).toBeInstanceOf(ExecError); + expect(error).toMatchObject({ + tool: "child", + args: ["arg"], + code, + stderr: "first\nsecond", + }); + } + }), + 1000, +); + +it.effect( + "exec drains a real child's stderr beyond pipe capacity before stdout", + () => + Effect.gen(function* () { + const proc = yield* Proc.Service; + for (const code of [0, 7]) { + const args = [ + "-e", + `process.stderr.write(" " + "x".repeat(2 * 1024 * 1024) + "\\n", () => { + process.stdout.write(" ready\\n"); + process.exitCode = ${code}; + });`, + ]; + const exec = proc.exec(process.cwd(), process.execPath, args); + if (code === 0) { + expect(yield* exec).toBe("ready"); + } else { + const error = yield* Effect.flip(exec); + expect(error).toBeInstanceOf(ExecError); + expect(error).toMatchObject({ tool: process.execPath, args, code }); + expect(error.stderr).toBe("x".repeat(2 * 1024 * 1024)); + } + } + }).pipe(Effect.provide(Proc.live.pipe(Layer.provide(NodeServices.layer)))), + 5000, +); diff --git a/tests/stack.test.ts b/tests/stack.test.ts index 209d09f..d3eaa63 100644 --- a/tests/stack.test.ts +++ b/tests/stack.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Fiber, Layer, Option, Ref } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, Option, Ref } from "effect"; import { TestClock } from "effect/testing"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -3384,6 +3384,105 @@ describe("Stack", () => { }).pipe(Effect.provide(test.layer)); }); + it.effect("GitLab links reuse known titles without rereading live or titled history", () => { + const pull = pr(1, "topic", "dev"); + const body = + "\n### Stack\n\n1. !2 - Fix #99\n2. **!1**\n"; + const calls: Array = []; + const layer = stackTestLayer({ + refs: [ref("dev"), ref("topic")], + pulls: [pull], + state: stackState([stackLink({ branch: "topic", parent: "dev", anchor: "dev", pr: 1 })]), + service: { + provider: "gitlab", + reference: (number) => `!${number}`, + change: (number) => + Effect.gen(function* () { + calls.push(number); + if (calls.length > 1) + return yield* Effect.fail(new ExecError("glab", ["mr", "view"], 1, "i/o timeout")); + return metaFor(pull, body); + }), + }, + }); + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.links(false); + expect(calls).toEqual([1]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("GitLab links preserve history when optional title enrichment times out", () => { + const pull = pr(1, "topic", "dev"); + const body = + "\n### Stack\n\n1. !2\n2. **!1 - topic**\n"; + const bodies: Array = []; + const layer = stackTestLayer({ + refs: [ref("dev"), ref("topic")], + pulls: [pull], + state: stackState([stackLink({ branch: "topic", parent: "dev", anchor: "dev", pr: 1 })]), + service: { + provider: "gitlab", + reference: (number) => `!${number}`, + change: (number) => + number === 1 + ? Effect.succeed(metaFor(pull, body)) + : Effect.fail(new ExecError("glab", ["mr", "view"], 1, "i/o timeout")), + body: (_number, value) => Effect.sync(() => void bodies.push(value)), + }, + }); + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.links(true); + expect(bodies[0]).toContain("1. !2\n"); + expect(bodies[0]).toContain("!1 - topic"); + }).pipe(Effect.provide(layer)); + }); + + for (const optional of [false, true]) { + it.effect( + optional + ? "optional GitLab title interruption prevents body updates" + : "mandatory GitLab metadata failure prevents body updates", + () => { + const pull = pr(1, "topic", "dev"); + const body = + "\n### Stack\n\n1. !2\n2. !1\n"; + const error = new ExecError("glab", ["mr", "view"], 1, "i/o timeout"); + let writes = 0; + const layer = stackTestLayer({ + refs: [ref("dev"), ref("topic")], + pulls: [pull], + state: stackState([stackLink({ branch: "topic", parent: "dev", anchor: "dev", pr: 1 })]), + service: { + provider: "gitlab", + reference: (number) => `!${number}`, + change: (number) => + optional + ? number === 1 + ? Effect.succeed(metaFor(pull, body)) + : Effect.interrupt + : Effect.fail(error), + body: () => + Effect.sync(() => { + writes += 1; + }), + }, + }); + return Effect.gen(function* () { + const stack = yield* Stack; + if (optional) { + const exit = yield* Effect.exit(stack.links(true)); + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); + } else { + expect(yield* Effect.flip(stack.links(true))).toBe(error); + } + expect(writes).toBe(0); + }).pipe(Effect.provide(layer)); + }, + ); + } + it.effect("links render the current path through a forked stack", () => { const bodies = new Map(); const pulls = [