From b42a979ebf8e01b2b08f0108147510a9c56eb898 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 27 Aug 2026 22:42:35 -0400 Subject: [PATCH] fix(code-host): recover transient reads without retrying writes Add bounded jittered backoff at explicit read boundaries, avoid caching failed GitLab project lookups, and reuse known titles while treating optional history enrichment as best effort. Drain child process output concurrently to prevent stderr pipe deadlocks. Cover retries, cancellation, mutation safety, enrichment boundaries, and real pipe backpressure. --- .changeset/resilient-code-host-reads.md | 7 + README.md | 5 + src/platform/proc.ts | 18 +- src/services/CodeHost.ts | 16 +- src/services/Stack.ts | 26 ++- src/services/code-host/GitHub.ts | 5 +- src/services/code-host/GitLab.ts | 20 +- src/stackBlock.ts | 10 +- tests/codeHostReads.test.ts | 261 ++++++++++++++++++++++++ tests/proc.test.ts | 88 ++++++++ tests/stack.test.ts | 101 ++++++++- 11 files changed, 525 insertions(+), 32 deletions(-) create mode 100644 .changeset/resilient-code-host-reads.md create mode 100644 tests/codeHostReads.test.ts create mode 100644 tests/proc.test.ts 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 = [