diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f9f189a..0ddf72861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 waits for one rather than being dropped — including one that came due while lich was closed, which lands when lich is next up. +- **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 6868ed149..f6d5ab1b4 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -396,9 +396,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 1f796186b..07efd8acb 100644 --- a/frontend/src/components/sidebar/SessionSidebar.tsx +++ b/frontend/src/components/sidebar/SessionSidebar.tsx @@ -34,6 +34,7 @@ import { import { useProjects } from "@/providers/projects" import { queueFork } from "@/lib/terminal/fork-queue" 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 { @@ -287,6 +288,7 @@ 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) { @@ -299,6 +301,15 @@ export function SessionSidebar({ onCollapse }: SessionSidebarProps) { // A fresh checkout is the one moment the project's setup script runs; // reopening an existing worktree never queues it. queueSetup(opened) + // 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(opened, 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 803a21ad2..8a6c10d50 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 { WorktreeScriptRows } from "@/components/sidebar/WorktreeScriptRows" @@ -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 @@ -133,6 +142,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("") @@ -145,9 +156,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 @@ -156,6 +180,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 @@ -236,7 +290,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) @@ -282,15 +342,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 fd1c85ee6..1defa5f5e 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 4356c54a9..1bced1303 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -22,6 +22,7 @@ import type { DraftReviewComment, Attachment, DropItem, + Issue, MergeMethod, PatchNotes as PatchNotesData, PluginStatus, @@ -330,6 +331,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) + } +}