Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4f7c3b5
feat(linear): start worktrees from Linear tickets
hayke102 Aug 18, 2026
9b1e105
fix(linear): name the real command when auth is missing
hayke102 Aug 18, 2026
3d64d9e
refactor(linear): read tickets from the API instead of the CLI
hayke102 Aug 18, 2026
b1b20a8
feat(linear): enable browser sign-in
hayke102 Aug 18, 2026
185be11
fix(linear): store the credential when a terminal is attached
hayke102 Aug 18, 2026
b9d8965
fix(linear): deliver the ticket lookup's replies to the dialog
hayke102 Aug 18, 2026
98a52ef
test(ui): rename the connect dialog's key helper
hayke102 Aug 18, 2026
ba583c4
Merge master into feat/linear-tickets
hayke102 Aug 18, 2026
12100a8
fix(linear): say when the credential is for the wrong workspace
hayke102 Aug 18, 2026
94d47ee
fix(linear): keep the wrong-workspace note inside the box
hayke102 Aug 18, 2026
b6554c8
feat(linear): add a tickets tab to the command palette
hayke102 Aug 18, 2026
3d9e5d8
feat(ui): group the tickets tab and show what's already in fleet
hayke102 Aug 18, 2026
b926b1a
fix(ui): make the palette's selection and ticket rows readable
hayke102 Aug 18, 2026
0bbb115
feat(linear): order tickets by priority and show it
hayke102 Aug 18, 2026
b68700d
fix(ui): give priority its own column, and colour it
hayke102 Aug 18, 2026
13ad920
fix(ui): carry ticket state and priority in the mixed tab
hayke102 Aug 18, 2026
b134d4b
feat(ui): give fleet a design system, and fix the palette's focus amb…
hayke102 Aug 19, 2026
015e7ef
Merge branch 'feat/design-system' into feat/linear-tickets
hayke102 Aug 19, 2026
845613a
Merge origin/master into feat/linear-tickets
hayke102 Aug 19, 2026
b96c5dc
fix(linear): stop sending the Linear credential to any host an issue …
hayke102 Aug 19, 2026
9673279
fix(linear): don't report a prior run's state move as this run's
hayke102 Aug 19, 2026
cde7f35
Merge remote-tracking branch 'origin/master' into feat/linear-tickets
hayke102 Aug 19, 2026
0b9c6ff
feat(linear): show every priority as a gauge, not just the top two
hayke102 Aug 19, 2026
8d525b7
fix(linear): name the worktree after the ticket, not after its identi…
hayke102 Aug 19, 2026
1c386ef
fix(linear): say the branch was named from the ticket, not just that …
hayke102 Aug 19, 2026
33241d7
fix(linear): stop the keychain silently truncating the credential at …
hayke102 Aug 19, 2026
a1c7cc3
fix(ui): spell out what a ticket's worktree is doing, and close the d…
hayke102 Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions changelog/unreleased/linear-tickets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
type: added
highlight: true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
---

**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.
139 changes: 125 additions & 14 deletions cmd/fleet/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
Expand All @@ -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"
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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")
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
102 changes: 102 additions & 0 deletions cmd/fleet/worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
},
},
Comment thread
hayke102 marked this conversation as resolved.
{
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)
}
}
10 changes: 7 additions & 3 deletions internal/analytics/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading