Public: anyone can read this; the crew can change it.
When a new forge's crew cannot continue on a green pull, the pull and app page say why and offer Land it as it is #11901
Check · Review · Land · Live — Its check is queued.
scout/forge:scout/11892-when-a-new-forge-s-crew-cannot-continue → main 2 commits fork
7 files
changed +258
−3
docs/features/land-as-is.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Land it as it is | |
| 2 | ||
| 3 | When the crew cannot go on (its included credit is spent, or it has no model key) a green pull still goes live. | |
| 4 | ||
| 5 | - The pull page and the app's page each say so in one sentence, and offer **Land it as it is** with the preview link beside it, and **Add a key**. | |
| 6 | - The button is the pull's own ship action (`POST /:owner/:name/pulls/:n/ship`, same-origin checked, `mergePull` behind it). It is offered only for a pull whose check is green and whose verdict is "Ready to land"; a red pull never shows it. | |
| 7 | - MCP: the existing `merge` tool takes `action: "land"` (squash, only while the check is green); its plan carries `crew` with the same sentence. | |
| 8 | - Code: `src/web/studio/land_as_is.ts`. Test: `test/land-as-is.test.ts`. |
src/http/mcp/tools/merge.ts +11 −3
| @@ -3,6 +3,7 @@ import { mergePull } from "../../../actions/pulls.ts"; | ||
| 3 | 3 | import { getPull, pullDir, pullMergeState } from "../../../core/pulls.ts"; |
| 4 | 4 | import { checkSummary } from "../../../core/runs.ts"; |
| 5 | 5 | import { mergeFailure } from "../../../web/pages/pull.ts"; |
| 6 | import { LAND_AS_IS, stalledWords } from "../../../web/studio/land_as_is.ts"; | |
| 6 | 7 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 7 | 8 | |
| 8 | 9 | /** |
| @@ -13,7 +14,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 13 | 14 | */ |
| 14 | 15 | export const tool: Tool = { |
| 15 | 16 | name: "merge", |
| 16 | doc: "`{ repo, number, strategy?, dryRun? }` (a person, never an agent: a person merges): land a pull as its page's Merge button does, `merge` (default) or `squash`; planned unless `dryRun` is false: the plan is the pull's `head`, `base`, `mergeable`, `conflicts` and `check` as `pull` reads them; landing is `mergePull`'s, with its refusals (conflict, review or check required, not a writer) in the page's words, and takes an rw token", | |
| 17 | doc: "`{ repo, number, strategy?, action?, dryRun? }` (a person, never an agent: a person merges): land a pull as its page's Merge button does, `merge` (default) or `squash`; `action: \"land\"` is the page's Land it as it is (squash, and only while the check is green); plans carry `crew` when the crew cannot go on; planned unless `dryRun` is false: the plan is the pull's `head`, `base`, `mergeable`, `conflicts` and `check` as `pull` reads them; landing is `mergePull`'s, with its refusals (conflict, review or check required, not a writer) in the page's words, and takes an rw token", | |
| 17 | 18 | description: "Land a pull, as its page's Merge button does; a person's only.", |
| 18 | 19 | inputSchema: { |
| 19 | 20 | type: "object", |
| @@ -21,6 +22,7 @@ export const tool: Tool = { | ||
| 21 | 22 | repo: REPO_ARG, |
| 22 | 23 | number: { type: "integer", minimum: 1 }, |
| 23 | 24 | strategy: { type: "string", enum: ["merge", "squash"] }, |
| 25 | action: { type: "string", enum: ["land"] }, | |
| 24 | 26 | dryRun: { type: "boolean" }, |
| 25 | 27 | }, |
| 26 | 28 | required: ["repo", "number"], |
| @@ -35,7 +37,9 @@ export const tool: Tool = { | ||
| 35 | 37 | const s = args["strategy"]; |
| 36 | 38 | if (s !== undefined && s !== "merge" && s !== "squash") |
| 37 | 39 | return bad("invalid", "strategy is merge or squash"); |
| 38 | const strategy = s === "squash" ? "squash" : "merge"; | |
| 40 | const act = args["action"]; | |
| 41 | if (act !== undefined && act !== "land") return bad("invalid", "action is land"); | |
| 42 | const strategy = s === "squash" || act === "land" ? "squash" : "merge"; | |
| 39 | 43 | const n = args["number"]; |
| 40 | 44 | const pull = |
| 41 | 45 | typeof n === "number" && Number.isInteger(n) && n > 0 |
| @@ -43,6 +47,7 @@ export const tool: Tool = { | ||
| 43 | 47 | : undefined; |
| 44 | 48 | if (pull === undefined || !mayView(ctx.app.db, repo, pull, user)) |
| 45 | 49 | return bad("no_such_pull", `no pull #${String(n)}`); |
| 50 | const stalled = stalledWords(ctx.app); | |
| 46 | 51 | const url = `/${repo.ownerName}/${repo.name}/pulls/${pull.number}`; |
| 47 | 52 | if (args["dryRun"] !== false) { |
| 48 | 53 | // The `pull` tool's reads, so the plan and the reading never disagree. |
| @@ -60,9 +65,12 @@ export const tool: Tool = { | ||
| 60 | 65 | conflicts: state?.conflicts ?? [], |
| 61 | 66 | check: { state: check.state, baseOid: check.run?.baseOid ?? "" }, |
| 62 | 67 | url, |
| 68 | ...(stalled === null ? {} : { crew: stalled }), | |
| 63 | 69 | says: "This is the plan; nothing changed. Send dryRun: false to merge.", |
| 64 | 70 | }); |
| 65 | 71 | } |
| 72 | if (act === "land" && checkSummary(ctx.app.db, pull.id, pull.headOid).state !== "ok") | |
| 73 | return bad("check_required", `${LAND_AS_IS} waits for a green check`); | |
| 66 | 74 | const merged = await mergePull(ctx.app, user, repo, pull, strategy); |
| 67 | 75 | if (!merged.ok) { |
| 68 | 76 | const error = merged.error; |
| @@ -80,7 +88,7 @@ export const tool: Tool = { | ||
| 80 | 88 | strategy, |
| 81 | 89 | mergeOid: merged.value.mergeOid, |
| 82 | 90 | url, |
| 83 | says: `Merged #${pull.number} into ${pull.baseBranch}.`, | |
| 91 | says: act === "land" ? `Landed #${pull.number} as it is.` : `Merged #${pull.number} into ${pull.baseBranch}.`, | |
| 84 | 92 | }); |
| 85 | 93 | }, |
| 86 | 94 | }; |
src/web/pages/pull.ts +4
| @@ -32,6 +32,7 @@ import { tierLine, type TierState } from "../../checks/tiers.ts"; | ||
| 32 | 32 | import { fromKeptFork } from "../../core/landing_kind.ts"; |
| 33 | 33 | import { combinedCard } from "./pull-combined.ts"; |
| 34 | 34 | import { verdictLine, verdictOf, type VerdictInput } from "./pull-verdict.ts"; |
| 35 | import { landAsIs } from "../studio/land_as_is.ts"; | |
| 35 | 36 | import { getAppByRepo } from "../../core/apps.ts"; |
| 36 | 37 | import { labelsFor, listLabels } from "../../core/issues.ts"; |
| 37 | 38 | import { marksByName } from "../../core/marks.ts"; |
| @@ -1110,6 +1111,9 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 1110 | 1111 | ${changeHead(ctx, repo, pull, { headOid: status.headOid, conflicts: status.conflicts.length })} |
| 1111 | 1112 | ${changeTabs(repo, pull, "files")} |
| 1112 | 1113 | ${verdictLine(verdict)} |
| 1114 | ${verdict.tone === "success" && mayMerge && check.state === "ok" && !stale | |
| 1115 | ? landAsIs(ctx, repo, pull, isApp ? `${repoBase(repo)}/app/pull-${pull.number}/open` : undefined) | |
| 1116 | : null} | |
| 1113 | 1117 | <p class="files-where muted small" id="files"> |
| 1114 | 1118 | <span class="mono">${headLabel} → ${pull.baseBranch}</span> |
| 1115 | 1119 | <span class="sep" aria-hidden="true">·</span> |
src/web/studio/app.ts +3
| @@ -65,6 +65,7 @@ import { schedulesCard } from "./schedules.ts"; | ||
| 65 | 65 | import { appPanels } from "./previews.ts"; |
| 66 | 66 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; |
| 67 | 67 | import { crewKeyCard, includedCredit } from "./crew_key.ts"; |
| 68 | import { landOnApp } from "./land_as_is.ts"; | |
| 68 | 69 | import { usdWords } from "../../spawn/credit.ts"; |
| 69 | 70 | import { |
| 70 | 71 | notePhase, |
| @@ -510,6 +511,7 @@ export async function studioApp( | ||
| 510 | 511 | : followingCard(ctx, repo, following)} |
| 511 | 512 | <div id="app-head">${head}</div> |
| 512 | 513 | ${ownerFacts ? crewKeyCard(ctx, true) : null} |
| 514 | ${ownerFacts ? landOnApp(ctx, repo) : null} | |
| 513 | 515 | <div class="stack"> |
| 514 | 516 | ${adapter} |
| 515 | 517 | ${schedules} |
| @@ -531,6 +533,7 @@ export async function studioApp( | ||
| 531 | 533 | ${askingLine(ctx, repo)} |
| 532 | 534 | ${policy} |
| 533 | 535 | ${ownerFacts ? crewKeyCard(ctx, true) : null} |
| 536 | ${ownerFacts ? landOnApp(ctx, repo) : null} | |
| 534 | 537 | ${ownerFacts |
| 535 | 538 | ? egressNote( |
| 536 | 539 | ctx.app.config.dataDir, |
src/web/studio/change.ts +4
| @@ -66,6 +66,7 @@ import { | ||
| 66 | 66 | import { previewBlock } from "../pages/apps.ts"; |
| 67 | 67 | import { lookCard } from "../pages/looks.ts"; |
| 68 | 68 | import { approveHereCard, keptChange, pullChecksLineFor, teamsCard, verdictFacts } from "../pages/pull.ts"; |
| 69 | import { landAsIs } from "./land_as_is.ts"; | |
| 69 | 70 | import { changeSummary, verdictLine, verdictOf } from "../pages/pull-verdict.ts"; |
| 70 | 71 | import { guestWalls, strangerCard } from "../pages/pull-guest.ts"; |
| 71 | 72 | import { combinedCard } from "../pages/pull-combined.ts"; |
| @@ -757,6 +758,9 @@ async function changeConversation( | ||
| 757 | 758 | )} |
| 758 | 759 | <div id="pull-check">${checkLine}</div> |
| 759 | 760 | ${when(runLine, (l) => l)} ${gate} ${decisionSection} |
| 761 | ${verdict.tone === "success" && writer && word === "look" | |
| 762 | ? landAsIs(ctx, repo, pull, record === undefined ? undefined : `${base}/app/pull-${pull.number}/open`) | |
| 763 | : null} | |
| 760 | 764 | ${when(visionPreview, (v) => visionPreviewCard(v))} |
| 761 | 765 | ${look} |
| 762 | 766 | <div class="split"> |
src/web/studio/land_as_is.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * Land it as it is (#11892): when the crew cannot go on (its included credit | |
| 3 | * is spent, or it has no model key) a green pull still lands. One sentence | |
| 4 | * says why the crew stopped; two actions follow: land the pull as it is, with | |
| 5 | * the preview beside it, and add a key. The button is the pull's own ship | |
| 6 | * action (`POST …/pulls/:n/ship`, same-origin checked, `mergePull` behind | |
| 7 | * it), so a red or held pull is never landed from here: it is only offered | |
| 8 | * for a pull whose verdict is already "Ready to land". | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../../app.ts"; | |
| 12 | import type { Pull } from "../../core/pulls.ts"; | |
| 13 | import type { Repo } from "../../core/repos.ts"; | |
| 14 | import type { Ctx } from "../../http/context.ts"; | |
| 15 | import { repoBase } from "../chrome.ts"; | |
| 16 | import { html, when, type Html } from "../html.ts"; | |
| 17 | import { btn } from "../ui.ts"; | |
| 18 | import { crewCanRun, hasCrew, includedCredit } from "./crew_key.ts"; | |
| 19 | import { decisionsFor } from "./decisions.ts"; | |
| 20 | import { changeHref } from "./nouns.ts"; | |
| 21 | ||
| 22 | /** The one label, on the page and in the MCP tool's `action`. */ | |
| 23 | export const LAND_AS_IS = "Land it as it is"; | |
| 24 | export const ADD_KEY_HREF = "/settings/subscriptions"; | |
| 25 | ||
| 26 | /** Why the crew cannot go on, in one plain sentence; null while it can, or when there is no crew to stop. */ | |
| 27 | export function stalledWords(app: App): string | null { | |
| 28 | if (!hasCrew(app) || crewCanRun(app)) return null; | |
| 29 | return includedCredit(app) !== undefined | |
| 30 | ? "The crew cannot go on: the included credit is spent." | |
| 31 | : "The crew cannot go on: it has no model key."; | |
| 32 | } | |
| 33 | ||
| 34 | /** The card for one green pull: the sentence, the button with the preview beside it, and the way to add a key. */ | |
| 35 | export function landAsIs(ctx: Ctx, repo: Repo, pull: Pull, preview: string | undefined): Html | null { | |
| 36 | const words = stalledWords(ctx.app); | |
| 37 | if (words === null || pull.state !== "open" || pull.mergedAt !== null) return null; | |
| 38 | const thread = `${repoBase(repo)}/pulls/${pull.number}`; | |
| 39 | const admin = ctx.auth.user?.isAdmin === true; | |
| 40 | return html`<div class="mt mb" id="land-as-is" data-land-as-is role="status"> | |
| 41 | <p>${words} This pull is ready, so you can put it live as it is.</p> | |
| 42 | <div class="bar mt"> | |
| 43 | <form method="post" action="${thread}/ship" data-now="Landing"> | |
| 44 | ${btn(LAND_AS_IS, { size: "sm", variant: "primary", icon: "merge" })} | |
| 45 | </form> | |
| 46 | ${when(preview, (href) => btn("Preview", { href, size: "sm" }))} | |
| 47 | ${admin ? btn("Add a key", { href: ADD_KEY_HREF, size: "sm" }) : html`<span class="muted small">Ask this forge's admin to add a key.</span>`} | |
| 48 | </div> | |
| 49 | </div>`; | |
| 50 | } | |
| 51 | ||
| 52 | /** The app's page: the same sentence and button for each pull of this app that is green and waiting on the viewer. Reads nothing while the crew can go on. */ | |
| 53 | export function landOnApp(ctx: Ctx, repo: Repo): Html | null { | |
| 54 | if (stalledWords(ctx.app) === null) return null; | |
| 55 | const green = decisionsFor(ctx, [repo], { evenDismissed: true }).filter( | |
| 56 | (d) => d.kind === "look" && d.pull !== undefined && d.pull.state === "open", | |
| 57 | ); | |
| 58 | if (green.length === 0) return null; | |
| 59 | return html`${green.map((d) => { | |
| 60 | const pull = d.pull!; | |
| 61 | return html`<div class="mt"> | |
| 62 | <p class="small"><a href="${changeHref(repoBase(repo), pull.number)}">${pull.title}</a></p> | |
| 63 | ${landAsIs(ctx, repo, pull, d.preview)} | |
| 64 | </div>`; | |
| 65 | })}`; | |
| 66 | } |
test/land-as-is.test.ts added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | /** | |
| 2 | * Land it as it is (#11892): credit runs out after a pull went green; the | |
| 3 | * pull page and the app page say so in one sentence and offer the button, | |
| 4 | * and pressing it lands the pull. A red pull is never offered it. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { writeFileSync } from "node:fs"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { enableApp } from "../src/actions/apps.ts"; | |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { STRANGER_NEVER_SEES } from "../src/design/first_five_minutes.ts"; | |
| 22 | import { LAND_AS_IS } from "../src/web/studio/land_as_is.ts"; | |
| 23 | ||
| 24 | describe("land it as it is", () => { | |
| 25 | let dataDir = ""; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let base = ""; | |
| 29 | let alice: User; | |
| 30 | let repo: Repo; | |
| 31 | let cookie = ""; | |
| 32 | let n = 0; | |
| 33 | ||
| 34 | const get = (path: string) => | |
| 35 | fetch(`${base}${path}`, { headers: { cookie: `${cookie}; forge_mode=studio` }, redirect: "manual" }).then((r) => r.text()); | |
| 36 | ||
| 37 | const pullWith = async (state: "ok" | "failed"): Promise<Pull> => { | |
| 38 | n += 1; | |
| 39 | const branch = await commitToBranch(app, alice, repo, { | |
| 40 | branch: `feature/b${n}`, | |
| 41 | base: "main", | |
| 42 | message: `Change ${n}`, | |
| 43 | files: [{ path: `F${n}.md`, content: `${n}\n` }], | |
| 44 | }); | |
| 45 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 46 | const opened = await openPull(app, alice, repo, { | |
| 47 | title: `Change ${n}`, | |
| 48 | body: "A change.", | |
| 49 | baseBranch: "main", | |
| 50 | headBranch: `feature/b${n}`, | |
| 51 | }); | |
| 52 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 53 | const pull = opened.value; | |
| 54 | const run = createRun(app.db, { | |
| 55 | repoId: repo.id, | |
| 56 | kind: "check", | |
| 57 | issueId: pull.id, | |
| 58 | env: `pull-${pull.number}`, | |
| 59 | commitOid: pull.headOid, | |
| 60 | reason: "pull", | |
| 61 | }); | |
| 62 | claimRun(app.db, run.id); | |
| 63 | finishRun(app.db, run.id, { state, summary: state === "ok" ? "check passed in 1s" : "check failed" }); | |
| 64 | return pull; | |
| 65 | }; | |
| 66 | ||
| 67 | const spendCredit = () => | |
| 68 | writeFileSync( | |
| 69 | join(dataDir, "parent.json"), | |
| 70 | JSON.stringify({ credit: { token: "bearer-token-123", capUsd: 5, spentUsd: 5 } }), | |
| 71 | ); | |
| 72 | ||
| 73 | beforeAll(async () => { | |
| 74 | dataDir = await mkdtemp(join(tmpdir(), "forge-land-as-is-")); | |
| 75 | app = createApp({ dataDir, port: 0 }); | |
| 76 | servers = await serve(app); | |
| 77 | base = servers.url; | |
| 78 | const a = await createUser(app.db, { | |
| 79 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 80 | password: "a-good-passphrase", adminIfFirst: true, | |
| 81 | }); | |
| 82 | if (!a.ok) throw new Error(a.error); | |
| 83 | alice = a.value; | |
| 84 | cookie = ( | |
| 85 | (await fetch(`${base}/signin`, { | |
| 86 | method: "POST", | |
| 87 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 88 | redirect: "manual", | |
| 89 | })).headers.get("set-cookie") ?? "" | |
| 90 | ).split(";")[0]!; | |
| 91 | const made = await createRepo(app, alice, { name: "garden", visibility: "public", description: "d" }); | |
| 92 | if (!made.ok) throw new Error(String(made.error)); | |
| 93 | repo = made.value; | |
| 94 | const seeded = await commitToBranch(app, alice, repo, { | |
| 95 | message: "Start", | |
| 96 | files: [ | |
| 97 | { path: "package.json", content: '{"name":"garden","scripts":{"start":"bun run server.ts"}}\n' }, | |
| 98 | { path: "server.ts", content: "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n" }, | |
| 99 | ], | |
| 100 | }); | |
| 101 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 102 | const enabled = await enableApp(app, alice, repo, { slug: "garden" }); | |
| 103 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 104 | const agent = await createAgent(app, alice, { username: "keeper" }); | |
| 105 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 106 | expect(addToCrew(app, alice, repo, "keeper").ok).toBe(true); | |
| 107 | }); | |
| 108 | ||
| 109 | afterAll(async () => { | |
| 110 | servers.stop(); | |
| 111 | app.close(); | |
| 112 | await rm(dataDir, { recursive: true, force: true }); | |
| 113 | }); | |
| 114 | ||
| 115 | test("while the crew can go on, nothing is offered", async () => { | |
| 116 | // No credit file and no key: the crew has nothing to run on, so seed a key-less forge with credit left. | |
| 117 | writeFileSync(join(dataDir, "parent.json"), JSON.stringify({ credit: { token: "bearer-token-123", capUsd: 5, spentUsd: 1 } })); | |
| 118 | const pull = await pullWith("ok"); | |
| 119 | const page = await get(`/alice/garden/pulls/${pull.number}`); | |
| 120 | expect(page).not.toContain("data-land-as-is"); | |
| 121 | expect(await get(`/alice/garden/pulls/${pull.number}/files`)).not.toContain("data-land-as-is"); | |
| 122 | }); | |
| 123 | ||
| 124 | test("credit runs out after a green pull: the sentence, the button, the preview and the key on both pages; pressing it lands", async () => { | |
| 125 | const pull = await pullWith("ok"); | |
| 126 | spendCredit(); | |
| 127 | const sentence = "The crew cannot go on: the included credit is spent."; | |
| 128 | for (const path of [`/alice/garden/pulls/${pull.number}`, `/alice/garden/pulls/${pull.number}/files`, "/alice/garden"]) { | |
| 129 | const page = await get(path); | |
| 130 | expect(page).toContain(sentence); | |
| 131 | expect(page).toContain(LAND_AS_IS); | |
| 132 | expect(page).toContain(`action="/alice/garden/pulls/${pull.number}/ship"`); | |
| 133 | expect(page).toContain(`/alice/garden/app/pull-${pull.number}/open`); | |
| 134 | expect(page).toContain('href="/settings/subscriptions"'); | |
| 135 | } | |
| 136 | const card = (await get(`/alice/garden/pulls/${pull.number}`)).split("data-land-as-is")[1]!.split("</div>")[1]!.toLowerCase(); | |
| 137 | for (const word of STRANGER_NEVER_SEES) expect(card.includes(word)).toBe(false); | |
| 138 | ||
| 139 | const pressed = await fetch(`${base}/alice/garden/pulls/${pull.number}/ship`, { | |
| 140 | method: "POST", | |
| 141 | headers: { cookie, origin: "https://evil.example" }, | |
| 142 | redirect: "manual", | |
| 143 | }); | |
| 144 | expect(pressed.status).toBe(403); // same-origin check: a foreign origin lands nothing | |
| 145 | expect(getPull(app.db, repo.id, pull.number)?.mergedAt).toBeNull(); | |
| 146 | const landed = await fetch(`${base}/alice/garden/pulls/${pull.number}/ship`, { | |
| 147 | method: "POST", | |
| 148 | headers: { cookie, origin: base }, | |
| 149 | redirect: "manual", | |
| 150 | }); | |
| 151 | expect(landed.status).toBe(303); | |
| 152 | expect(getPull(app.db, repo.id, pull.number)?.mergedAt).not.toBeNull(); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a red pull is never offered it", async () => { | |
| 156 | spendCredit(); | |
| 157 | const pull = await pullWith("failed"); | |
| 158 | const page = await get(`/alice/garden/pulls/${pull.number}`); | |
| 159 | expect(page).not.toContain("data-land-as-is"); | |
| 160 | expect(await get(`/alice/garden/pulls/${pull.number}/files`)).not.toContain("data-land-as-is"); | |
| 161 | }); | |
| 162 | }); |
new above
offline