diff --git a/CLAUDE.md b/CLAUDE.md index 6f4ccf51..f7eca295 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 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 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 @@ -114,7 +118,7 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - 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) @@ -137,6 +141,30 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - 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. 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. **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 `:` 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. +- 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. +- 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. *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 confirmation line reads **`✓ named from BRZ-3217 · `**, 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. +- **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) - PR info includes unresolved review thread count via GitHub GraphQL API @@ -150,13 +178,14 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa - 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, account_usage_style) +- 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, account_usage_style) - 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 - 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. 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 <id>` / `codex fork <id>` / `opencode --session <id>` / `opencode --session <id> --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 new file mode 100644 index 00000000..f9b9595f --- /dev/null +++ b/changelog/unreleased/linear-tickets.md @@ -0,0 +1,12 @@ +--- +type: added +highlight: true +--- + +**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.go b/cmd/fleet/worktree.go index b94e3e8c..f9051591 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 <branch> [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, 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,29 @@ 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{ + WorktreePath: info.Path, + Identifier: ticket.Identifier, + 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)\n", res.RelDir, describeTicketFiles(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 +614,12 @@ 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) +} diff --git a/cmd/fleet/worktree_test.go b/cmd/fleet/worktree_test.go index a9c16591..77c2942e 100644 --- a/cmd/fleet/worktree_test.go +++ b/cmd/fleet/worktree_test.go @@ -194,3 +194,105 @@ 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", + }, + { + // 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", + 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 7f2f2368..6d46ef48 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,6 +86,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. The // values are claudeaccount.Strategies, resolved by claudeaccount.ParseStrategy // via GetAccountStrategy — deliberately not restated here, since a stale copy @@ -500,6 +509,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/<ID>/, 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/<name>, but "info" is on git's shared-path list, so git reads +// the COMMON .git/info/exclude. Writing to .git/worktrees/<name>/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/api.go b/internal/linear/api.go new file mode 100644 index 00000000..8a3450c9 --- /dev/null +++ b/internal/linear/api.go @@ -0,0 +1,684 @@ +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} + +// 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 +// 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 } } + } +}` + +// 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 priority state { name type position } } + } + } +}` + +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"` + Priority int `json:"priority"` + State *struct { + 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, Priority: i.Priority} + if i.State != nil { + t.StateName, t.StateType = i.State.Name, i.State.Type + } + 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 +} + +// 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": + 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 + // 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 + } + 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)) + for i := range nodes { + if nodes[i].Identifier == "" { + continue + } + tickets = append(tickets, nodes[i].ticket()) + } + return tickets, 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) + + // 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 +} + +// 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/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/identifier.go b/internal/linear/identifier.go new file mode 100644 index 00000000..b8816157 --- /dev/null +++ b/internal/linear/identifier.go @@ -0,0 +1,149 @@ +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 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 +// 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 string, teamKeys []string) string { + if branch == "" || len(teamKeys) == 0 { + return "" + } + seg := branch + if i := strings.LastIndex(seg, "/"); i >= 0 { + seg = seg[i+1:] + } + m := identifierRe.FindStringSubmatch(seg) + if m == nil || !matchesTeam(m[1], teamKeys) { + return "" + } + return strings.ToUpper(m[1]) + "-" + m[2] +} + +// 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 string, teamKeys []string) (string, bool) { + t := strings.TrimSpace(text) + if t == "" || len(teamKeys) == 0 { + return "", false + } + m := identifierRe.FindStringSubmatch(t) + if m == nil || len(m[0]) != len(t) || !matchesTeam(m[1], teamKeys) { + 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 +// (<repo>-<branch>) 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 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. +// +// 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..2b83a48c --- /dev/null +++ b/internal/linear/images.go @@ -0,0 +1,214 @@ +package linear + +import ( + "context" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "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 Linear's own markdown, not an arbitrary document. +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 an issue's markdown. +type imageRef struct { + alt string + target string +} + +// allowedImageURL reports whether a markdown image target is one fleet may +// fetch: https, and hosted on Linear's own upload host. +// +// 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 !allowedImageURL(target) { + 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. 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 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 { + 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 +} + +// 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) { + // 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 + } + + ctx, cancel := context.WithTimeout(ctx, imageFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", 0, err + } + req.Header.Set("Authorization", cred.authHeader()) + + // 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 + } + 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/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/linear.go b/internal/linear/linear.go new file mode 100644 index 00000000..f1490cae --- /dev/null +++ b/internal/linear/linear.go @@ -0,0 +1,154 @@ +// Package linear reads Linear issues through Linear's own GraphQL API. +// +// Design rules, in the order they matter: +// +// - 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" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/brizzai/fleet/internal/workspace" +) + +// Timeouts. Each is sized against a measured cost. +const ( + // 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 + + // 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 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 + + // imageFetchTimeout bounds one uploads.linear.app download, 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 + + // 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 ( + // 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") + + // 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 projection of an issue that fleet's UI needs. +type Ticket struct { + Identifier string // "BRZ-3182"; empty means the payload was unusable + 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 + // ordered or compared, while the type can. + StateType string +} + +// Ok reports whether the payload carried enough to be worth acting on. +func (t Ticket) Ok() bool { return t.Identifier != "" } + +// 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. +// +// 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]*)["']`) + +// TeamKeys returns the Linear team keys this repo tracks, or nil if it tracks +// none. +// +// 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 nil + } + m := teamIDRe.FindSubmatch(data) + if m == nil { + 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 + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, limit)) +} diff --git a/internal/linear/linear_test.go b/internal/linear/linear_test.go new file mode 100644 index 00000000..dcab651e --- /dev/null +++ b/internal/linear/linear_test.go @@ -0,0 +1,598 @@ +package linear + +import ( + "go/ast" + "go/parser" + "go/token" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// 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.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) + } + 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 + } + 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 + }) + } + if scanned == 0 { + t.Fatal("scanned no files — this guard is vacuous") + } +} + +// 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) + } + + // 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") + } +} + +// 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}, + } + 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") + } +} + +// 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) { + brz := []string{"BRZ"} + cases := []struct { + 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", []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, ""}, + + {"brz-3182-x", nil, ""}, + {"", brz, ""}, + } + for _, c := range cases { + 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 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}, + {"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}, + {"BRZ-3182", nil, "", false}, + } + for _, c := range cases { + got, ok := LooksLikeIdentifier(c.text, c.teams) + if got != c.want || ok != c.ok { + t.Errorf("LooksLikeIdentifier(%q, %v) = (%q, %v), want (%q, %v)", + c.text, c.teams, 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(`<!DOCTYPE html><html><body>401 Unauthorized</body></html>`) + + // 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) + } +} + +// 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](/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) != 1 { + t.Fatalf("found %d images, want only the remote one: %+v", len(refs), refs) + } + if refs[0].target != "https://uploads.linear.app/a/b/c" { + t.Errorf("kept the wrong link: %q", refs[0].target) + } +} + +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, linearConfigFile), []byte(toml), 0644); err != nil { + t.Fatal(err) + } + got := TeamKeys(dir) + if len(got) != 1 || got[0] != "BRZ" { + t.Fatalf("TeamKeys = %v, want [BRZ]", got) + } + + // 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) + } +} + +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") + } +} + +// 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() +} + +// 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 { + 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), + 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) + } +} + +// 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/linear/materialize.go b/internal/linear/materialize.go new file mode 100644 index 00000000..b9b39f4d --- /dev/null +++ b/internal/linear/materialize.go @@ -0,0 +1,405 @@ +package linear + +import ( + "context" + "encoding/json" + "errors" + "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: <worktree>/.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 + StateMoved string // resulting state name; "" if not attempted or failed +} + +// Opts configures one materialization. +type Opts struct { + WorktreePath string + Identifier string + + // 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" + MovedTo string `json:"moved_to,omitempty"` +} + +// 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 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 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) + + // 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 { + // 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 + } + + res.Ticket = issue.ticket() + 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) + } + + 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, issue, 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"} + + // 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. + // + // 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 + 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, m.MovedTo = "done", name + res.StateMoved = name + } + } + writeMeta(dir, m) + + analytics.Track(analytics.EventLinearTicketMaterialized, map[string]any{ + "images": images, + "dropped": dropped, + }) + return res, nil +} + +// 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 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 + } + name, size, err := fetchImage(ctx, ref.target, imgDir, ref.alt, i+1) + if err != nil { + 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 +} + +// 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, "images: %d\n", r.Images) + b.WriteString("---\n\n") + b.WriteString("<!-- Read-only snapshot fetched by fleet. The live ticket may have moved on since fetched_at. -->\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) + } + 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 "" +} + +// 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 { + 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..83d6d7a7 --- /dev/null +++ b/internal/linear/materialize_e2e_test.go @@ -0,0 +1,77 @@ +package linear + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestMaterializeEndToEnd exercises the real Linear API against a real issue. +// +// Opt-in, because it needs a credential and network: +// +// 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 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=<ISSUE-ID> to run") + } + if os.Getenv(APIKeyEnvVar) == "" { + t.Skipf("set %s to run", APIKeyEnvVar) + } + + wt := t.TempDir() + res, err := Materialize(context.Background(), Opts{ + 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", res.Identifier, res.Images, res.ImagesDropped) + + 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") + } + + // 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) { + t.Errorf("ticket.md kept a remote image link: %s", ref.target) + } + + if res.Images == 0 { + return + } + entries, err := os.ReadDir(filepath.Join(res.Dir, imagesDir)) + if err != nil { + t.Fatalf("images/ missing though %d were reported: %v", res.Images, err) + } + for _, e := range entries { + 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()) + } + info, err := e.Info() + if err != nil || info.Size() == 0 { + t.Errorf("%s is empty", e.Name()) + } + } +} diff --git a/internal/linear/materialize_test.go b/internal/linear/materialize_test.go new file mode 100644 index 00000000..145195f0 --- /dev/null +++ b/internal/linear/materialize_test.go @@ -0,0 +1,120 @@ +package linear + +import ( + "os" + "path/filepath" + "strings" + "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) + } + } +} + +// 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() +} diff --git a/internal/linear/oauth.go b/internal/linear/oauth.go new file mode 100644 index 00000000..de9abc0e --- /dev/null +++ b/internal/linear/oauth.go @@ -0,0 +1,316 @@ +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 + +// 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. +// +// 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. +// +// 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" + +// 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, `<!doctype html><meta charset="utf-8"><title>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 new file mode 100644 index 00000000..f35015e7 --- /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 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 := "" + 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/store.go b/internal/linear/store.go new file mode 100644 index 00000000..fc9740b8 --- /dev/null +++ b/internal/linear/store.go @@ -0,0 +1,342 @@ +package linear + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "syscall" + "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) { 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(): + return readKeychainChunks(service) + case useSecretTool(): + out, err := runQuiet(storeTimeout, nil, "secret-tool", "lookup", "service", service) + 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 +} + +// 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 ":" 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: +// `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 { 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(): + return writeKeychainChunks(service, data) + 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(): + // 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 + } + 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 + // 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..e0833d22 --- /dev/null +++ b/internal/linear/store_pty_test.go @@ -0,0 +1,164 @@ +package linear + +import ( + "bytes" + "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") +} + +// 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)) + } +} 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 7ff7cedc..76e362b3 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" @@ -449,6 +450,12 @@ type Home struct { accounts *claudeaccount.Store 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 @@ -601,6 +608,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. @@ -716,9 +724,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(). @@ -830,6 +862,7 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) { h.allowedAccounts.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) @@ -1520,7 +1553,15 @@ 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.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: @@ -1590,18 +1631,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(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: @@ -1642,12 +1694,75 @@ 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 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. + // 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 + // 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 + 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. + 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 { @@ -2143,6 +2258,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() || @@ -2187,6 +2303,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() } @@ -2510,6 +2629,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 @@ -2937,7 +3060,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) @@ -3243,6 +3376,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) } @@ -3591,6 +3734,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) @@ -7012,7 +7158,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 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) + } + return workspaceListMsg{ + workspaces: workspaces, provider: provider, repoPath: repoPath, + defaultBranch: defaultBranch, originKey: originKey, linearTeams: linearTeams, err: err, + } } } @@ -8089,6 +8246,8 @@ 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: "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"}, @@ -8181,6 +8340,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) @@ -8290,6 +8451,21 @@ 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 + // 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/command_palette.go b/internal/ui/command_palette.go index c3bc698f..60b76c54 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). @@ -26,6 +28,7 @@ const ( PaletteKindCommand PaletteItemKind = iota PaletteKindRepo PaletteKindWorktree + PaletteKindTicket ) // commandPaletteMsg is sent when the user selects an item from the palette. @@ -42,6 +45,25 @@ 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 + + // 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 + // 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. @@ -51,6 +73,7 @@ const ( PaletteTabAll PaletteTab = iota PaletteTabActions PaletteTabPlaces + PaletteTabTickets ) var paletteTabOrder = []struct { @@ -60,6 +83,7 @@ var paletteTabOrder = []struct { {PaletteTabAll, "all"}, {PaletteTabActions, "actions"}, {PaletteTabPlaces, "repos/worktrees"}, + {PaletteTabTickets, "tickets"}, } // CommandPaletteDialog shows a fuzzy-filterable list of palette items. @@ -73,13 +97,20 @@ 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 + // "you're done", which an empty list alone cannot say. + ticketsLoaded bool } 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 @@ -87,7 +118,7 @@ const paletteMaxVisible = 14 // NewCommandPaletteDialog creates a new command palette dialog. func NewCommandPaletteDialog() *CommandPaletteDialog { fi := NewTextInput() - fi.Placeholder = "search commands, repos, worktrees..." + fi.Placeholder = "search commands, repos, worktrees, tickets..." fi.CharLimit = 64 fi.SetWidth(40) @@ -103,6 +134,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 +143,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 +183,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 } @@ -184,17 +251,54 @@ 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 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 := "" + switch { + case d.activeTab == PaletteTabTickets: + sec = it.Group + 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}) } } 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 != "" { + // 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: tabItems[m.Index], + PaletteItem: it, score: m.Score, matchedIndexes: m.MatchedIndexes, }) @@ -306,19 +410,49 @@ 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 - const maxNameCap = 22 + // 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, badgeCol = paletteLeadWidth, 0 + } + 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 + // 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 d.activeTab == PaletteTabTickets { + r = runeLen(ticketsTabRight(it.PaletteItem, filtering)) + } + 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 @@ -327,6 +461,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 @@ -341,12 +482,35 @@ 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 = SelectionMarker(true).Render("▸ ") } - badge := renderKindBadge(it.Kind) + // 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 := "" + if badgeCol > 0 { + badge = renderKindBadge(it.Kind) + " " + } // Haystack is `Name + " " + Detail` (for places) or just `Name` (commands). // Map matched haystack indexes back to the Name and Detail substrings. @@ -359,7 +523,7 @@ func (d *CommandPaletteDialog) View() string { namePad := strings.Repeat(" ", nameCol-runeLen(rawName)) var name string if selected { - name = SelectionPill(true).Render(rawName + namePad) + name = SelectionBand().Render(rawName + namePad) } else { name = highlightMatches(rawName, nameIdx) + namePad } @@ -370,11 +534,36 @@ 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) - 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. + b.WriteString(SelectionBandSecondary().Render(" " + pad(right, rightBudget))) + b.WriteString("\n") + continue + } + 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)) @@ -400,7 +589,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) { @@ -452,12 +641,168 @@ 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 } return lipgloss.NewStyle().Foreground(col).Render(label) } +// 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. +// +// 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 "" + } + 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(glyph) + " " + StatusStyle(it.SessionStatus).Render(StatusWord(it.SessionStatus)) +} + +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 +} + +// 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 styles the gauge for the tickets tab's lead column. +// +// 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(g, paletteLeadWidth)) + case 2: + return lipgloss.NewStyle().Foreground(ColorOrange).Render(pad(g, paletteLeadWidth)) + } + return DimStyle.Render(pad(g, paletteLeadWidth)) +} + +// 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 "" @@ -474,10 +819,36 @@ func truncRunes(s string, maxRunes int) string { func runeLen(s string) int { return len([]rune(s)) } +// 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 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 { + return priorityGauge(priority) +} + 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/connect_linear.go b/internal/ui/connect_linear.go new file mode 100644 index 00000000..66020318 --- /dev/null +++ b/internal/ui/connect_linear.go @@ -0,0 +1,444 @@ +package ui + +import ( + "context" + "errors" + "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. +// +// 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 + persistErr error +} + +// 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 + 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 { + ti := NewTextInput() + 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, d.persistErr = nil, 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() + // 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 +// 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 = 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 + 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 + 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 + d.input.Focus() + return d, textinput.Blink + } + return d, nil + } +} + +// linearDisconnectedMsg closes the loop after a disconnect. +// +// 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. +// +// 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 + // Not an error path: the credential is already live. See linearConnectedMsg. + persistErr := linear.SetCredential(cred) + return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia(), persistErr: persistErr} + } +} + +// 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} + } + ws, err := linear.VerifyCredential(ctx, cred) + if err != nil { + return linearConnectFailedMsg{err: err} + } + cred.Workspace = ws.Name + persistErr := linear.SetCredential(cred) + return linearConnectedMsg{workspace: ws, via: linear.ConnectedVia(), persistErr: persistErr} + } +} + +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 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, ", "))) + 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: + // 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", "connects the workspace your browser is in"}, + {"Paste an API key", "works over SSH; scoped to that key"}, + } + 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..a715ebd3 --- /dev/null +++ b/internal/ui/connect_linear_test.go @@ -0,0 +1,222 @@ +package ui + +import ( + "errors" + "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 pressConnect(d *ConnectLinearDialog, key string) *ConnectLinearDialog { + out, _ := d.Update(tea.KeyPressMsg{Code: connectKeyCode(key), Text: key}) + return out +} + +// connectKeyCode maps the handful of keys these tests send. Named keys carry no +// Text, which is exactly how the dialog distinguishes them. +func connectKeyCode(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 = pressConnect(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 = pressConnect(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 = pressConnect(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) + } +} + +// 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) + } +} diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go index 79003694..f51f5ac6 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/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..00487238 --- /dev/null +++ b/internal/ui/palette_tickets.go @@ -0,0 +1,229 @@ +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. + 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 + } + // 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 { + // 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: + 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) + + // 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 { + // 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, + 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. + 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: name + " " + t.StateName, + } + if s := byTicket[t.Identifier]; s != nil { + it.HasSession = true + 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 + // the width the title needed. One fact, one place. + } + items = append(items, it) + } + 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..05cfd007 --- /dev/null +++ b/internal/ui/palette_tickets_test.go @@ -0,0 +1,554 @@ +package ui + +import ( + "path/filepath" + "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" + "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 !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 !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 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. + 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") + } +} + +// 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 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. + 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) + } + } + // 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) + } + // 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) + } +} + +// 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 + } + } + } + // 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 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-4"} { + if strings.ContainsAny(rows[id], "▰▱") { + t.Errorf("%s has no priority set and must carry no gauge: %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") + } + } + } +} + +// 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. The LEAD +// column is now rendered only where every row is a ticket; the mark itself +// still reaches the mixed tab, on the right, where it costs no alignment. +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 gauge:\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) + } + // The mark may appear on the RIGHT — TestMixedTabCarriesStateAndPriorityOnTheRight + // covers why: outside the tickets tab there is no header for the state and no + // 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") { + t.Errorf("the mixed tab must not carry a priority LEAD 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) + } +} + +// 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") + } +} + +// 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) + } + } +} + +// 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/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/styles.go b/internal/ui/styles.go index 957bdd9b..525fe898 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -81,6 +81,11 @@ var ( // Panel title style (cyan/blue like agent-deck). PanelTitleStyle lipgloss.Style + // PaletteSectionStyle labels a group of palette rows (a Linear state). Dim + // and bold so it reads as structure rather than as another row competing + // with the ones under it. + PaletteSectionStyle lipgloss.Style + // Header bar style — no background fill so the top bar reads as part of // the canvas, not a separate ribbon. HeaderBarStyle lipgloss.Style @@ -161,6 +166,8 @@ func ApplyPalette(p Palette) { ToolClaudeStyle = lipgloss.NewStyle().Foreground(ColorOrange) PanelTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorBlue) + + PaletteSectionStyle = lipgloss.NewStyle().Foreground(ColorTextDim).Bold(true) HeaderBarStyle = lipgloss.NewStyle().Padding(0, 1) HelpKeyStyle = lipgloss.NewStyle().Foreground(ColorAccent).Bold(true) @@ -523,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)) } diff --git a/internal/ui/ticket.go b/internal/ui/ticket.go new file mode 100644 index 00000000..ee0bf2e4 --- /dev/null +++ b/internal/ui/ticket.go @@ -0,0 +1,173 @@ +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 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 + +// 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(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{ + WorktreePath: worktreePath, + Identifier: t.Identifier, + 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 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. +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.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) + if len(teamKeys) == 0 { + if teamKeys = linear.TeamKeys(repoRoot); len(teamKeys) == 0 { + return "", nil + } + } + + branch := "" + if info, ok := h.gitInfo()[repoRoot]; ok && info != nil { + branch = info.Branch + } + 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, repoRoot), teamKeys) + } + 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{ + 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". +// 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(repoRoot) + 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: + // 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) + case res == nil: + return "" + } + + line := fmt.Sprintf("%s materialized", res.Identifier) + if 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/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/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 519c6e28..e5204b3a 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 7d811050..a06d9ba4 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 + // 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 @@ -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 --- + + // 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. + 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 + // 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 string, linearTeams []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.linearTeams = linearTeams + 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 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 len(d.linearTeams) > 0 { + b.WriteString(DimStyle.Render(" " + strings.Join(d.linearTeams, " "))) + } 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..a17c116e --- /dev/null +++ b/internal/ui/workspace_picker_ticket.go @@ -0,0 +1,424 @@ +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: 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. +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 len(d.linearTeams) > 0 && !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 round trip. +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 + } + + teams := d.linearTeams + gen := m.gen + + 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 + } + d.ticketPending = true + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), ticketLookupTimeout) + defer cancel() + t, err := linear.Fetch(ctx, 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, 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 { + // "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" + 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" + 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 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) + } + return + } + + d.tickets = m.tickets + 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. +// 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 { + 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" + } + // 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 +} + +// 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) +} + +// 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) { + 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: + // "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), "…"))) + 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(SelectionMarker(true).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..c7e06a44 --- /dev/null +++ b/internal/ui/workspace_picker_ticket_test.go @@ -0,0 +1,452 @@ +package ui + +import ( + "github.com/charmbracelet/x/ansi" + "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", []string{"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.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" }}, + {"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(teams []string) string { + d := NewWorktreeDialog() + d.SetSize(120, 40) + d.Show(nil, nil, nil, "/repo", "master", teams) + d.newBranchInput.SetValue("my-experiment") + return d.View() + } + if mk(nil) == "" { + t.Fatal("empty render") + } + 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) + } + 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) + } +} + +// 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) + } + }) + } +} + +// 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) + } + }) + } +} diff --git a/internal/ui/worktree_ticket_e2e_test.go b/internal/ui/worktree_ticket_e2e_test.go new file mode 100644 index 00000000..ac529484 --- /dev/null +++ b/internal/ui/worktree_ticket_e2e_test.go @@ -0,0 +1,170 @@ +package ui + +import ( + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "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) + } +} + +// 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) + } +} + +// 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. +// +// 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) { + 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"}}, + } + 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 new file mode 100644 index 00000000..a14bba1f --- /dev/null +++ b/internal/ui/worktree_ticket_routing_test.go @@ -0,0 +1,128 @@ +package ui + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "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) + } + } +} + +// 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 9134f330..6028cc64 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,25 @@ 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. +// +// 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. +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 +81,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 +119,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);