Public: anyone can read this; the crew can change it.
take-updates: a forge in take-updates merges its parent's new code when it applies cleanly, and opens the converge issue when it does not #2640
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2634-take-updates-a-forge-in-take-updates-mer → main 3 commits fork 1 thread · 1 unresolved
6 files
changed +288
−4
README.md +5 −2
| @@ -381,14 +381,17 @@ taking a release is not done here. This forge publishes its own | ||
| 381 | 381 | forge can be a parent. The terminal shows the count, not the release names. |
| 382 | 382 | |
| 383 | 383 | A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen |
| 384 | `followMode` `tell-me` or `take-updates`; recorded: the mode does not yet apply anything). Once an hour, and | |
| 384 | `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check merges what the parent gained). Once an hour, and | |
| 385 | 385 | on demand, a forge that follows and has not severed fetches its parent's code into |
| 386 | 386 | `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps |
| 387 | 387 | `{ behind, parentTip, checkedAt, error }` in `upstream.json` under `FORGE_APP_DATA`; |
| 388 | 388 | the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as |
| 389 | 389 | `upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer, |
| 390 | 390 | same-origin) checks on demand; a fetch is cut at 60 s and a failure is recorded, not |
| 391 | thrown. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain | |
| 391 | thrown. In `take-updates` the check then fast-forwards or cleanly merges `refs/upstream/HEAD` | |
| 392 | into the fork's main (`spawn/take.ts`), records it as `taken` in `upstream.json` (shown on the chain line) and as a | |
| 393 | `forge.updates_taken` event; on a conflict main is untouched and one open issue, | |
| 394 | "Take updates from <parent>", names the files. It never deploys. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain | |
| 392 | 395 | to the root is at `/:owner/forge/following`, in `chain` of `GET |
| 393 | 396 | /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the |
| 394 | 397 | terminal's **Chain** command; the owner severs it with the page's Sever, |
src/spawn/take.ts added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | /** | |
| 2 | * Taking the parent's code, for a forge that follows in `take-updates` | |
| 3 | * mode. Once the hourly fetch has put the parent's tip at | |
| 4 | * `refs/upstream/HEAD`, this moves the forge's own default branch: a | |
| 5 | * fast-forward, or a merge commit when git merges the two without conflict. | |
| 6 | * A conflict changes nothing; it opens (or finds) one issue naming the files | |
| 7 | * for the people here to settle. It never deploys: production follows main | |
| 8 | * as it always did. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { commitIdentity, forgeHost, noreplyAddress } from "../core/commit_identity.ts"; | |
| 13 | import { createIssue, listIssues } from "../core/issues.ts"; | |
| 14 | import { getRepo } from "../core/repos.ts"; | |
| 15 | import { getUserById } from "../core/users.ts"; | |
| 16 | import { runGit } from "../git/env.ts"; | |
| 17 | import { mergeable, mergeBase, merge, resolveOid } from "../git/merge.ts"; | |
| 18 | import { repoDir } from "../git/repos.ts"; | |
| 19 | import type { ForgeLink } from "./chain.ts"; | |
| 20 | ||
| 21 | /** What the last check did with the parent's new commits; absent when it did nothing. */ | |
| 22 | export type Taken = | |
| 23 | | { readonly kind: "took"; readonly commits: number; readonly merged: boolean; readonly to: string } | |
| 24 | | { readonly kind: "conflict"; readonly files: readonly string[]; readonly issue: number | null }; | |
| 25 | ||
| 26 | export const takeTitle = (upstream: string): string => `Take updates from ${upstream}`; | |
| 27 | ||
| 28 | /** Take what `refs/upstream/HEAD` holds into the forge's default branch, or tell. Never throws. */ | |
| 29 | export async function takeFromParent(app: App, link: ForgeLink): Promise<Taken | undefined> { | |
| 30 | const [o, n] = link.repo.split("/") as [string, string]; | |
| 31 | const repo = getRepo(app.db, o, n); | |
| 32 | if (repo === undefined) return undefined; | |
| 33 | const dir = repoDir(app.reposRoot, o, n); | |
| 34 | const branch = repo.defaultBranch; | |
| 35 | const head = await resolveOid(dir, `refs/heads/${branch}`); | |
| 36 | const up = await resolveOid(dir, "refs/upstream/HEAD"); | |
| 37 | if (head === undefined || up === undefined || head === up) return undefined; | |
| 38 | const base = await mergeBase(dir, head, up); | |
| 39 | if (base === undefined || base === up) return undefined; // the parent has nothing we lack | |
| 40 | const count = await runGit(["rev-list", "--count", `${head}..${up}`], { cwd: dir }); | |
| 41 | const commits = count.ok ? Number(count.value.trim()) || 0 : 0; | |
| 42 | ||
| 43 | let to = up; | |
| 44 | let merged = false; | |
| 45 | if (base === head) { | |
| 46 | const moved = await runGit(["update-ref", `refs/heads/${branch}`, up, head], { cwd: dir }); | |
| 47 | if (!moved.ok) return undefined; // pushed to meanwhile: the next check looks again | |
| 48 | } else { | |
| 49 | const tree = await mergeable(dir, head, up); | |
| 50 | if (!tree.ok) { | |
| 51 | if (tree.error.kind !== "conflict") return undefined; | |
| 52 | return { kind: "conflict", files: tree.error.files, issue: tell(app, repo, link, tree.error.files) }; | |
| 53 | } | |
| 54 | const owner = getUserById(app.db, repo.ownerId); | |
| 55 | const author = { | |
| 56 | name: "forge", | |
| 57 | email: noreplyAddress("forge", forgeHost(app.config)), | |
| 58 | }; | |
| 59 | const landed = await merge(dir, { | |
| 60 | baseBranch: branch, | |
| 61 | baseOid: head, | |
| 62 | headOid: up, | |
| 63 | strategy: "merge", | |
| 64 | message: takeTitle(link.upstream), | |
| 65 | author: owner === undefined ? author : commitIdentity(app.config, owner), | |
| 66 | committer: author, | |
| 67 | treeOid: tree.value.treeOid, | |
| 68 | }); | |
| 69 | if (!landed.ok) return undefined; | |
| 70 | to = landed.value.mergeOid; | |
| 71 | merged = true; | |
| 72 | } | |
| 73 | const taken: Taken = { kind: "took", commits, merged, to }; | |
| 74 | app.bus.append({ | |
| 75 | kind: "push", | |
| 76 | repoId: repo.id, | |
| 77 | payload: { | |
| 78 | updates: [{ ref: `refs/heads/${branch}`, oldOid: head, newOid: to }], | |
| 79 | followed: link.upstream, | |
| 80 | merged, | |
| 81 | }, | |
| 82 | }); | |
| 83 | app.bus.append({ | |
| 84 | kind: "forge.updates_taken", | |
| 85 | repoId: repo.id, | |
| 86 | payload: { repo: link.repo, upstream: link.upstream, from: head, to, commits, merged }, | |
| 87 | }); | |
| 88 | return taken; | |
| 89 | } | |
| 90 | ||
| 91 | /** The one open issue naming the conflicting files; the number, or null when nobody may open it. */ | |
| 92 | function tell(app: App, repo: NonNullable<ReturnType<typeof getRepo>>, link: ForgeLink, files: readonly string[]): number | null { | |
| 93 | const title = takeTitle(link.upstream); | |
| 94 | const open = listIssues(app.db, repo.id, { sealed: true, state: "open", limit: 200 }).find((i) => i.title === title); | |
| 95 | if (open !== undefined) return open.number; | |
| 96 | const owner = getUserById(app.db, repo.ownerId); | |
| 97 | if (owner === undefined || owner.kind !== "user") return null; | |
| 98 | const made = createIssue(app.db, { | |
| 99 | repoId: repo.id, | |
| 100 | authorId: owner.id, | |
| 101 | title, | |
| 102 | body: [ | |
| 103 | `This forge takes updates from ${link.upstream}, and they do not merge cleanly: ${files.length === 1 ? "one file" : `${files.length} files`} changed on both sides: ${files.join(", ")}.`, | |
| 104 | "", | |
| 105 | "Nothing on the default branch was changed. The parent's code is fetched at `refs/upstream/HEAD`; merge it into a branch, settle those files, and propose the result as a pull.", | |
| 106 | ].join("\n"), | |
| 107 | }); | |
| 108 | if (!made.ok) return null; | |
| 109 | app.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: owner.id, payload: { number: made.value.number } }); | |
| 110 | return made.value.number; | |
| 111 | } |
src/spawn/upstream.ts +33
| @@ -20,6 +20,7 @@ import { getUserById, getUserByName } from "../core/users.ts"; | ||
| 20 | 20 | import { runGit } from "../git/env.ts"; |
| 21 | 21 | import { repoDir } from "../git/repos.ts"; |
| 22 | 22 | import { forgeLinkOf, originOf } from "./chain.ts"; |
| 23 | import { takeFromParent, type Taken } from "./take.ts"; | |
| 23 | 24 | |
| 24 | 25 | export const CHECK_MS = 60 * 60_000; |
| 25 | 26 | export const FETCH_TIMEOUT_MS = 60_000; |
| @@ -34,6 +35,8 @@ export interface UpstreamState { | ||
| 34 | 35 | readonly checkedAt: number; |
| 35 | 36 | /** One line; "" when the last attempt worked. */ |
| 36 | 37 | readonly error: string; |
| 38 | /** What `take-updates` did with the parent's new commits at that check; absent when nothing. */ | |
| 39 | readonly taken?: Taken; | |
| 37 | 40 | /** The parent tip the owner was last told about; "" when never. */ |
| 38 | 41 | readonly toldTip: string; |
| 39 | 42 | } |
| @@ -51,6 +54,7 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined | ||
| 51 | 54 | parentTip: typeof d["parentTip"] === "string" && TIP.test(d["parentTip"]) ? d["parentTip"] : "", |
| 52 | 55 | checkedAt: d["checkedAt"], |
| 53 | 56 | error: typeof d["error"] === "string" ? d["error"].slice(0, 200) : "", |
| 57 | ...readTaken(d["taken"]), | |
| 54 | 58 | toldTip: typeof d["toldTip"] === "string" && TIP.test(d["toldTip"]) ? d["toldTip"] : "", |
| 55 | 59 | }; |
| 56 | 60 | } catch { |
| @@ -58,6 +62,22 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined | ||
| 58 | 62 | } |
| 59 | 63 | } |
| 60 | 64 | |
| 65 | function readTaken(v: unknown): { taken?: Taken } { | |
| 66 | if (typeof v !== "object" || v === null) return {}; | |
| 67 | const t = v as Record<string, unknown>; | |
| 68 | if (t["kind"] === "took" && typeof t["to"] === "string" && TIP.test(t["to"]) && typeof t["commits"] === "number") | |
| 69 | return { taken: { kind: "took", to: t["to"], commits: t["commits"], merged: t["merged"] === true } }; | |
| 70 | if (t["kind"] === "conflict" && Array.isArray(t["files"])) | |
| 71 | return { | |
| 72 | taken: { | |
| 73 | kind: "conflict", | |
| 74 | files: t["files"].filter((f): f is string => typeof f === "string").slice(0, 20).map((f) => f.slice(0, 200)), | |
| 75 | issue: typeof t["issue"] === "number" ? t["issue"] : null, | |
| 76 | }, | |
| 77 | }; | |
| 78 | return {}; | |
| 79 | } | |
| 80 | ||
| 61 | 81 | async function keep(app: App, s: UpstreamState): Promise<void> { |
| 62 | 82 | try { |
| 63 | 83 | await Bun.write(`${statePath(app)}.tmp`, JSON.stringify(s)); |
| @@ -129,6 +149,15 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis | ||
| 129 | 149 | toldTip: (await upstreamState(app))?.toldTip ?? "", |
| 130 | 150 | }; |
| 131 | 151 | } |
| 152 | // The parent is ahead and this forge takes updates: merge it or tell. | |
| 153 | if (state.error === "" && state.behind !== null && state.behind > 0 && link.mode === "take-updates") { | |
| 154 | const taken = await takeFromParent(app, link); | |
| 155 | if (taken !== undefined) { | |
| 156 | const left = await runGit(["rev-list", "--count", "HEAD..refs/upstream/HEAD"], { cwd: dir }); | |
| 157 | const behind = left.ok ? Number(left.value.trim()) : NaN; | |
| 158 | state = { ...state, behind: Number.isSafeInteger(behind) ? behind : state.behind, taken }; | |
| 159 | } | |
| 160 | } | |
| 132 | 161 | const repo = getRepo(app.db, o, n); |
| 133 | 162 | // Remember the tip before the card, so a crash between them can lose a card but never repeat one. |
| 134 | 163 | const tell = repo !== undefined && link.mode === "tell-me" && state.error === "" && state.parentTip !== "" && |
| @@ -183,6 +212,10 @@ export function upstreamWords(s: UpstreamState | undefined, now = Date.now()): s | ||
| 183 | 212 | const mins = Math.max(0, Math.floor((now - s.checkedAt) / 60_000)); |
| 184 | 213 | const when = mins < 1 ? "just now" : mins < 60 ? `${mins} min ago` : `${Math.floor(mins / 60)} h ago`; |
| 185 | 214 | if (s.error !== "") return `could not check your parent: ${s.error} (${when})`; |
| 215 | if (s.taken?.kind === "took") | |
| 216 | return `took ${s.taken.commits} new commit${s.taken.commits === 1 ? "" : "s"} from your parent${s.taken.merged ? " in a merge" : ""} (checked ${when})`; | |
| 217 | if (s.taken?.kind === "conflict") | |
| 218 | return `your parent's new code conflicts in ${s.taken.files.join(", ")}${s.taken.issue === null ? "" : `; see issue #${s.taken.issue}`} (checked ${when})`; | |
| 186 | 219 | if (s.behind === null) return `checked ${when}`; |
| 187 | 220 | if (s.behind === 0) return `up to date (checked ${when})`; |
| 188 | 221 | return `your parent has ${s.behind} new commit${s.behind === 1 ? "" : "s"} (checked ${when})`; |
src/web/pages/chain.ts +1 −1
| @@ -19,7 +19,7 @@ import { flashOrJson, page, redirect } from "../layout.ts"; | ||
| 19 | 19 | import { btn, card } from "../ui.ts"; |
| 20 | 20 | |
| 21 | 21 | /** The events that change what the chain page says. */ |
| 22 | export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode,forge.upstream"; | |
| 22 | export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode,forge.upstream,forge.updates_taken"; | |
| 23 | 23 | |
| 24 | 24 | const MODE_LABELS: Record<FollowMode, string> = { |
| 25 | 25 | "tell-me": "Tell me when the parent has something new", |
test/following.test.ts +1 −1
| @@ -1092,7 +1092,7 @@ describe("an open chain page hears a sever", () => { | ||
| 1092 | 1092 | test("the page carries a live region for forge.severed, scoped to its repository", async () => { |
| 1093 | 1093 | const page = await chainPage(); |
| 1094 | 1094 | expect(page).toContain('id="live-chain"'); |
| 1095 | expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode,forge.upstream"'); | |
| 1095 | expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode,forge.upstream,forge.updates_taken"'); | |
| 1096 | 1096 | expect(page).toContain('data-live-repo="kid/forge"'); |
| 1097 | 1097 | expect(page).not.toContain("follows nothing since"); |
| 1098 | 1098 | }); |
test/spawn-take.test.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * Taking the parent's code (#2634): local bare repositories, no network. | |
| 3 | * A fast-forward and a clean merge move main and say so; a conflict leaves | |
| 4 | * main alone and opens one issue; `tell-me` never touches main. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { listEvents } from "../src/core/events.ts"; | |
| 14 | import { listIssues } from "../src/core/issues.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { runGit } from "../src/git/env.ts"; | |
| 18 | import { repoDir } from "../src/git/repos.ts"; | |
| 19 | import { checkUpstream, upstreamWords } from "../src/spawn/upstream.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const git = async (cwd: string, ...args: string[]) => { | |
| 25 | const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd }); | |
| 26 | if (!r.ok) throw new Error(r.error.stderr); | |
| 27 | return r.value.trim(); | |
| 28 | }; | |
| 29 | ||
| 30 | describe("taking the parent's code", () => { | |
| 31 | let dir = ""; | |
| 32 | let app: App; | |
| 33 | let kid: User; | |
| 34 | let repo: Repo; | |
| 35 | let child = ""; | |
| 36 | let parent = ""; | |
| 37 | let work = ""; | |
| 38 | ||
| 39 | const setMode = (mode: string) => | |
| 40 | Bun.write( | |
| 41 | join(app.config.dataDir, "parent.json"), | |
| 42 | JSON.stringify({ | |
| 43 | upstream: "https://parent.test", | |
| 44 | forgeCode: { repo: "kid/forge", upstream: "root/forge", mode, tip: "a".repeat(40), chain: [] }, | |
| 45 | }), | |
| 46 | ); | |
| 47 | const mine = async (path: string, content: string) => { | |
| 48 | const r = await commitToBranch(app, kid, repo, { message: `kid: ${path}`, files: [{ path, content }] }); | |
| 49 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 50 | }; | |
| 51 | const theirs = async (path: string, content: string) => { | |
| 52 | await Bun.write(join(work, path), content); | |
| 53 | await git(work, "add", "."); | |
| 54 | await git(work, "commit", "-m", `parent: ${path}`); | |
| 55 | await git(work, "push", "-q", parent, "HEAD:refs/heads/main"); | |
| 56 | }; | |
| 57 | const main = () => git(child, "rev-parse", "refs/heads/main"); | |
| 58 | const taken = () => listEvents(app.db, { repoId: repo.id, kind: "forge.updates_taken" }); | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | dir = await mkdtemp(join(tmpdir(), "forge-take-")); | |
| 62 | app = createApp({ dataDir: join(dir, "data"), url: "https://child.test", port: 0 }); | |
| 63 | const u = await createUser(app.db, { | |
| 64 | origin: "seed", | |
| 65 | trusted: true, | |
| 66 | username: "kid", | |
| 67 | email: "[email protected]", | |
| 68 | password: "a-good-passphrase", | |
| 69 | adminIfFirst: true, | |
| 70 | }); | |
| 71 | if (!u.ok) throw new Error(u.error); | |
| 72 | kid = u.value; | |
| 73 | const made = await createRepo(app, kid, { name: "forge", visibility: "public" }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | repo = made.value; | |
| 76 | child = repoDir(app.reposRoot, "kid", "forge"); | |
| 77 | await mine("a.txt", "base\n"); | |
| 78 | parent = join(dir, "parent.git"); | |
| 79 | work = join(dir, "work"); | |
| 80 | await git(dir, "clone", "-q", "--bare", child, parent); | |
| 81 | await git(dir, "clone", "-q", parent, work); | |
| 82 | await setMode("take-updates"); | |
| 83 | }, 30_000); | |
| 84 | afterAll(async () => { | |
| 85 | app.close(); | |
| 86 | await rm(dir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("tell-me fetches and changes nothing on main", async () => { | |
| 90 | await setMode("tell-me"); | |
| 91 | const before = await main(); | |
| 92 | await theirs("p0.txt", "0\n"); | |
| 93 | const s = await checkUpstream(app, { remote: parent }); | |
| 94 | expect(s?.behind).toBe(1); | |
| 95 | expect(s?.taken).toBeUndefined(); | |
| 96 | expect(await main()).toBe(before); | |
| 97 | await setMode("take-updates"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a fast-forward is taken and recorded", async () => { | |
| 101 | const s = await checkUpstream(app, { remote: parent }); | |
| 102 | expect(s?.taken).toMatchObject({ kind: "took", commits: 1, merged: false }); | |
| 103 | expect(s?.behind).toBe(0); | |
| 104 | expect(await main()).toBe(await git(parent, "rev-parse", "refs/heads/main")); | |
| 105 | expect(taken()).toHaveLength(1); | |
| 106 | expect(upstreamWords(s, s!.checkedAt)).toContain("took 1 new commit from your parent"); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a clean merge makes a merge commit", async () => { | |
| 110 | await mine("mine.txt", "mine\n"); | |
| 111 | await theirs("p1.txt", "1\n"); | |
| 112 | const before = await main(); | |
| 113 | const s = await checkUpstream(app, { remote: parent }); | |
| 114 | expect(s?.taken).toMatchObject({ kind: "took", commits: 1, merged: true }); | |
| 115 | expect(await main()).not.toBe(before); | |
| 116 | expect((await git(child, "rev-list", "--parents", "-n1", "refs/heads/main")).split(" ")).toHaveLength(3); | |
| 117 | expect(await git(child, "show", "refs/heads/main:p1.txt")).toBe("1"); | |
| 118 | expect(await git(child, "show", "refs/heads/main:mine.txt")).toBe("mine"); | |
| 119 | expect(taken()).toHaveLength(2); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a conflict changes nothing and opens one issue", async () => { | |
| 123 | await mine("a.txt", "kid's\n"); | |
| 124 | await theirs("a.txt", "parent's\n"); | |
| 125 | const before = await main(); | |
| 126 | const s = await checkUpstream(app, { remote: parent }); | |
| 127 | expect(s?.taken).toMatchObject({ kind: "conflict", files: ["a.txt"] }); | |
| 128 | expect(await main()).toBe(before); | |
| 129 | await checkUpstream(app, { remote: parent }); | |
| 130 | expect(await main()).toBe(before); | |
| 131 | const issues = listIssues(app.db, repo.id, { sealed: true }).filter((i) => i.title === "Take updates from root/forge"); | |
| 132 | expect(issues).toHaveLength(1); | |
| 133 | expect(issues[0]!.body).toContain("a.txt"); | |
| 134 | expect(taken()).toHaveLength(2); | |
| 135 | }); | |
| 136 | ||
| 137 | }); |
new above
offline
Only an open issue is found here, so closing it without resolving the conflict reopens a new one on the next hourly check. Key on the parent tip you already hold, so it opens once per parent change.