From b3a2273c2ac4083dcb06a01cb2e02e7a3c297956 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 5 Sep 2026 18:13:48 -0300 Subject: [PATCH] feat: name a worktree by the GitHub issue it is for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The New worktree dialog's name field took a branch name or the task in plain words. It takes an issue too now — "#128", or the URL copied out of the browser — looked up through gh on the project's own account. What that buys is the other half: the session opened on that worktree finds the issue at its prompt. Number, title, link and body, as one bracketed paste so the body's newlines are text rather than a run of Enters, and unsent — the Enter stays the user's, as with the pull request handoff. Typing the context in by hand was the only way an agent learned what its worktree was for. Everything the flow needs already existed: toBranchName slugs the title and prints the exact branch under the field before anything is created (#413), CreateWorktree makes an ordinary branch off the chosen base, writeAtPrompt waits out the setup script and the provider's boot (#447), and bracketedPaste is why the paste is one block. What is new is one gh call, one pure module, and a row in the gh message table. Two refusals, both because the field is a branch name first: - A bare number stays a branch name. "#" is the marker, so there is one field with one meaning rather than a mode to be in. - A pull request's number is refused, naming the screen that does open pull requests. GitHub counts issues and pull requests in one sequence and `gh issue view` answers for either; taking it would branch off the base under its title instead of checking its head out. The URL is the only field in the answer that tells the two apart. And the reference itself never names the branch, resolved or not: git accepts "#381" as a branch name, and most shells read the "#" as the start of a comment, so `git checkout #381` is `git checkout`. Claude-Session: https://claude.ai/code/session_01GSu6sRs6pBuZHmWaJmuZ3o --- CHANGELOG.md | 14 +++ docs/ceilings.md | 7 +- .../src/components/sidebar/SessionSidebar.tsx | 15 ++- .../src/components/sidebar/WorktreeDialog.tsx | 92 ++++++++++++-- frontend/src/lib/api-types.ts | 10 ++ frontend/src/lib/issue.test.ts | 84 +++++++++++++ frontend/src/lib/issue.ts | 48 ++++++++ frontend/src/lib/rpc.ts | 4 + internal/project/gherror.go | 5 + internal/project/issue.go | 70 +++++++++++ internal/project/issue_test.go | 116 ++++++++++++++++++ 11 files changed, 451 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/issue.test.ts create mode 100644 frontend/src/lib/issue.ts create mode 100644 internal/project/issue.go create mode 100644 internal/project/issue_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dd28c4775..8fb25cf4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A GitHub issue can now be the worktree.** + The New worktree dialog's name field takes an issue — `#128`, or the URL copied + out of the browser — beside the branch names and plain words it already took. + lich looks the issue up through `gh` on the project's own account, prints its + title and the exact branch it will create under the field before anything + exists, and hands the issue to the session that opens on it: the number, the + title, the link and the body, waiting unsent at the agent's prompt for you to + read, edit and send. Nothing is auto-generated on your behalf — a worktree + named `#128` used to be a branch called `128`, and typing the task in by hand + was the only way the agent learned what it was for. A bare number is still just + a branch name, because that is what git makes of it; a pull request's number is + refused with the screen that does open pull requests named, since GitHub counts + both in one sequence and `gh` will answer for either. + - **`lich open` and its MCP tool now open a project, not only a session in one.** `--project` took the name of a project already on screen, so an agent could fan work out across the projects you had open and no further: putting a diff --git a/docs/ceilings.md b/docs/ceilings.md index df0383421..a2e2a7b09 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -342,9 +342,10 @@ work when nobody knows it and that the call site never shows. The mechanism and `draftIdle` for nothing. The stale-draft release is what keeps that a delay instead of a wedged relay. Two gaps stay open: input arriving between the paste and its Enter still rides along — a window that is `defaultSubmitDelay` at best and lasts until the target's PTY goes quiet at worst (`internal/relay`, `awaitSettled`) — and a provider - that takes keystrokes through anything other than this PTY is invisible here. The pull request handoff waits on - the same answer (`frontend/src/lib/terminal/write-at-prompt.ts`) with no Enter of its own to justify it: hand a - conflict to a session you left half a sentence in, and nothing appears at that prompt until the draft goes stale. + that takes keystrokes through anything other than this PTY is invisible here. The pull request and issue handoffs + wait on the same answer (`frontend/src/lib/terminal/write-at-prompt.ts`) with no Enter of their own to justify it: + hand a conflict to a session you left half a sentence in, and nothing appears at that prompt until the draft goes + stale. - **A relayed Enter is timed against silence, not against the target** (`internal/relay`, `awaitSettled`): lich presses Enter once the target's PTY has been quiet for `defaultSubmitDelay`, because nothing here can read a TUI's screen to know it has taken the paste in. On Windows that quiet is the whole instrument — ConPTY hands a child key diff --git a/frontend/src/components/sidebar/SessionSidebar.tsx b/frontend/src/components/sidebar/SessionSidebar.tsx index 030f25510..e8afa3099 100644 --- a/frontend/src/components/sidebar/SessionSidebar.tsx +++ b/frontend/src/components/sidebar/SessionSidebar.tsx @@ -31,6 +31,7 @@ import { } from "@/components/ui/dropdown-menu" import { useProjects } from "@/providers/projects" import { queueSetup } from "@/lib/terminal/setup-queue" +import { writeAtPrompt } from "@/lib/terminal/write-at-prompt" import { filterSessions } from "@/lib/session/session-filter" import { requestTerminalFocus } from "@/lib/terminal/focus-request" import { @@ -50,6 +51,7 @@ import { useGitStatus } from "@/lib/git/use-git-status" import { usePanelWidth } from "@/lib/use-panel-width" import { useWorktreeDialogIntent } from "@/lib/use-sidebar-intent" import { SessionLaunchMenuItems } from "./SessionLaunchMenuItems" +import { errorText } from "@/lib/utils" // Named here like every other `lich.*` pref rather than spelled at the call // site. The bounds go with it: wide enough for a session label and its branch, @@ -250,12 +252,23 @@ export function SessionSidebar({ onCollapse }: SessionSidebarProps) { base: string, baseIsRemote: boolean, sandbox: string, + prompt: string, ) => { const wt = await ProjectService.CreateWorktree(path, projectId, name, base, baseIsRemote) if (wt) { // A fresh checkout is the one moment the project's setup script runs; // reopening an existing worktree never queues it. - queueSetup(newWorktreeSession(projectId, wt, sandbox)) + const target = newWorktreeSession(projectId, wt, sandbox) + queueSetup(target) + // The issue the worktree was named after, written at that session's + // prompt once it has one — the setup script and the provider's boot come + // first, which is what writeAtPrompt waits out. Not awaited: the dialog + // closes on a worktree that exists, and the wait can be minutes. + if (prompt) { + void writeAtPrompt(target, prompt).catch((err: unknown) => { + toast.error(`Couldn’t hand the issue to the session: ${errorText(err)}`) + }) + } } setWorktreeOpen(false) } diff --git a/frontend/src/components/sidebar/WorktreeDialog.tsx b/frontend/src/components/sidebar/WorktreeDialog.tsx index e98005bed..afcea7da7 100644 --- a/frontend/src/components/sidebar/WorktreeDialog.tsx +++ b/frontend/src/components/sidebar/WorktreeDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react" import type { KeyboardEvent } from "react" import { ProjectService } from "@/lib/rpc" -import type { Branches, Worktree } from "@/lib/api-types" +import type { Branches, Issue, Worktree } from "@/lib/api-types" import { SearchInput } from "@/components/common/SearchInput" import { WorktreeSandboxRow } from "@/components/sidebar/WorktreeSandboxRow" import { WorktreeSetupRow } from "@/components/sidebar/WorktreeSetupRow" @@ -18,9 +18,15 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { toBranchName } from "@/lib/git/branch-name" +import { issueBrief, issueName, parseIssueRef } from "@/lib/issue" import { useSandboxChoice, type SandboxAnswer } from "@/lib/use-sandbox-choice" import { cn, errorText } from "@/lib/utils" +// How long the field has to settle before the issue behind a reference is +// looked up. Every keystroke of "#381" is a valid reference on its way to the +// one that was meant, so without this one issue costs three gh calls. +const ISSUE_LOOKUP_MS = 400 + interface WorktreeDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -33,12 +39,15 @@ interface WorktreeDialogProps { currentBranch: string /** Create the worktree and open its session; rejections show in the dialog. * sandbox is the confinement answer for that session ("on"/"off", "" when the - * machine cannot confine and nothing was asked). */ + * machine cannot confine and nothing was asked). prompt is what that session + * is handed once it comes up — the issue this worktree is for, or "" when the + * name was not one. */ onCreate: ( name: string, base: string, baseIsRemote: boolean, sandbox: SandboxAnswer, + prompt: string, ) => Promise /** Reopen a session on an already-existing worktree. */ onResume: (wt: { name: string; path: string }) => void @@ -126,6 +135,8 @@ export function WorktreeDialog({ }: WorktreeDialogProps) { const [branches, setBranches] = useState(null) const [name, setName] = useState("") + const [issue, setIssue] = useState(null) + const [issueError, setIssueError] = useState("") const [base, setBase] = useState("") const [filter, setFilter] = useState("") const [loadError, setLoadError] = useState("") @@ -138,9 +149,22 @@ export function WorktreeDialog({ const vis = filterBranches(branches, filter) const flat = flatValues(vis) const noMatches = branches !== null && flat.length === 0 + // The issue the field names, if it names one. The reference is what was + // typed; the issue is what GitHub answered for it, and only that carries the + // title the branch is named after. + const issueRef = parseIssueRef(name) + const lookingUp = issueRef !== null && !issue && !issueError // What the typed name creates: itself when git takes it, a slug of the first // few words when it is a phrase, blank when nothing usable was typed. - const newBranch = toBranchName(name) + // + // A reference is never named by the "#381" that was typed, resolved or not. + // git accepts "#381" as a branch name, and most shells read the "#" as the + // start of a comment — `git checkout #381` is `git checkout`. So the issue + // names it once GitHub has answered, and its bare number until then, which is + // also what is left when the lookup fails and the worktree is made anyway. + const newBranch = toBranchName( + issue ? issueName(issue) : issueRef !== null ? String(issueRef) : name, + ) const isResume = base.startsWith("worktree:") // Keep the selected base in view as it changes — the preselected current @@ -149,6 +173,36 @@ export function WorktreeDialog({ listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }) }, [base, branches]) + // The issue behind the typed reference, looked up while the field is still + // being typed in. A failure is shown but never blocks: the branch under the + // field is still a branch, and creating the worktree without the issue's text + // is a worse outcome than not creating it at all only if lich decides so. + useEffect(() => { + setIssue(null) + setIssueError("") + if (issueRef === null) { + return + } + let stale = false + const timer = setTimeout(() => { + ProjectService.Issue(projectPath, issueRef) + .then((found) => { + if (!stale) { + setIssue(found) + } + }) + .catch((err: unknown) => { + if (!stale) { + setIssueError(errorText(err)) + } + }) + }, ISSUE_LOOKUP_MS) + return () => { + stale = true + clearTimeout(timer) + } + }, [issueRef, projectPath]) + useEffect(() => { if (!open) { return @@ -229,7 +283,13 @@ export function WorktreeDialog({ setSubmitting(true) setSubmitError("") try { - await onCreate(newBranch, id, group === "remote", sandbox.answer) + await onCreate( + newBranch, + id, + group === "remote", + sandbox.answer, + issue ? issueBrief(issue) : "", + ) } catch (err) { setSubmitError(errorText(err)) setSubmitting(false) @@ -256,15 +316,27 @@ export function WorktreeDialog({ id="worktree-name" value={name} onChange={(e) => setName(e.target.value)} - placeholder="A branch name, or the task in plain words (e.g. fix the auth redirect)" + placeholder="A branch name, an issue (#128), or the task in plain words" disabled={isResume} autoFocus /> - - {isResume - ? "Opens the selected worktree" - : `Branch: ${newBranch || ""}`} - +
+ {isResume ? ( + Opens the selected worktree + ) : lookingUp ? ( + Looking up #{issueRef}… + ) : ( + <> + {issue && ( + + #{issue.number} {issue.title} + + )} + {issueError && {issueError}} + Branch: {newBranch || ""} + + )} +
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 30c58d9bb..b94eed62b 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -273,6 +273,16 @@ export interface Branches { worktrees: Worktree[] | null } +/** internal/project.Issue — the little of a GitHub issue the New-worktree + * dialog needs: the title names the branch, the body is what the session it + * opens finds at its prompt. The body arrives already cut (maxIssueBody). */ +export interface Issue { + number: number + title: string + body: string + url: string +} + /** internal/project.WorktreeSetup — what the New-worktree dialog shows about * the project's setup: the .lich/setup-worktree.sh content, or a suggestion * detected from the repository root when the file is absent. */ diff --git a/frontend/src/lib/issue.test.ts b/frontend/src/lib/issue.test.ts new file mode 100644 index 000000000..c9701d682 --- /dev/null +++ b/frontend/src/lib/issue.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest" +import { issueBrief, issueName, parseIssueRef } from "@/lib/issue" +import { toBranchName } from "@/lib/git/branch-name" + +describe("parseIssueRef", () => { + it("reads GitHub's own spelling", () => { + expect(parseIssueRef("#381")).toBe(381) + expect(parseIssueRef(" #1 ")).toBe(1) + }) + + it("reads a copied issue URL, with or without a tail", () => { + expect(parseIssueRef("https://github.com/omartelo/lich/issues/381")).toBe(381) + expect(parseIssueRef("https://github.com/omartelo/lich/issues/381#issuecomment-9")).toBe(381) + expect(parseIssueRef("https://ghe.corp.example/team/app/issues/12?foo=1")).toBe(12) + }) + + it("leaves a bare number alone — git takes it as a branch name", () => { + expect(parseIssueRef("381")).toBeNull() + }) + + it("is not a pull request URL", () => { + expect(parseIssueRef("https://github.com/omartelo/lich/pull/453")).toBeNull() + }) + + it("names no issue for ordinary branch names and typed tasks", () => { + expect(parseIssueRef("")).toBeNull() + expect(parseIssueRef("fix the auth redirect")).toBeNull() + expect(parseIssueRef("feat/issues-381")).toBeNull() + expect(parseIssueRef("#")).toBeNull() + expect(parseIssueRef("#abc")).toBeNull() + expect(parseIssueRef("#12 and more")).toBeNull() + }) + + it("refuses a number that is not one", () => { + expect(parseIssueRef("#0")).toBeNull() + expect(parseIssueRef(`#${"9".repeat(30)}`)).toBeNull() + }) +}) + +describe("issueName", () => { + it("puts the number in front, so the branch carries it", () => { + const issue = { + number: 381, + title: "Sandbox backend for Windows (unelevated write protection)", + body: "", + url: "", + } + expect(issueName(issue)).toBe("381 Sandbox backend for Windows (unelevated write protection)") + // The name is only ever seen through toBranchName, which is what the dialog + // prints under the field before anything is created. + expect(toBranchName(issueName(issue))).toBe("381-sandbox-backend-for-windows") + }) +}) + +describe("issueBrief", () => { + const issue = { + number: 381, + title: "Sandbox backend for Windows", + body: "Windows has no user namespace.\n\nMap what an unelevated process can protect.", + url: "https://github.com/omartelo/lich/issues/381", + } + + it("is one bracketed paste, so the newlines are text and nothing is sent", () => { + const brief = issueBrief(issue) + expect(brief.startsWith("\x1b[200~")).toBe(true) + expect(brief.endsWith("\x1b[201~")).toBe(true) + expect(brief).not.toContain("\r") + }) + + it("names the issue, links it, and carries its body", () => { + const brief = issueBrief(issue) + expect(brief).toContain("GitHub issue #381 — Sandbox backend for Windows") + expect(brief).toContain("https://github.com/omartelo/lich/issues/381") + expect(brief).toContain("Map what an unelevated process can protect.") + }) + + it("leaves no dangling blank lines when the issue has no body", () => { + const brief = issueBrief({ ...issue, body: " \n " }) + expect(brief).toBe( + "\x1b[200~GitHub issue #381 — Sandbox backend for Windows\n" + + "https://github.com/omartelo/lich/issues/381\x1b[201~", + ) + }) +}) diff --git a/frontend/src/lib/issue.ts b/frontend/src/lib/issue.ts new file mode 100644 index 000000000..f2de16f2d --- /dev/null +++ b/frontend/src/lib/issue.ts @@ -0,0 +1,48 @@ +import type { Issue } from "@/lib/api-types" +import { bracketedPaste } from "@/lib/terminal/bracketed-paste" + +// GitHub's own spelling of an issue reference, and the URL you get from copying +// one out of the browser. Both are what a person already has in hand when they +// decide to work on an issue — nothing here is a syntax lich invented. +// +// A bare number is deliberately not a reference: the field it is typed into is +// a branch name first, and git takes "128" as one. The "#" is the marker, so +// there is one field with one meaning rather than a mode to be in. +const ISSUE_HASH = /^#(\d+)$/ +const ISSUE_URL = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/issues\/(\d+)(?:[/?#].*)?$/ + +/** + * The issue number a typed worktree name refers to, or null when it names no + * issue — which is every ordinary branch name and every task typed in words. + */ +export function parseIssueRef(input: string): number | null { + const trimmed = input.trim() + const match = ISSUE_HASH.exec(trimmed) ?? ISSUE_URL.exec(trimmed) + if (!match) { + return null + } + const number = Number(match[1]) + return Number.isSafeInteger(number) && number > 0 ? number : null +} + +/** + * The name an issue's worktree is proposed under: the number first, so the + * branch sorts and greps by it, then the title. toBranchName does the rest — + * this only decides what it is given. + */ +export function issueName(issue: Issue): string { + return `${issue.number} ${issue.title}` +} + +/** + * What the issue's session finds at its prompt, ready to write into a PTY. + * + * One bracketed paste, so the newlines in a body are text rather than a run of + * Enters — and so the whole thing lands unsent, which is the rule the pull + * request handoff follows too: lich writes the prompt, the user sends it. + */ +export function issueBrief(issue: Issue): string { + const head = `GitHub issue #${issue.number} — ${issue.title}\n${issue.url}` + const body = issue.body.trim() + return bracketedPaste(body ? `${head}\n\n${body}` : head) +} diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index 376e92f58..fab22e85e 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -21,6 +21,7 @@ import type { DraftReviewComment, Attachment, DropItem, + Issue, MergeMethod, PatchNotes as PatchNotesData, PluginStatus, @@ -315,6 +316,9 @@ export const ProjectService = { base, baseIsRemote, ]), + /** One GitHub issue, for the New worktree dialog: its title names the branch, + * its body is handed to the session. A pull request's number is refused. */ + Issue: (path: string, number: number) => call("project.Issue", [path, number]), /** Check a pull request's head branch out into its own worktree; rejects a fork PR. */ CreateWorktreeFromPR: (projectPath: string, projectID: string, number: number) => call("project.CreateWorktreeFromPR", [projectPath, projectID, number]), diff --git a/internal/project/gherror.go b/internal/project/gherror.go index 013436276..60f90a095 100644 --- a/internal/project/gherror.go +++ b/internal/project/gherror.go @@ -34,6 +34,11 @@ var ghFailures = []toolFailure{ "Choose the account that can see it in Settings → Version Control.", }, {"could not resolve to a pullrequest", "GitHub has no pull request with that number."}, + // What `gh issue view` says for a number GitHub has never issued. Its + // wording names both kinds because GitHub numbers them in one sequence, but + // only the issue lookup can reach it — the pull request flows address a + // number gh already listed. + {"resolve to an issue or pull request", "GitHub has no issue with that number."}, { "can not approve your own pull request", "GitHub does not let an account approve its own pull request — this one was opened by the " + diff --git a/internal/project/issue.go b/internal/project/issue.go new file mode 100644 index 000000000..2f93a5aff --- /dev/null +++ b/internal/project/issue.go @@ -0,0 +1,70 @@ +package project + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// An issue read is one round-trip like a pull request read, and shares its +// budget; the alias only spells the call site honestly. +const issueReadTimeout = prReadTimeout + +// maxIssueBody caps the issue text handed to a session. A body is prose, but a +// template-heavy one carries logs and stack traces whole, and this is pasted +// into a terminal prompt — past a few pages it is scrollback, not context. The +// issue's URL travels with it, so the cut costs nothing but a click. +const maxIssueBody = 4000 + +// Issue is the little of a GitHub issue the New worktree dialog needs: what to +// name the branch after, and what to tell the session it opens. +type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` +} + +// Issue resolves one GitHub issue through gh, as the account the project +// selected. The title names the branch the dialog is about to create, and the +// body is what that worktree's session finds at its prompt. +// +// A pull request is refused rather than answered. GitHub numbers issues and +// pull requests in one sequence and its API calls them all issues, so +// `gh issue view ` answers for either — and taking the pull request would +// branch off the base under its title instead of checking its head branch out, +// which is a different flow entirely (CreateWorktreeFromPR). The URL is the +// only field in the answer that tells the two apart. +func (s *Service) Issue(path string, number int) (Issue, error) { + if number <= 0 { + return Issue{}, fmt.Errorf("%d is not an issue number.", number) + } + // The selector is an int, so it can never reach gh's argument slot as a + // "-"-prefixed value that would parse as a flag — prSelector's reasoning. + out, err := s.gh(issueReadTimeout, path, + "issue", "view", strconv.Itoa(number), "--json", "number,title,body,url") + if err != nil { + return Issue{}, err + } + var issue Issue + if err := json.Unmarshal(out, &issue); err != nil { + return Issue{}, errUnreadableAnswer(err) + } + if strings.Contains(issue.URL, "/pull/") { + return Issue{}, fmt.Errorf("#%d is a pull request, not an issue — open it from the Pulls screen.", number) + } + issue.Body = truncateRunes(issue.Body, maxIssueBody) + return issue, nil +} + +// truncateRunes cuts text to at most limit runes, marking the cut so whoever +// reads it knows there is more. Runes rather than bytes: a cut through a +// codepoint is a mojibake tail, and issue bodies are not ASCII. +func truncateRunes(text string, limit int) string { + runes := []rune(text) + if len(runes) <= limit { + return text + } + return strings.TrimRight(string(runes[:limit]), " \t\n") + "\n\n[…]" +} diff --git a/internal/project/issue_test.go b/internal/project/issue_test.go new file mode 100644 index 000000000..a99b442e7 --- /dev/null +++ b/internal/project/issue_test.go @@ -0,0 +1,116 @@ +package project + +import ( + "errors" + "strings" + "testing" +) + +func TestIssue(t *testing.T) { + t.Run("decodes and asks gh for the number", func(t *testing.T) { + gh := &fakeGH{out: []byte(`{"number":381,"title":"Sandbox backend","body":"why","url":"https://github.com/o/l/issues/381"}`)} + issue, err := withGH(gh).Issue("/repo", 381) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if issue.Number != 381 || issue.Title != "Sandbox backend" || issue.Body != "why" { + t.Errorf("decoded %+v", issue) + } + want := []string{"issue", "view", "381", "--json", "number,title,body,url"} + if strings.Join(gh.args, " ") != strings.Join(want, " ") { + t.Errorf("gh args = %v, want %v", gh.args, want) + } + if gh.dir != "/repo" { + t.Errorf("gh ran in %q, want /repo", gh.dir) + } + }) + + t.Run("a pull request is refused, not answered", func(t *testing.T) { + // GitHub numbers issues and pull requests in one sequence, so gh answers + // `issue view` with a pull request. Taking it would branch off the base + // under its title instead of checking out its head. + gh := &fakeGH{out: []byte(`{"number":453,"title":"feat: open a project","url":"https://github.com/o/l/pull/453"}`)} + _, err := withGH(gh).Issue("/repo", 453) + if err == nil { + t.Fatal("a pull request was accepted as an issue") + } + if !strings.Contains(err.Error(), "pull request") { + t.Errorf("message does not say what it is: %v", err) + } + }) + + t.Run("a number gh cannot be asked for is refused before the call", func(t *testing.T) { + gh := &fakeGH{} + if _, err := withGH(gh).Issue("/repo", 0); err == nil { + t.Fatal("zero was accepted as an issue number") + } + if gh.calls != 0 { + t.Errorf("gh was called %d times for a number that is not one", gh.calls) + } + }) + + t.Run("gh's failure travels", func(t *testing.T) { + gh := &fakeGH{err: errors.New("gh said no")} + if _, err := withGH(gh).Issue("/repo", 7); err == nil { + t.Fatal("a failed gh call answered an issue") + } + }) + + t.Run("an answer that cannot be decoded is not an issue", func(t *testing.T) { + gh := &fakeGH{out: []byte(`{"number":`)} + if _, err := withGH(gh).Issue("/repo", 7); err == nil { + t.Fatal("half a JSON object decoded") + } + }) + + t.Run("a long body is cut", func(t *testing.T) { + gh := &fakeGH{out: []byte(`{"number":7,"url":"https://github.com/o/l/issues/7","body":"` + + strings.Repeat("a", maxIssueBody+50) + `"}`)} + issue, err := withGH(gh).Issue("/repo", 7) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.HasSuffix(issue.Body, "[…]") { + t.Errorf("a cut body does not say it was cut: %q", issue.Body[len(issue.Body)-10:]) + } + if len([]rune(issue.Body)) <= maxIssueBody { + t.Errorf("body is %d runes, want the cap plus its marker", len([]rune(issue.Body))) + } + }) +} + +func TestTruncateRunes(t *testing.T) { + // The cap is pinned as a literal and crossed by one, rather than derived + // from the constant: a test that follows the constant proves nothing about + // where the boundary is. + const limit = 5 + tests := []struct { + name, text, want string + }{ + {"under the cap", "abcd", "abcd"}, + {"exactly the cap", "abcde", "abcde"}, + {"one over", "abcdef", "abcde\n\n[…]"}, + {"empty", "", ""}, + // Multi-byte runes: cutting at 5 bytes would land inside a codepoint. + {"runes, not bytes", "áéíóúx", "áéíóú\n\n[…]"}, + {"trailing space before the cut is dropped", "abcd ef", "abcd\n\n[…]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := truncateRunes(tt.text, limit); got != tt.want { + t.Errorf("truncateRunes(%q, %d) = %q, want %q", tt.text, limit, got, tt.want) + } + }) + } +} + +// TestIssueNotFoundMessage pins the gh wording an unknown number produces onto +// the sentence the dialog shows. The literal is what gh answered in the field — +// a reword there is a fallback message on screen, never a wrong one. +func TestIssueNotFoundMessage(t *testing.T) { + const stderr = "GraphQL: Could not resolve to an issue or pull request with the number of 99999. (repository.issue)" + got := ghMessage(stderr, errors.New("exit status 1"), nil) + if got != "GitHub has no issue with that number." { + t.Errorf("ghMessage = %q", got) + } +}