diff --git a/README.md b/README.md index 4c4ef55..444891c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,37 @@ -# Ship πŸš€ +# Ship -A CLI that runs agents sequentially. +Ship turns labeled GitHub issues into a pull request by driving **your** local coding agent through a fixed sequence of phases. -Confirm a queue from ship-labeled GitHub issues, then spin agents through an Implement β†’ Review loop for each of the tickets and open a pull request. +It is not an agent itself. You bring Cursor Agent, Claude Code, Codex, or pi. Ship queues the tickets, starts a fresh agent for each phase, and opens the PR in whatever checkout you already have. **Status:** v0.1.0. +## What β€œagents in a loop” means + +One **Run** of `ship` processes a confirmed queue of tickets **one after another**, and for each ticket it starts **two fresh agent sessions** (Implement, then Review). When the queue is done, Ship starts **one more** agent for the Final phase which reviews the branch and opens a PR. + +``` +Run (`ship`) +β”œβ”€β”€ pick ordered tickets labeled `ship` +β”œβ”€β”€ for each ticket (one Iteration): +β”‚ β”œβ”€β”€ Implement β†’ fresh agent: write the work, must commit +β”‚ └── Review β†’ fresh agent: one pass over those commits +└── Final β†’ fresh agent: branch review, green bar, open PR +``` + +Each phase is a **new** agent process with a built-in prompt. Implement commits the ticket; Review may fix or leave the commits alone; Final must open a pull request. + +It is recommended to run ship in a seperate worktree. I personnaly use [treehouse](https://github.com/kunchenguid/treehouse) to do manage them. + +## Prerequisites + +- Authenticated [`gh`](https://cli.github.com/) +- One of: Cursor Agent (`agent`), `pi`, `codex`, or `claude` on your `PATH` +- A git checkout of the GitHub repo whose issues you want to ship + ## Install -#### Linux/MacOS +#### Linux / macOS ```bash curl -fsSL https://raw.githubusercontent.com/maxBRT/ship-cli/main/install.sh | bash @@ -16,13 +39,15 @@ curl -fsSL https://raw.githubusercontent.com/maxBRT/ship-cli/main/install.sh | b #### Windows -[Releases](https://github.com/maxBRT/ship-cli/releases) page for manual download. +Download a binary from [Releases](https://github.com/maxBRT/ship-cli/releases). +## Update -## Prerequisites +```bash +ship update +``` -- Authenticated [`gh`](https://cli.github.com/) -- Any of `cursor`, `codex`, `claude caude`, `pi` CLI installed +Downloads the latest GitHub Release, verifies checksums, and replaces the running binary in place. Check what you have with `ship --version`. ## Quickstart @@ -32,7 +57,29 @@ gh issue edit --add-label "ship" ship ``` -Run `ship --help` for flags and commands. +On first run, Ship creates `.ship/config.yaml` (or run `ship init`). Pick tickets in the picker, then leave the agent phases running. When Final succeeds, Ship prints the PR URL. + +## Configuration + +Settings live in `.ship/config.yaml`. Create one with `ship init`, or let the first `ship` run write defaults. + +Precedence: YAML β†’ flags. + +```yaml +branch: "" # empty β†’ generate ship/ +agent: cursor # cursor | pi | codex | claude +model: "" # optional model for the Agent +max_iterations: 10 # max tickets (Implement+Review) before Final +timeout: 20m # per-Phase timeout (Go duration) +``` + +One-off overrides: + +```bash +ship --agent pi --model gpt-5 --max-iterations 3 --branch ship/my-feature --timeout 30m +``` + +Run `ship --help` for flags and commands (`init`, `update`, `--version`). ## Install from source diff --git a/cmd/ship/main_test.go b/cmd/ship/main_test.go index 34b33e9..079ff7d 100644 --- a/cmd/ship/main_test.go +++ b/cmd/ship/main_test.go @@ -149,7 +149,6 @@ func TestMain_helpDocumentsDomainLanguage(t *testing.T) { func TestMain_invalidFlagExitsNonZero(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: "" -feature: "" agent: cursor model: "" max_iterations: 10 @@ -168,7 +167,6 @@ timeout: 20m func TestMain_invalidTimeoutExitsNonZero(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: "" -feature: "" agent: cursor model: "" max_iterations: 10 @@ -187,7 +185,6 @@ timeout: 20m func TestMain_missingAgentBinaryExitsBeforeRun(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: "" -feature: "" agent: cursor model: "" max_iterations: 10 diff --git a/internal/run/config.go b/internal/run/config.go index 1b33638..b503d03 100644 --- a/internal/run/config.go +++ b/internal/run/config.go @@ -11,7 +11,6 @@ import ( // Config holds the Run configuration parsed from .ship/config.yaml and flags. type Config struct { Branch string - Feature string Agent string Model string MaxIterations int @@ -75,7 +74,6 @@ func defaultConfig() Config { func newFlagSet(cfg *Config) *flag.FlagSet { fs := flag.NewFlagSet("ship", flag.ContinueOnError) fs.StringVar(&cfg.Branch, "branch", cfg.Branch, "git branch for the Run (empty means generate ship/)") - fs.StringVar(&cfg.Feature, "feature", cfg.Feature, "optional extra filter label for ship Tickets") fs.StringVar(&cfg.Agent, "agent", cfg.Agent, "Agent kind for every Phase (cursor, pi, codex, claude)") fs.StringVar(&cfg.Model, "model", cfg.Model, "optional model for the Agent") fs.IntVar(&cfg.MaxIterations, "max-iterations", cfg.MaxIterations, "max Iterations (one Ticket each) before Final") diff --git a/internal/run/config_test.go b/internal/run/config_test.go index c10a86e..885f9e7 100644 --- a/internal/run/config_test.go +++ b/internal/run/config_test.go @@ -22,7 +22,6 @@ func writeShipYAML(t *testing.T, dir, content string) { } const fullYAML = `branch: "" -feature: "" agent: cursor model: "" max_iterations: 10 @@ -41,9 +40,6 @@ func TestParseConfig_yamlDefaults(t *testing.T) { if cfg.Branch != "" { t.Errorf("Branch = %q, want empty", cfg.Branch) } - if cfg.Feature != "" { - t.Errorf("Feature = %q, want empty", cfg.Feature) - } if cfg.Agent != "cursor" { t.Errorf("Agent = %q, want %q", cfg.Agent, "cursor") } @@ -61,7 +57,6 @@ func TestParseConfig_yamlDefaults(t *testing.T) { func TestParseConfig_yamlOverridesBuiltInDefaults(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: feat/widget -feature: widget agent: pi model: composer max_iterations: 3 @@ -76,9 +71,6 @@ timeout: 5m if cfg.Branch != "feat/widget" { t.Errorf("Branch = %q, want feat/widget", cfg.Branch) } - if cfg.Feature != "widget" { - t.Errorf("Feature = %q, want widget", cfg.Feature) - } if cfg.Agent != "pi" { t.Errorf("Agent = %q, want pi", cfg.Agent) } @@ -96,7 +88,6 @@ timeout: 5m func TestParseConfig_flagsOverrideYAML(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: from-yaml -feature: yaml-feature agent: cursor model: yaml-model max_iterations: 7 @@ -104,7 +95,6 @@ timeout: 2m `) args := []string{ "--branch", "from-flag", - "--feature", "flag-feature", "--agent", "claude", "--model", "flag-model", "--max-iterations", "4", @@ -118,9 +108,6 @@ timeout: 2m if cfg.Branch != "from-flag" { t.Errorf("Branch = %q, want from-flag", cfg.Branch) } - if cfg.Feature != "flag-feature" { - t.Errorf("Feature = %q, want flag-feature", cfg.Feature) - } if cfg.Agent != "claude" { t.Errorf("Agent = %q, want claude", cfg.Agent) } @@ -138,7 +125,6 @@ timeout: 2m func TestParseConfig_rejectsUnknownAgentKind(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: "" -feature: "" agent: opencode model: "" max_iterations: 10 @@ -157,7 +143,6 @@ timeout: 20m func TestParseConfig_rejectsLegacyBinaryAgentWithMigrationHint(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: "" -feature: "" agent: agent model: "" max_iterations: 10 @@ -177,7 +162,6 @@ timeout: 20m func TestParseConfig_rejectsAbsolutePathAgentWithMigrationHint(t *testing.T) { dir := t.TempDir() writeShipYAML(t, dir, `branch: "" -feature: "" agent: /usr/local/bin/agent model: "" max_iterations: 10 diff --git a/internal/run/orchestrator.go b/internal/run/orchestrator.go index 408658e..c7d8ae5 100644 --- a/internal/run/orchestrator.go +++ b/internal/run/orchestrator.go @@ -58,7 +58,7 @@ func (r Orchestrator) Run(ctx context.Context) error { return fmt.Errorf("ensure tracker labels: %w", err) } - ready, err := r.Tickets.ListReady(ctx, r.Config.Feature) + ready, err := r.Tickets.ListReady(ctx) if err != nil { return fmt.Errorf("list ship Tickets: %w", err) } diff --git a/internal/run/orchestrator_test.go b/internal/run/orchestrator_test.go index eaa2d7b..8aab0f3 100644 --- a/internal/run/orchestrator_test.go +++ b/internal/run/orchestrator_test.go @@ -1379,7 +1379,7 @@ func (f *fakeTickets) EnsureLabels(context.Context) error { return f.ensureErr } -func (f *fakeTickets) ListReady(context.Context, string) ([]ticket.Ticket, error) { +func (f *fakeTickets) ListReady(context.Context) ([]ticket.Ticket, error) { out := make([]ticket.Ticket, len(f.ready)) copy(out, f.ready) return out, nil diff --git a/internal/run/shipfile.go b/internal/run/shipfile.go index 636d66d..08fa4e4 100644 --- a/internal/run/shipfile.go +++ b/internal/run/shipfile.go @@ -50,12 +50,11 @@ func (i Init) Config(dir string) (created bool, err error) { cfg.Agent = kind } content := fmt.Sprintf(`branch: %q -feature: %q agent: %s model: %q max_iterations: %d timeout: %s -`, cfg.Branch, cfg.Feature, cfg.Agent, cfg.Model, cfg.MaxIterations, formatDuration(cfg.Timeout)) +`, cfg.Branch, cfg.Agent, cfg.Model, cfg.MaxIterations, formatDuration(cfg.Timeout)) if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return false, err } @@ -139,7 +138,6 @@ func LoadConfig(dir string) (Config, error) { return Config{ Branch: *raw.Branch, - Feature: *raw.Feature, Agent: *raw.Agent, Model: *raw.Model, MaxIterations: *raw.MaxIterations, @@ -149,7 +147,6 @@ func LoadConfig(dir string) (Config, error) { type shipYAML struct { Branch *string `yaml:"branch"` - Feature *string `yaml:"feature"` Agent *string `yaml:"agent"` Model *string `yaml:"model"` MaxIterations *int `yaml:"max_iterations"` @@ -161,9 +158,6 @@ func (s shipYAML) missingKeys() []string { if s.Branch == nil { missing = append(missing, "branch") } - if s.Feature == nil { - missing = append(missing, "feature") - } if s.Agent == nil { missing = append(missing, "agent") } diff --git a/internal/run/shipfile_test.go b/internal/run/shipfile_test.go index d63204e..7ec5592 100644 --- a/internal/run/shipfile_test.go +++ b/internal/run/shipfile_test.go @@ -135,7 +135,6 @@ func TestInitConfig_writesFilledDefaults(t *testing.T) { got := string(data) for _, want := range []string{ "branch:", - "feature:", "agent: cursor", "model:", "max_iterations: 10", @@ -173,7 +172,6 @@ func TestInitConfig_alreadyExistsDoesNotOverwrite(t *testing.T) { func TestLoadConfig_readsAllFields(t *testing.T) { dir := t.TempDir() content := `branch: feat/widget -feature: widget agent: pi model: composer max_iterations: 3 @@ -188,9 +186,6 @@ timeout: 5m if cfg.Branch != "feat/widget" { t.Errorf("Branch = %q, want feat/widget", cfg.Branch) } - if cfg.Feature != "widget" { - t.Errorf("Feature = %q, want widget", cfg.Feature) - } if cfg.Agent != "pi" { t.Errorf("Agent = %q, want pi", cfg.Agent) } @@ -208,7 +203,6 @@ timeout: 5m func TestLoadConfig_missingKeyErrors(t *testing.T) { dir := t.TempDir() content := `branch: "" -feature: "" agent: cursor model: "" timeout: 10m @@ -227,11 +221,11 @@ timeout: 10m func TestLoadConfig_ignoresUnknownKeys(t *testing.T) { dir := t.TempDir() content := `branch: "" -feature: "" agent: cursor model: "" max_iterations: 10 timeout: 10m +feature: leftover extra_thing: ignored ` writeShipConfig(t, dir, content) diff --git a/internal/ticket/github.go b/internal/ticket/github.go index a4c0dc1..707b094 100644 --- a/internal/ticket/github.go +++ b/internal/ticket/github.go @@ -46,7 +46,6 @@ query($owner: String!, $name: String!) { number title createdAt - labels(first: 20) { nodes { name } } } } } @@ -70,11 +69,6 @@ type gqlIssue struct { Number int `json:"number"` Title string `json:"title"` CreatedAt time.Time `json:"createdAt"` - Labels struct { - Nodes []struct { - Name string `json:"name"` - } `json:"nodes"` - } `json:"labels"` } type orderedTicket struct { @@ -84,7 +78,7 @@ type orderedTicket struct { // ListReady returns ship-labeled Tickets in stable default order: // ascending issue number, then oldest created date. -func (g *GitHub) ListReady(ctx context.Context, feature string) ([]Ticket, error) { +func (g *GitHub) ListReady(ctx context.Context) ([]Ticket, error) { execGH := g.exec() repoOut, err := execGH(ctx, "repo", "view", "--json", "nameWithOwner") @@ -116,9 +110,6 @@ func (g *GitHub) ListReady(ctx context.Context, feature string) ([]Ticket, error ordered := make([]orderedTicket, 0, len(resp.Data.Repository.Issues.Nodes)) for _, issue := range resp.Data.Repository.Issues.Nodes { - if feature != "" && !hasLabel(issue, feature) { - continue - } ordered = append(ordered, orderedTicket{ Ticket: Ticket{ Number: issue.Number, @@ -142,15 +133,6 @@ func (g *GitHub) ListReady(ctx context.Context, feature string) ([]Ticket, error return outTickets, nil } -func hasLabel(issue gqlIssue, want string) bool { - for _, l := range issue.Labels.Nodes { - if l.Name == want { - return true - } - } - return false -} - // requiredLabels are the tracker labels Ship needs for triage and ship queue // membership (candidates for the Run picker). var requiredLabels = []struct { diff --git a/internal/ticket/github_test.go b/internal/ticket/github_test.go index e29870f..0cda688 100644 --- a/internal/ticket/github_test.go +++ b/internal/ticket/github_test.go @@ -42,7 +42,7 @@ func TestListReady_ordersByNumberThenCreatedDate(t *testing.T) { }`, })} - got, err := gh.ListReady(context.Background(), "") + got, err := gh.ListReady(context.Background()) if err != nil { t.Fatalf("ListReady: %v", err) } @@ -58,48 +58,6 @@ func TestListReady_ordersByNumberThenCreatedDate(t *testing.T) { } } -func TestListReady_filtersByFeatureLabel(t *testing.T) { - gh := &ticket.GitHub{Exec: scriptedExec(t, map[string]string{ - "repo view --json nameWithOwner": `{"nameWithOwner":"maxBRT/ship-cli"}`, - "api graphql": `{ - "data": { - "repository": { - "issues": { - "nodes": [ - { - "number": 10, - "title": "other feature", - "createdAt": "2026-01-01T00:00:00Z", - "labels": {"nodes": [ - {"name": "ship"}, - {"name": "feat-other"} - ]} - }, - { - "number": 11, - "title": "wanted feature", - "createdAt": "2026-02-01T00:00:00Z", - "labels": {"nodes": [ - {"name": "ship"}, - {"name": "feat-ship"} - ]} - } - ] - } - } - } - }`, - })} - - got, err := gh.ListReady(context.Background(), "feat-ship") - if err != nil { - t.Fatalf("ListReady: %v", err) - } - if len(got) != 1 || got[0].Number != 11 { - t.Fatalf("ListReady = %v, want only #11", numbers(got)) - } -} - func TestEnsureLabels_createsMissingShipLabels(t *testing.T) { var creates [][]string gh := &ticket.GitHub{Exec: func(ctx context.Context, args ...string) ([]byte, error) { diff --git a/internal/ticket/port.go b/internal/ticket/port.go index 31aa447..1ecc131 100644 --- a/internal/ticket/port.go +++ b/internal/ticket/port.go @@ -22,9 +22,9 @@ type Port interface { // missing from the tracker, so queue stamping and Done can succeed. EnsureLabels(ctx context.Context) error - // ListReady returns Tickets labeled ship, optionally also matching - // feature, in stable default order (issue number / created date). - ListReady(ctx context.Context, feature string) ([]Ticket, error) + // ListReady returns Tickets labeled ship in stable default order + // (issue number / created date). + ListReady(ctx context.Context) ([]Ticket, error) // Stamp adds the ship label to the confirmed queue without removing // Ready for Agent.