diff --git a/cmd/envctl/main.go b/cmd/envctl/main.go index 28f17cd..dc9e013 100644 --- a/cmd/envctl/main.go +++ b/cmd/envctl/main.go @@ -68,7 +68,7 @@ func root() *cobra.Command { pf.StringVar(&g.stateDir, "state-dir", "", "workflow state directory (default: user state directory)") cmd.AddCommand(upCmd(g), downCmd(g), stopCmd(g), startCmd(g), statusCmd(g), renderCmd(g), - logsCmd(g), execCmd(g), listCmd(g), initCmd(g), hookCmd(g), agentCmd(g), envCmd(g), runCmd(g), daemonCmd(g), tuiCmd(g), webCmd(g), themeCmd(g), mcpCmd(g), doctorCmd(g)) + logsCmd(g), execCmd(g), listCmd(g), initCmd(g), hookCmd(g), agentCmd(g), envCmd(g), runCmd(g), trackerCmd(g), daemonCmd(g), tuiCmd(g), webCmd(g), themeCmd(g), mcpCmd(g), doctorCmd(g)) return cmd } diff --git a/cmd/envctl/tracker.go b/cmd/envctl/tracker.go new file mode 100644 index 0000000..e043fec --- /dev/null +++ b/cmd/envctl/tracker.go @@ -0,0 +1,350 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/sam-bretz/envctl/internal/manifest" + "github.com/sam-bretz/envctl/internal/tracker" + "github.com/sam-bretz/envctl/internal/tui" + "github.com/sam-bretz/envctl/internal/workflow" +) + +const trackerReference = "https://sam-bretz.github.io/envctl/config-reference/#tracker-version-2-experimental" + +func trackerCmd(g *globals) *cobra.Command { + c := &cobra.Command{Use: "tracker", Short: "Connect an issue tracker so linked issues follow a run"} + c.AddCommand(&cobra.Command{ + Use: "setup", + Short: "Map workflow events to your tracker's statuses, choosing from the real ones", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // A wizard reading answers from a pipe would block forever on a + // question nobody can see, so refuse and say where to write it. + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return fmt.Errorf("envctl tracker setup is interactive and needs a terminal; to configure a tracker without one, write tracker: in envctl.yaml by hand (see %s)", trackerReference) + } + return trackerSetup(cmd.Context(), setupIO{ + in: bufio.NewReader(cmd.InOrStdin()), out: cmd.OutOrStdout(), style: wizardStyle(), + root: workflowRoot(cmd.Context(), g.dir), open: tracker.NewDirectory, + }) + }, + }) + return c +} + +// setupIO holds everything the wizard touches, so a test can drive the whole +// conversation with a scripted reader and a fake tracker. +type setupIO struct { + in *bufio.Reader + out io.Writer + style style + root string + open func(workflow.TrackerConfig) (tracker.Directory, error) +} + +type style struct{ title, accent, muted, ok lipgloss.Style } + +// wizardStyle uses the dashboard's resolved theme, so the wizard looks like +// the rest of envctl rather than like a plain prompt. +func wizardStyle() style { + settings := tui.Settings{} + if path, err := tui.ConfigPath(); err == nil { + if loaded, err := tui.LoadSettings(path); err == nil { + settings = loaded + } + } + p := settings.Theme.Resolve(lipgloss.HasDarkBackground(os.Stdin, os.Stdout)) + return style{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.Accent)), + accent: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Accent)), + muted: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Muted)), + ok: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Success)), + } +} + +func trackerSetup(ctx context.Context, s setupIO) error { + config, err := workflow.Load(s.root) + if err != nil { + return fmt.Errorf("%s: %w", s.root, err) + } + fmt.Fprintln(s.out, s.style.title.Render("Connect an issue tracker")) + fmt.Fprintln(s.out, s.style.muted.Render("Nothing changes on any issue while this runs; every tracker call is a read.")) + + provider, err := s.choose("Tracker", []string{"linear"}, 0) + if err != nil { + return err + } + current := "" + if config.Tracker != nil { + current = strings.TrimPrefix(config.Tracker.Credential, "file:") + } + path, err := s.ask("Path to a file holding the API key (the key itself is never asked for or shown)", current) + if err != nil { + return err + } + credential, err := absolute(path) + if err != nil { + return err + } + cfg := workflow.TrackerConfig{Provider: provider, Credential: "file:" + credential} + dir, err := s.open(cfg) + if err != nil { + return err + } + if _, err = dir.Viewer(ctx); err != nil { + return fmt.Errorf("the credential in %s did not work: %w", credential, err) + } + fmt.Fprintln(s.out, s.style.ok.Render("✓ Credential works")) + + teams, err := dir.Teams(ctx) + if err != nil { + return err + } + if len(teams) == 0 { + return errors.New("this credential can see no teams") + } + names := make([]string, len(teams)) + for i, t := range teams { + names[i] = fmt.Sprintf("%s (%s)", t.Name, t.Key) + } + picked, err := s.choose("Team", names, 0) + if err != nil { + return err + } + team := teams[indexOf(names, picked)] + states, err := dir.States(ctx, team.ID) + if err != nil { + return err + } + + mapping := map[string]workflow.TrackerStatusMapping{} + for _, name := range config.WorkflowNames() { + selected, err := config.SelectWorkflow(name) + if err != nil { + return err + } + stages := tracker.Stages(selected.Workflow) + m, err := s.mapWorkflow(name, states, stages) + if err != nil { + return err + } + if len(tracker.Preview(m, stages)) > 0 || !empty(m) { + mapping[name] = m + } + } + + block, err := tracker.TrackerBlock(provider, credential, mapping) + if err != nil { + return err + } + fmt.Fprintln(s.out, "\n"+s.style.title.Render("This will be written to envctl.yaml")) + fmt.Fprintln(s.out, block) + for _, name := range config.WorkflowNames() { + selected, _ := config.SelectWorkflow(name) + lines := tracker.Preview(mapping[name], tracker.Stages(selected.Workflow)) + fmt.Fprintln(s.out, "\n"+s.style.accent.Render("A typical "+name+" run would move the issue:")) + if len(lines) == 0 { + fmt.Fprintln(s.out, s.style.muted.Render(" never")) + } + for _, line := range lines { + fmt.Fprintln(s.out, " "+line) + } + } + yes, err := s.ask("\nWrite it? (y/N)", "n") + if err != nil { + return err + } + if !strings.EqualFold(strings.TrimSpace(yes), "y") { + fmt.Fprintln(s.out, s.style.muted.Render("Nothing written.")) + return nil + } + file := filepath.Join(s.root, manifest.FileName) + raw, err := os.ReadFile(file) + if err != nil { + return err + } + updated, err := tracker.SpliceTracker(raw, block) + if err != nil { + return err + } + info, err := os.Stat(file) + if err != nil { + return err + } + if err = os.WriteFile(file, updated, info.Mode().Perm()); err != nil { + return err + } + // Prove the file still loads before saying it worked. + if _, err = workflow.Load(s.root); err != nil { + return fmt.Errorf("wrote %s but it no longer loads: %w", file, err) + } + fmt.Fprintln(s.out, s.style.ok.Render("✓ Written to "+file)) + return nil +} + +// mapWorkflow asks for one workflow's mapping, with the suggested defaults +// preselected so pressing Enter throughout gives a working result. +func (s setupIO) mapWorkflow(name string, states []tracker.State, stages []tracker.Stage) (workflow.TrackerStatusMapping, error) { + fmt.Fprintln(s.out, "\n"+s.style.title.Render("Workflow: "+name)) + m := tracker.DefaultMapping(states, stages) + choices := append([]string{"(leave unmapped)"}, stateNames(states)...) + ask := func(event string, current *string) error { + picked, err := s.choose(event, choices, max(0, indexOf(choices, *current))) + if err != nil { + return err + } + if picked == choices[0] { + *current = "" + } else { + *current = picked + } + return nil + } + for _, q := range []struct { + event string + field *string + }{ + {"Run starts", &m.RunStarted}, + {"Approved", &m.Approved}, + {"Pull request published", &m.PRPublished}, + {"Needs attention", &m.NeedsAttention}, + {"Cancelled", &m.Cancelled}, + {"Rewound", &m.Rewound}, + } { + if err := ask(q.event, q.field); err != nil { + return m, err + } + } + for _, stage := range stages { + if !stage.Gated { + continue + } + value := m.AwaitingApproval[stage.ID] + if err := ask(stage.ID+" waits for approval", &value); err != nil { + return m, err + } + m.AwaitingApproval = setOrDelete(m.AwaitingApproval, stage.ID, value) + } + detail, err := s.ask("Also map each stage starting and being accepted? (y/N)", "n") + if err != nil { + return m, err + } + if strings.EqualFold(strings.TrimSpace(detail), "y") { + for _, stage := range stages { + started, accepted := m.StageStarted[stage.ID], m.StageAccepted[stage.ID] + if err = ask(stage.ID+" starts", &started); err != nil { + return m, err + } + if err = ask(stage.ID+" is accepted", &accepted); err != nil { + return m, err + } + m.StageStarted = setOrDelete(m.StageStarted, stage.ID, started) + m.StageAccepted = setOrDelete(m.StageAccepted, stage.ID, accepted) + } + } + return m, nil +} + +// choose shows a numbered list and returns the chosen entry. Choosing from a +// list rather than typing means a status name can never be misspelled. +func (s setupIO) choose(question string, options []string, preselected int) (string, error) { + fmt.Fprintln(s.out, s.style.accent.Render(question)) + for i, o := range options { + marker := " " + if i == preselected { + marker = s.style.ok.Render("› ") + } + fmt.Fprintf(s.out, "%s%d. %s\n", marker, i+1, o) + } + for { + answer, err := s.ask("Choose", strconv.Itoa(preselected+1)) + if err != nil { + return "", err + } + n, err := strconv.Atoi(strings.TrimSpace(answer)) + if err == nil && n >= 1 && n <= len(options) { + return options[n-1], nil + } + fmt.Fprintf(s.out, "Enter a number from 1 to %d.\n", len(options)) + } +} + +func (s setupIO) ask(question, fallback string) (string, error) { + if fallback != "" { + fmt.Fprintf(s.out, "%s %s: ", question, s.style.muted.Render("["+fallback+"]")) + } else { + fmt.Fprintf(s.out, "%s: ", question) + } + line, err := s.in.ReadString('\n') + if err != nil && !(errors.Is(err, io.EOF) && line != "") { + return "", fmt.Errorf("setup ended before it was answered: %w", err) + } + if answer := strings.TrimSpace(line); answer != "" { + return answer, nil + } + return fallback, nil +} + +func absolute(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", errors.New("a credential file path is required") + } + if rest, ok := strings.CutPrefix(path, "~/"); ok { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(home, rest) + } + return filepath.Abs(path) +} + +func stateNames(states []tracker.State) []string { + out := make([]string, len(states)) + for i, s := range states { + out[i] = s.Name + } + return out +} + +func indexOf(options []string, want string) int { + for i, o := range options { + if o == want { + return i + } + } + return -1 +} + +func setOrDelete(m map[string]string, key, value string) map[string]string { + if value == "" { + delete(m, key) + if len(m) == 0 { + return nil + } + return m + } + if m == nil { + m = map[string]string{} + } + m[key] = value + return m +} + +func empty(m workflow.TrackerStatusMapping) bool { + return m.RunStarted == "" && m.Approved == "" && m.PRPublished == "" && m.NeedsAttention == "" && + m.Cancelled == "" && m.Rewound == "" && len(m.StageStarted) == 0 && len(m.StageAccepted) == 0 && len(m.AwaitingApproval) == 0 +} diff --git a/cmd/envctl/tracker_test.go b/cmd/envctl/tracker_test.go index 6c143c0..c430da6 100644 --- a/cmd/envctl/tracker_test.go +++ b/cmd/envctl/tracker_test.go @@ -1,41 +1,119 @@ package main import ( + "bufio" "bytes" + "context" + "os" + "path/filepath" "strings" "testing" - "time" - - "github.com/spf13/cobra" + "github.com/sam-bretz/envctl/internal/tracker" "github.com/sam-bretz/envctl/internal/workflow" ) -func TestPrintRunShowsCaptainsLogCounts(t *testing.T) { - c, err := workflow.Parse([]byte("version: 2\nproject: demo\nrepositories: [{id: app, url: /source}]\nworkflow: {template: feature}\n")) - if err != nil { +type fakeDirectory struct{ viewed bool } + +func (f *fakeDirectory) Viewer(context.Context) (string, error) { f.viewed = true; return "me", nil } +func (f *fakeDirectory) Teams(context.Context) ([]tracker.Team, error) { + return []tracker.Team{{ID: "t1", Key: "ENG", Name: "Engineering"}}, nil +} +func (f *fakeDirectory) States(context.Context, string) ([]tracker.State, error) { + return []tracker.State{ + {Name: "Todo", Type: "unstarted"}, {Name: "In Progress", Type: "started"}, + {Name: "In Review", Type: "started"}, {Name: "Done", Type: "completed"}, {Name: "Canceled", Type: "canceled"}, + }, nil +} + +const trackerProject = "# keep this comment\nversion: 2\nproject: shop\nrepositories: [{id: app, url: /source}]\nworkflow: {template: feature}\n" + +const secret = "lin_api_THIS_MUST_NEVER_APPEAR" + +func runSetup(t *testing.T, answers string) (string, string, *fakeDirectory) { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "envctl.yaml"), []byte(trackerProject), 0o600); err != nil { t.Fatal(err) } - run, err := workflow.NewRun("demo", "objective", "dev", c, time.Now()) - if err != nil { + token := filepath.Join(t.TempDir(), "linear.token") + if err := os.WriteFile(token, []byte(secret), 0o600); err != nil { t.Fatal(err) } - cmd := &cobra.Command{} + fake := &fakeDirectory{} var out bytes.Buffer - cmd.SetOut(&out) - if err = printRun(cmd, &globals{}, run); err != nil { - t.Fatal(err) + var opened workflow.TrackerConfig + err := trackerSetup(context.Background(), setupIO{ + in: bufio.NewReader(strings.NewReader(strings.ReplaceAll(answers, "TOKEN", token))), + out: &out, + root: root, + open: func(cfg workflow.TrackerConfig) (tracker.Directory, error) { opened = cfg; return fake, nil }, + }) + if err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) } - if strings.Contains(out.String(), "captain's log") { - t.Fatal("captain's log line shown for a run with no tracker entries") + if opened.Credential != "file:"+token { + t.Fatalf("tracker opened with %q, want the file reference", opened.Credential) } + written, _ := os.ReadFile(filepath.Join(root, "envctl.yaml")) + return out.String(), string(written), fake +} - run.TrackerLog = []workflow.TrackerLogEntry{{Status: "pending"}, {Status: "pending"}, {Status: "failed"}} - out.Reset() - if err = printRun(cmd, &globals{}, run); err != nil { - t.Fatal(err) +func TestAcceptingEveryDefaultWritesAWorkingMapping(t *testing.T) { + // Tracker, credential path, team, then Enter through every question, + // then "y" to write. + answers := "\nTOKEN\n\n" + strings.Repeat("\n", 7) + "\n" + "y\n" + out, written, fake := runSetup(t, answers) + if !fake.viewed { + t.Fatal("the credential was never checked") + } + if !strings.HasPrefix(written, "# keep this comment\n") { + t.Fatalf("the file was rewritten rather than added to:\n%s", written) } - if !strings.Contains(out.String(), "captain's log: 2 pending, 1 failed") { - t.Fatalf("captain's log counts not shown:\n%s", out.String()) + for _, want := range []string{"run_started: In Progress", "pr_published: Done", "approved-change: In Review", "rewound: In Progress"} { + if !strings.Contains(written, want) { + t.Fatalf("missing %q in:\n%s", want, written) + } + } + if !strings.Contains(out, "run starts → In Progress") { + t.Fatalf("no transition preview shown:\n%s", out) + } +} + +func TestTheAPIKeyIsNeverWrittenOrShown(t *testing.T) { + answers := "\nTOKEN\n\n" + strings.Repeat("\n", 7) + "\n" + "y\n" + out, written, _ := runSetup(t, answers) + if strings.Contains(out, secret) { + t.Fatal("the API key was printed") + } + if strings.Contains(written, secret) { + t.Fatal("the API key was written to envctl.yaml") + } +} + +func TestDecliningWritesNothing(t *testing.T) { + answers := "\nTOKEN\n\n" + strings.Repeat("\n", 7) + "\n" + "n\n" + out, written, _ := runSetup(t, answers) + if written != trackerProject { + t.Fatalf("declining changed envctl.yaml:\n%s", written) + } + if !strings.Contains(out, "Nothing written") { + t.Fatalf("declining was not acknowledged:\n%s", out) + } +} + +func TestSetupRefusesWithoutATerminalInsteadOfHanging(t *testing.T) { + // go test's stdin is not a terminal, which is exactly the scripted or CI + // case the command must refuse. + g := &globals{dir: t.TempDir()} + cmd := trackerCmd(g) + cmd.SetArgs([]string{"setup"}) + cmd.SetIn(strings.NewReader("")) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "needs a terminal") || !strings.Contains(err.Error(), "config-reference") { + t.Fatalf("expected a refusal pointing to the config reference, got %v", err) } } diff --git a/docs/src/content/docs/config-reference.mdx b/docs/src/content/docs/config-reference.mdx index 0a3edca..ca415c8 100644 --- a/docs/src/content/docs/config-reference.mdx +++ b/docs/src/content/docs/config-reference.mdx @@ -253,6 +253,57 @@ The log posts one comment per event: | Rewound | The new revision, its target stage, and the reason or objective change. | | Needs attention | The cause, such as the token ceiling or a publication failure. | +### Moving the issue's status + +`tracker.mapping` moves the linked issue through your tracker's statuses as the run progresses, so the ticket stays in step without anyone updating it. It is optional; without it, statuses are never changed and only comments are posted. + +The fastest way to write it is the wizard, which reads your team's real statuses and suggests a mapping: + +```bash +envctl tracker setup +``` + +It asks for a credential **file path** — never the key itself, which it reads, checks, and never prints or writes — then your team, then a status for each event, chosen from a numbered list with sensible defaults already selected. It shows the YAML and the transitions a typical run would make before writing anything, and re-running it edits the existing mapping. It needs a terminal and refuses otherwise. + +A worked Linear example, for a team with the default board: + +```yaml +tracker: + provider: linear + credential: file:/Users/me/.config/envctl/linear.token + mapping: + default: + run_started: In Progress + awaiting_approval: + approved-change: In Review + pr_published: Done + cancelled: Canceled + rewound: In Progress + small: + run_started: In Progress + pr_published: Done +``` + +`mapping` is keyed by workflow name (`default` for the `workflow:` section), because named workflows need not share stages. Events: + +| Event | Fires when | Keyed by stage | +| --- | --- | --- | +| `run_started` | The revision starts preparing or running | no | +| `stage_started` | A stage starts | yes | +| `stage_accepted` | A stage's result is accepted | yes | +| `awaiting_approval` | A stage waits for a person | yes | +| `approved` | A result is approved | no | +| `pr_published` | The approved change opens its pull request | no | +| `needs_attention` | The run needs attention | no | +| `cancelled` | The run is cancelled | no | +| `rewound` | A rewind starts a new revision | no | + +An unknown event or stage name is rejected when the configuration loads. Status names are checked during Plan's readiness against your team's actual workflow states, without changing anything; an unknown status fails readiness and lists the valid ones. + +Each transition applies exactly once, in order, across coordinator restarts, using the same durable receipts as comments. A transition waiting to retry holds back the ones after it, so a brief outage cannot leave the issue at an earlier status than the run reached. A tracker outage or rejected transition never blocks or fails the workflow; `envctl run show` reports pending and failed updates. + +**Manual changes are overwritten.** If someone moves the issue by hand, the next mapped event moves it again: the mapping always applies, and the last write wins. This is not a free choice — "only move forward" would contradict `rewound`, since a rewind after `Done` has to be able to move the issue back to work. Leave an event unmapped if you would rather it never touch the status. + Link a specific run to an issue with `--task-ref` (a Linear issue URL or an identifier like `ENG-123`), the dashboard's `n` input (a second, optional prompt after the objective), or the `task_ref` argument of the MCP `envctl_create` tool. A run without a link posts nothing, even with a tracker configured. When a tracker is configured, Plan's readiness probes verify the credential, that the linked issue exists, and that the credential can comment on it, without posting anything. diff --git a/envctl b/envctl new file mode 100755 index 0000000..3c8281d Binary files /dev/null and b/envctl differ diff --git a/internal/daemon/api.go b/internal/daemon/api.go index ce8807a..a719469 100644 --- a/internal/daemon/api.go +++ b/internal/daemon/api.go @@ -187,7 +187,14 @@ func (s *Server) action(w http.ResponseWriter, r *http.Request) { run.Priority = req.Priority return nil case "cancel": - return run.Cancel(now) + // Cancel refuses on a finished run. Moving the issue to its + // cancelled status after a refusal would mark completed work as + // cancelled in the tracker while the run itself stays completed. + if err := run.Cancel(now); err != nil { + return err + } + run.AppendTrackerStatus(run.Current().Config.Tracker, workflow.TrackerEventCancelled, run.CurrentRevision, "", "", "", now) + return nil case "close": return run.Close(now) case "rewind": diff --git a/internal/daemon/api_test.go b/internal/daemon/api_test.go index de3738d..e6789ae 100644 --- a/internal/daemon/api_test.go +++ b/internal/daemon/api_test.go @@ -204,3 +204,33 @@ func TestServeSingleOwnerAndRestart(t *testing.T) { t.Fatal("coordinator failed to stop") } } + +func TestARefusedCancelNeverMovesTheIssueToCancelled(t *testing.T) { + // Cancel refuses on a finished run. If the cancelled status were enqueued + // regardless, the tracker would show completed work as cancelled while + // the run itself stayed completed. + c, store := testAPI(t) + r := create(t, c) + updated, err := store.Mutate(context.Background(), r.ID, r.Version, "finish", "fixture.completed", nil, func(run *workflow.Run) error { + run.TaskRef = "ENG-1" + run.Current().State = "completed" + run.Current().Config.Tracker = &workflow.TrackerConfig{Provider: "linear", Credential: "env:LINEAR_API_KEY", + Mapping: map[string]workflow.TrackerStatusMapping{workflow.DefaultWorkflow: {Cancelled: "Canceled"}}} + return nil + }) + if err != nil { + t.Fatal(err) + } + if _, err = c.Action(context.Background(), r.ID, ActionRequest{OperationID: "cancel-finished", ExpectedVersion: updated.Version, Revision: updated.CurrentRevision, Action: "cancel"}); err == nil { + t.Fatal("cancel accepted a completed run") + } + after, err := c.Get(context.Background(), r.ID) + if err != nil { + t.Fatal(err) + } + for _, entry := range after.TrackerLog { + if entry.Kind == workflow.TrackerEventCancelled { + t.Fatalf("a refused cancel still queued the issue to move to %q", entry.StatusName) + } + } +} diff --git a/internal/engine/attempt.go b/internal/engine/attempt.go index ff87f16..0f82ebe 100644 --- a/internal/engine/attempt.go +++ b/internal/engine/attempt.go @@ -197,11 +197,7 @@ func (e *Engine) reconcileAttempt(ctx context.Context, run *workflow.Run, rev *w return err } r.AppendTrackerLog(v.Config.Tracker, workflow.TrackerKindStageCompleted, v.ID, current.Node, current.ID, "", e.now()) - // The pull request is the run's visible outcome, so it is - // logged as its own entry rather than buried in the stage's. - if len(prs) > 0 { - r.AppendTrackerLog(v.Config.Tracker, workflow.TrackerKindPublished, v.ID, current.Node, current.ID, "", e.now()) - } + logPublication(r, v, current.Node, current.ID, prs, e.now()) return nil }) if err != nil && !errors.Is(err, workflow.ErrConflict) { @@ -226,3 +222,17 @@ func (e *Engine) reconcileAttempt(ctx context.Context, run *workflow.Run, rev *w } return false, nil } + +// logPublication records a change's pull requests in the tracker: a comment +// with the links, and the issue's move to its published status. The pull +// request is the run's visible outcome, so it is logged as its own entry +// rather than buried in the stage's. Both happen only when something was +// actually published, because moving an issue to done for a change that +// opened no pull request would be false. +func logPublication(r *workflow.Run, v *workflow.Revision, node, attempt string, prs map[string]string, now time.Time) { + if len(prs) == 0 { + return + } + r.AppendTrackerLog(v.Config.Tracker, workflow.TrackerKindPublished, v.ID, node, attempt, "", now) + r.AppendTrackerStatus(v.Config.Tracker, workflow.TrackerEventPRPublished, v.ID, node, attempt, "", now) +} diff --git a/internal/engine/attempt_test.go b/internal/engine/attempt_test.go new file mode 100644 index 0000000..c4d7b69 --- /dev/null +++ b/internal/engine/attempt_test.go @@ -0,0 +1,50 @@ +package engine + +import ( + "testing" + "time" + + "github.com/sam-bretz/envctl/internal/workflow" +) + +func TestTheIssueMovesToPublishedOnlyWhenAPullRequestWasOpened(t *testing.T) { + tracked := func(t *testing.T) (*workflow.Run, *workflow.Revision) { + t.Helper() + c, err := workflow.Parse([]byte("version: 2\nproject: demo\nrepositories: [{id: app, url: /source}]\nworkflow: {template: feature}\n")) + if err != nil { + t.Fatal(err) + } + r, err := workflow.NewRun("demo", "ship it", "dev", c, time.Now()) + if err != nil { + t.Fatal(err) + } + r.TaskRef = "ENG-1" + r.Current().Config.Tracker = &workflow.TrackerConfig{Provider: "linear", Credential: "env:LINEAR_API_KEY", + Mapping: map[string]workflow.TrackerStatusMapping{workflow.DefaultWorkflow: {PRPublished: "Done"}}} + return r, r.Current() + } + kinds := func(r *workflow.Run) map[string]bool { + out := map[string]bool{} + for _, e := range r.TrackerLog { + out[e.Kind] = true + } + return out + } + + // A change that opened nothing must not tell the tracker it is done. + r, v := tracked(t) + logPublication(r, v, "approved-change", "attempt_1", nil, time.Now()) + if got := kinds(r); got[workflow.TrackerEventPRPublished] || got[workflow.TrackerKindPublished] { + t.Fatalf("an unpublished change moved the issue or logged a pull request: %v", got) + } + + // The positive control: a real pull request does both, comment first. + r, v = tracked(t) + logPublication(r, v, "approved-change", "attempt_1", map[string]string{"app": "https://github.com/o/r/pull/7"}, time.Now()) + if got := kinds(r); !got[workflow.TrackerEventPRPublished] || !got[workflow.TrackerKindPublished] { + t.Fatalf("a published change did not both comment and move the issue: %v", got) + } + if r.TrackerLog[0].Kind != workflow.TrackerKindPublished || r.TrackerLog[1].StatusName != "Done" { + t.Fatalf("expected the comment then the move to Done, got %+v", r.TrackerLog) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 1e82912..6cd4a56 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -159,6 +159,7 @@ func (e *Engine) Tick(ctx context.Context) error { } v.State = "preparing" v.Runtime = workflow.RuntimeState{ID: "envctl-" + strings.ReplaceAll(v.ID, "_", "-"), Provider: v.Config.Runtime.Provider, Location: "local", State: "preparing"} + r.AppendTrackerStatus(v.Config.Tracker, workflow.TrackerEventRunStarted, v.ID, "", "", "", e.now()) return nil }) if err != nil { @@ -445,6 +446,7 @@ func (e *Engine) Reconcile(ctx context.Context, id, revision string) error { Provider: v.Config.Runtime.Provider, Location: "local", State: "preparing", }} } + r.AppendTrackerStatus(v.Config.Tracker, workflow.TrackerEventStageStarted, v.ID, node, attempt.ID, "", e.now()) return nil }) // Capacity exhaustion is normal; Begin enforces it atomically. diff --git a/internal/tracker/deliverer.go b/internal/tracker/deliverer.go index d8a2c28..7d791d2 100644 --- a/internal/tracker/deliverer.go +++ b/internal/tracker/deliverer.go @@ -112,6 +112,7 @@ func (d *Deliverer) deliverRun(ctx context.Context, run workflow.Run) { type trackerReceipt struct { CommentID string `json:"comment_id"` BodyDigest string `json:"body_digest"` + StatusName string `json:"status_name,omitempty"` } func (d *Deliverer) receiptPath(runID, entryID string) string { @@ -130,6 +131,10 @@ func (d *Deliverer) deliverOne(ctx context.Context, run workflow.Run, entry work d.recordFailure(ctx, run.ID, entry.ID, err) return } + if entry.StatusName != "" { + deliverStatus(ctx, d, client, run, entry) + return + } body, err := Render(&run, entry) if err != nil { d.recordFailure(ctx, run.ID, entry.ID, err) @@ -162,6 +167,34 @@ func (d *Deliverer) deliverOne(ctx context.Context, run workflow.Run, entry work d.markPosted(ctx, run.ID, entry.ID, commentID) } +func deliverStatus(ctx context.Context, d *Deliverer, client Tracker, run workflow.Run, entry workflow.TrackerLogEntry) { + updater, ok := client.(StatusUpdater) + if !ok { + d.recordFailure(ctx, run.ID, entry.ID, errors.New("tracker provider does not support status transitions")) + return + } + receiptPath := d.receiptPath(run.ID, entry.ID) + var receipt trackerReceipt + if load(receiptPath, &receipt) == nil && receipt.StatusName == entry.StatusName { + d.markPosted(ctx, run.ID, entry.ID, "") + return + } + ref, err := ParseRef(run.TaskRef) + if err != nil { + d.recordFailure(ctx, run.ID, entry.ID, err) + return + } + if err := updater.SetStatus(ctx, ref, entry.StatusName); err != nil { + d.recordFailure(ctx, run.ID, entry.ID, err) + return + } + if err := freeze(receiptPath, trackerReceipt{StatusName: entry.StatusName}); err != nil { + d.recordFailure(ctx, run.ID, entry.ID, err) + return + } + d.markPosted(ctx, run.ID, entry.ID, "") +} + // bound caps a comment body, applied after redaction (so truncation never // exposes half a secret) and before the marker is appended by Comment (so // the marker used for dedup is never truncated away). diff --git a/internal/tracker/deliverer_test.go b/internal/tracker/deliverer_test.go index 16536f1..68a0bcf 100644 --- a/internal/tracker/deliverer_test.go +++ b/internal/tracker/deliverer_test.go @@ -15,10 +15,11 @@ import ( ) type fakeTracker struct { - mu sync.Mutex - fail int // remaining calls that return an error before succeeding - calls int - id string + mu sync.Mutex + fail int // remaining calls that return an error before succeeding + calls int + id string + statuses []string } func (f *fakeTracker) Comment(ctx context.Context, ref, marker, body string) (string, error) { @@ -41,6 +42,23 @@ func (f *fakeTracker) count() int { return f.calls } +func (f *fakeTracker) SetStatus(ctx context.Context, ref, statusName string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail > 0 { + f.fail-- + return errors.New("temporary linear outage") + } + f.statuses = append(f.statuses, statusName) + return nil +} + +func (f *fakeTracker) statusCalls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.statuses...) +} + func delivererFixture(t *testing.T) (*runstore.Store, *workflow.Run) { t.Helper() t.Setenv("LINEAR_TOKEN_DELIVERER_TEST", "secret-key") @@ -202,3 +220,106 @@ func TestDelivererFailureNeverMutatesRunStateBeyondTheEntry(t *testing.T) { t.Fatalf("unexpected entry after failure: %+v", after.TrackerLog[0]) } } + +func TestDelivererAppliesMappedStatusesOnceAndInOrder(t *testing.T) { + store, run := delivererFixture(t) + run.Current().Config.Tracker.Mapping = map[string]workflow.TrackerStatusMapping{ + "default": {RunStarted: "In Progress", NeedsAttention: "Needs Attention"}, + } + run.TrackerLog = []workflow.TrackerLogEntry{ + {ID: "tstatus_1", Kind: workflow.TrackerEventRunStarted, Revision: run.CurrentRevision, Status: "pending", StatusName: "In Progress"}, + {ID: "tstatus_2", Kind: workflow.TrackerEventNeedsAttention, Revision: run.CurrentRevision, Status: "pending", StatusName: "Needs Attention"}, + } + ctx := context.Background() + run, err := store.Create(ctx, "op-create", nil, run) + if err != nil { + t.Fatal(err) + } + fake := &fakeTracker{} + d := &Deliverer{Store: store, newTracker: func(workflow.TrackerConfig) (Tracker, error) { return fake, nil }} + + d.tick(ctx) + if got := fake.statusCalls(); len(got) != 1 || got[0] != "In Progress" { + t.Fatalf("first status delivery = %v", got) + } + d.tick(ctx) + if got := fake.statusCalls(); len(got) != 2 || got[1] != "Needs Attention" { + t.Fatalf("ordered status delivery = %v", got) + } + d.tick(ctx) + if got := fake.statusCalls(); len(got) != 2 { + t.Fatalf("delivered statuses were repeated: %v", got) + } +} + +func TestDelivererStatusReceiptReconcilesAfterRestart(t *testing.T) { + store, run := delivererFixture(t) + run.TrackerLog = []workflow.TrackerLogEntry{{ID: "tstatus_1", Kind: workflow.TrackerEventRunStarted, Revision: run.CurrentRevision, Status: "pending", StatusName: "In Progress"}} + ctx := context.Background() + run, err := store.Create(ctx, "op-create", nil, run) + if err != nil { + t.Fatal(err) + } + fake := &fakeTracker{} + d := &Deliverer{Store: store, newTracker: func(workflow.TrackerConfig) (Tracker, error) { return fake, nil }} + path := d.receiptPath(run.ID, "tstatus_1") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(trackerReceipt{StatusName: "In Progress"}) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + + d.tick(ctx) + if len(fake.statusCalls()) != 0 { + t.Fatal("status receipt did not prevent a repeated remote transition") + } + got, err := store.Get(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + if got.TrackerLog[0].Status != "posted" { + t.Fatalf("status receipt did not close the outbox entry: %+v", got.TrackerLog[0]) + } +} + +func TestDelivererDoesNothingForAnUnconfiguredStatusMapping(t *testing.T) { + store, run := delivererFixture(t) + run.TrackerLog = []workflow.TrackerLogEntry{{ID: "tlog_1", Kind: workflow.TrackerKindNeedsAttention, Revision: run.CurrentRevision, Detail: "first", Status: "pending"}} + ctx := context.Background() + run, err := store.Create(ctx, "op-create", nil, run) + if err != nil { + t.Fatal(err) + } + fake := &fakeTracker{} + d := &Deliverer{Store: store, newTracker: func(workflow.TrackerConfig) (Tracker, error) { return fake, nil }} + d.tick(ctx) + if len(fake.statusCalls()) != 0 { + t.Fatal("unconfigured mapping caused a status transition") + } +} + +func TestDelivererRetriesARejectedStatusWithoutBlockingTheRun(t *testing.T) { + store, run := delivererFixture(t) + run.TrackerLog = []workflow.TrackerLogEntry{{ID: "tstatus_1", Kind: workflow.TrackerEventRunStarted, Revision: run.CurrentRevision, Status: "pending", StatusName: "In Progress"}} + ctx := context.Background() + run, err := store.Create(ctx, "op-create", nil, run) + if err != nil { + t.Fatal(err) + } + fake := &fakeTracker{fail: 2} + clock := time.Now() + d := &Deliverer{Store: store, Now: func() time.Time { return clock }, newTracker: func(workflow.TrackerConfig) (Tracker, error) { return fake, nil }} + for i := 0; i < 3; i++ { + d.tick(ctx) + clock = clock.Add(time.Minute) + } + got, err := store.Get(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + if got.TrackerLog[0].Status != "posted" || len(fake.statusCalls()) != 1 { + t.Fatalf("rejected transition did not retry to success: entry=%+v statuses=%v", got.TrackerLog[0], fake.statusCalls()) + } +} diff --git a/internal/tracker/linear.go b/internal/tracker/linear.go index 051e61a..5ca94d7 100644 --- a/internal/tracker/linear.go +++ b/internal/tracker/linear.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -31,6 +32,7 @@ type linearClient struct { } var _ Tracker = (*linearClient)(nil) +var _ StatusUpdater = (*linearClient)(nil) func newLinearClient(cfg workflow.TrackerConfig) (*linearClient, error) { key, err := ResolveCredential(cfg) @@ -118,6 +120,78 @@ func (c *linearClient) viewer(ctx context.Context) (string, error) { return out.Viewer.ID, nil } +// Viewer checks the credential with a read, for the setup wizard. +func (c *linearClient) Viewer(ctx context.Context) (string, error) { return c.viewer(ctx) } + +// Teams lists the teams the credential can see, for the setup wizard. +func (c *linearClient) Teams(ctx context.Context) ([]Team, error) { + var out struct { + Teams struct { + Nodes []struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"teams"` + } + if err := c.call(ctx, `query { teams { nodes { id key name } } }`, nil, &out); err != nil { + return nil, err + } + teams := make([]Team, 0, len(out.Teams.Nodes)) + for _, t := range out.Teams.Nodes { + if t.ID != "" { + teams = append(teams, Team{ID: t.ID, Key: t.Key, Name: t.Name}) + } + } + return teams, nil +} + +// States lists a team's workflow states with their type. workflowStates +// serves readiness, which only needs names; the wizard also needs the type to +// suggest a mapping. +func (c *linearClient) States(ctx context.Context, teamID string) ([]State, error) { + var out struct { + Team *struct { + States struct { + Nodes []struct { + Name string `json:"name"` + Type string `json:"type"` + Position float64 `json:"position"` + } `json:"nodes"` + } `json:"states"` + } `json:"team"` + } + if err := c.call(ctx, `query($id: String!) { team(id: $id) { states { nodes { name type position } } } }`, map[string]string{"id": teamID}, &out); err != nil { + return nil, err + } + if out.Team == nil { + return nil, errors.New("linear team not found") + } + nodes := out.Team.States.Nodes + // Position is the order the team's board shows, which is the order a + // person expects to choose from. + slices.SortStableFunc(nodes, func(a, b struct { + Name string `json:"name"` + Type string `json:"type"` + Position float64 `json:"position"` + }) int { + switch { + case a.Position < b.Position: + return -1 + case a.Position > b.Position: + return 1 + } + return 0 + }) + states := make([]State, 0, len(nodes)) + for _, n := range nodes { + if n.Name != "" { + states = append(states, State{Name: n.Name, Type: n.Type}) + } + } + return states, nil +} + // issue resolves ref to its internal ID and owning team. func (c *linearClient) issue(ctx context.Context, ref string) (string, string, error) { var out struct { @@ -137,6 +211,96 @@ func (c *linearClient) issue(ctx context.Context, ref string) (string, string, e return out.Issue.ID, out.Issue.Team.ID, nil } +func (c *linearClient) workflowStates(ctx context.Context, teamID string) (map[string]string, []string, error) { + var out struct { + Team *struct { + States struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"states"` + } `json:"team"` + } + if err := c.call(ctx, `query($id: String!) { team(id: $id) { states { nodes { id name } } } }`, map[string]string{"id": teamID}, &out); err != nil { + return nil, nil, err + } + if out.Team == nil { + return nil, nil, errors.New("linear team not found") + } + byName := map[string]string{} + valid := make([]string, 0, len(out.Team.States.Nodes)) + for _, state := range out.Team.States.Nodes { + if state.ID == "" || state.Name == "" { + continue + } + byName[strings.ToLower(state.Name)] = state.ID + valid = append(valid, state.Name) + } + slices.Sort(valid) + return byName, valid, nil +} + +// issueState returns the issue and its current state in one read. It is used +// by SetStatus so a retry after a coordinator crash does not repeat a state +// mutation that already reached Linear. +func (c *linearClient) issueState(ctx context.Context, ref string) (string, string, string, error) { + var out struct { + Issue *struct { + ID string `json:"id"` + Team struct { + ID string `json:"id"` + } `json:"team"` + State struct { + Name string `json:"name"` + } `json:"state"` + } `json:"issue"` + } + if err := c.call(ctx, `query($id: String!) { issue(id: $id) { id team { id } state { name } } }`, map[string]string{"id": ref}, &out); err != nil { + return "", "", "", err + } + if out.Issue == nil { + return "", "", "", fmt.Errorf("linear issue %s not found", ref) + } + return out.Issue.ID, out.Issue.Team.ID, out.Issue.State.Name, nil +} + +// SetStatus applies the configured state by name. Linear exposes state IDs in +// mutations, so the team state list is resolved immediately before changing +// the issue and also supplies the authoritative names used by readiness. +func (c *linearClient) SetStatus(ctx context.Context, ref, statusName string) error { + issueID, teamID, current, err := c.issueState(ctx, ref) + if err != nil { + return err + } + if strings.EqualFold(strings.TrimSpace(current), strings.TrimSpace(statusName)) { + return nil + } + // Mapping is intentionally unconditional: a later envctl event is the + // authoritative writer, so manual moves are corrected by the next event. + states, valid, err := c.workflowStates(ctx, teamID) + if err != nil { + return err + } + stateID := states[strings.ToLower(strings.TrimSpace(statusName))] + if stateID == "" { + return fmt.Errorf("linear status %q not found; valid statuses are %s", statusName, strings.Join(valid, ", ")) + } + var out struct { + IssueUpdate struct { + Success bool `json:"success"` + } `json:"issueUpdate"` + } + mutation := `mutation($id: String!, $stateId: String!) { issueUpdate(id: $id, input: {stateId: $stateId}) { success } }` + if err := c.call(ctx, mutation, map[string]string{"id": issueID, "stateId": stateID}, &out); err != nil { + return err + } + if !out.IssueUpdate.Success { + return errors.New("linear issue status update did not succeed") + } + return nil +} + // canComment reports whether viewerID is a member of teamID, the signal // used to check comment permission without posting anything. func (c *linearClient) canComment(ctx context.Context, teamID, viewerID string) (bool, error) { diff --git a/internal/tracker/linear_test.go b/internal/tracker/linear_test.go index f958be1..6c609c0 100644 --- a/internal/tracker/linear_test.go +++ b/internal/tracker/linear_test.go @@ -176,6 +176,43 @@ func TestLinearCommentCreatesWhenMarkerAbsent(t *testing.T) { } } +func TestLinearSetStatusResolvesTheTeamStateAndUpdatesTheIssue(t *testing.T) { + rs := newRecordingServer(t) + rs.handler = func(req graphQLRequest) (int, string) { + switch { + case strings.Contains(req.Query, "state { name }"): + return 200, `{"data":{"issue":{"id":"issue_1","team":{"id":"team_1"},"state":{"name":"Todo"}}}}` + case strings.Contains(req.Query, "states { nodes"): + return 200, `{"data":{"team":{"states":{"nodes":[{"id":"state_todo","name":"Todo"},{"id":"state_progress","name":"In Progress"}]}}}}` + case strings.Contains(req.Query, "issueUpdate"): + return 200, `{"data":{"issueUpdate":{"success":true}}}` + } + return 200, `{"data":{}}` + } + if err := newClient(rs).SetStatus(context.Background(), "ENG-1", "In Progress"); err != nil { + t.Fatal(err) + } + if rs.count() != 3 || !strings.Contains(rs.last().Query, "issueUpdate") { + t.Fatalf("status update did not read issue/state and mutate it: %d requests, last %q", rs.count(), rs.last().Query) + } +} + +func TestLinearSetStatusDoesNotMutateAnIssueAlreadyInTheRequestedState(t *testing.T) { + rs := newRecordingServer(t) + rs.handler = func(req graphQLRequest) (int, string) { + if strings.Contains(req.Query, "state { name }") { + return 200, `{"data":{"issue":{"id":"issue_1","team":{"id":"team_1"},"state":{"name":"Done"}}}}` + } + return 200, `{"data":{}}` + } + if err := newClient(rs).SetStatus(context.Background(), "ENG-1", "Done"); err != nil { + t.Fatal(err) + } + if rs.count() != 1 { + t.Fatalf("already reconciled status made extra API calls: %d", rs.count()) + } +} + func TestLinearGraphQLErrorsAndHTTPErrors(t *testing.T) { rs := newRecordingServer(t) rs.handler = func(graphQLRequest) (int, string) { return 200, `{"errors":[{"message":"boom"}]}` } diff --git a/internal/tracker/prober.go b/internal/tracker/prober.go index cf3a406..8f0c7db 100644 --- a/internal/tracker/prober.go +++ b/internal/tracker/prober.go @@ -2,6 +2,7 @@ package tracker import ( "context" + "fmt" "strings" "github.com/sam-bretz/envctl/internal/engine" @@ -48,5 +49,20 @@ func (LinearProber) Probe(ctx context.Context, a engine.Assignment, capability s if !ok { return false, "credential lacks comment permission on the linked issue's team", nil } + if statuses := cfg.TrackerStatuses(a.Revision.Config.WorkflowName); len(statuses) > 0 { + _, valid, err := client.workflowStates(ctx, teamID) + if err != nil { + return false, "could not verify tracker workflow statuses", nil + } + validSet := make(map[string]bool, len(valid)) + for _, name := range valid { + validSet[strings.ToLower(name)] = true + } + for _, status := range statuses { + if !validSet[strings.ToLower(status)] { + return false, fmt.Sprintf("tracker status %q is unknown; valid statuses are %s", status, strings.Join(valid, ", ")), nil + } + } + } return true, "Linear credential, issue and comment permission verified", nil } diff --git a/internal/tracker/setup.go b/internal/tracker/setup.go new file mode 100644 index 0000000..30e624a --- /dev/null +++ b/internal/tracker/setup.go @@ -0,0 +1,208 @@ +package tracker + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/sam-bretz/envctl/internal/workflow" +) + +// Team is a tracker team the credential can see. +type Team struct { + ID string + Key string + Name string +} + +// State is one of a team's workflow states. Type is the tracker's own +// classification (Linear: backlog, unstarted, started, completed, canceled), +// which is what lets the wizard suggest a mapping without guessing from names. +type State struct { + Name string + Type string +} + +// Directory is what the setup wizard reads from a tracker. It never changes +// an issue: every method is a read. +type Directory interface { + Viewer(ctx context.Context) (string, error) + Teams(ctx context.Context) ([]Team, error) + States(ctx context.Context, teamID string) ([]State, error) +} + +// NewDirectory opens a read-only directory for a configured tracker. +func NewDirectory(cfg workflow.TrackerConfig) (Directory, error) { + if cfg.Provider != "linear" { + return nil, fmt.Errorf("unsupported tracker %q; only linear is supported", cfg.Provider) + } + client, err := newLinearClient(cfg) + if err != nil { + // Returning client here would hand back a non-nil interface holding + // a nil pointer. + return nil, err + } + return client, nil +} + +// Stage is what the wizard needs to know about a workflow stage to suggest +// a mapping for it. +type Stage struct { + ID string + // Gated stages wait for a person, which is the natural moment for an + // issue to move into review. + Gated bool +} + +// Stages describes a workflow's stages in run order. +func Stages(def workflow.Definition) []Stage { + order, _ := def.Order() + out := make([]Stage, 0, len(order)) + for _, id := range order { + out = append(out, Stage{ID: id, Gated: def.Nodes[id].Gate == "human"}) + } + return out +} + +// DefaultMapping suggests a mapping from a team's states so that accepting +// every default gives one that works. It chooses by state type, which the +// tracker defines, and uses names only to separate states of the same type, +// such as Linear's In Progress and In Review, which are both "started". +// +// Rewound maps back to the working state because the configuration requires +// that a rewind after Done not leave the issue Done; mapping it to nothing +// would do exactly that. +func DefaultMapping(states []State, stages []Stage) workflow.TrackerStatusMapping { + var m workflow.TrackerStatusMapping + working := pick(states, "started", func(s State) bool { return !mentions(s, "review") }) + // Review has no fallback. A team without a review state should leave + // approval unmapped, not move a waiting run back into the working state. + review := only(states, "started", func(s State) bool { return mentions(s, "review") }) + m.RunStarted = working + m.Rewound = working + m.PRPublished = pick(states, "completed", nil) + m.Cancelled = pick(states, "canceled", nil) + if review != "" { + for _, stage := range stages { + if stage.Gated { + if m.AwaitingApproval == nil { + m.AwaitingApproval = map[string]string{} + } + m.AwaitingApproval[stage.ID] = review + } + } + } + return m +} + +// pick returns the first state of a type that satisfies prefer, falling back +// to the first state of that type at all. +func pick(states []State, kind string, prefer func(State) bool) string { + fallback := "" + for _, s := range states { + if !strings.EqualFold(s.Type, kind) { + continue + } + if prefer == nil || prefer(s) { + return s.Name + } + if fallback == "" { + fallback = s.Name + } + } + return fallback +} + +// only returns the first state of a type that satisfies want, or nothing. +func only(states []State, kind string, want func(State) bool) string { + for _, s := range states { + if strings.EqualFold(s.Type, kind) && want(s) { + return s.Name + } + } + return "" +} + +func mentions(s State, word string) bool { + return strings.Contains(strings.ToLower(s.Name), word) +} + +// Preview lists the transitions a typical run would make, in the order it +// would make them, so the person sees the effect rather than only the YAML. +func Preview(m workflow.TrackerStatusMapping, stages []Stage) []string { + var out []string + add := func(event, status string) { + if status != "" { + out = append(out, fmt.Sprintf("%s → %s", event, status)) + } + } + add("run starts", m.RunStarted) + for _, stage := range stages { + add(stage.ID+" starts", m.StageStarted[stage.ID]) + add(stage.ID+" waits for approval", m.AwaitingApproval[stage.ID]) + add(stage.ID+" is accepted", m.StageAccepted[stage.ID]) + } + add("approved", m.Approved) + add("pull request published", m.PRPublished) + return out +} + +// TrackerBlock renders the complete tracker: section the wizard writes. The +// credential is only ever the file reference; the token itself never reaches +// the configuration. +func TrackerBlock(provider, credentialFile string, mapping map[string]workflow.TrackerStatusMapping) (string, error) { + if !strings.HasPrefix(credentialFile, "/") { + return "", errors.New("the credential must be an absolute file path") + } + cfg := workflow.TrackerConfig{Provider: provider, Credential: "file:" + credentialFile, Mapping: mapping} + raw, err := yaml.Marshal(map[string]workflow.TrackerConfig{"tracker": cfg}) + if err != nil { + return "", err + } + return strings.TrimRight(string(raw), "\n"), nil +} + +// SpliceTracker writes a tracker: block into a configuration, replacing an +// existing one so that re-running the wizard edits the mapping instead of +// adding a second. It splices text rather than re-encoding the document, so +// every byte outside the tracker block, comments included, is kept. Comments +// inside the replaced tracker block are not. +func SpliceTracker(raw []byte, block string) ([]byte, error) { + var doc yaml.Node + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, err + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return nil, errors.New("envctl.yaml is not a mapping") + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + replacement := strings.Split(block, "\n") + root := doc.Content[0] + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value != "tracker" { + continue + } + value := root.Content[i+1] + if value.Style&yaml.FlowStyle != 0 { + return nil, errors.New("tracker: is written on one line; rewrite it as an indented block, or edit the mapping by hand") + } + start, end := root.Content[i].Line-1, lastLine(value) + return []byte(strings.Join(slices.Concat(lines[:start], replacement, lines[end:]), "\n") + "\n"), nil + } + return []byte(strings.Join(slices.Concat(lines, []string{""}, replacement), "\n") + "\n"), nil +} + +// lastLine is the line, counted from one, of the deepest content in a node. +func lastLine(n *yaml.Node) int { + last := n.Line + for _, child := range n.Content { + if deeper := lastLine(child); deeper > last { + last = deeper + } + } + return last +} diff --git a/internal/tracker/setup_test.go b/internal/tracker/setup_test.go new file mode 100644 index 0000000..058b350 --- /dev/null +++ b/internal/tracker/setup_test.go @@ -0,0 +1,159 @@ +package tracker + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sam-bretz/envctl/internal/workflow" +) + +// linearStates is a real Linear team's default board. +var linearStates = []State{ + {Name: "Backlog", Type: "backlog"}, + {Name: "Todo", Type: "unstarted"}, + {Name: "In Progress", Type: "started"}, + {Name: "In Review", Type: "started"}, + {Name: "Done", Type: "completed"}, + {Name: "Canceled", Type: "canceled"}, +} + +var featureStages = []Stage{ + {ID: "task"}, {ID: "plan"}, {ID: "design"}, {ID: "code"}, {ID: "qa"}, + {ID: "approved-change", Gated: true}, +} + +func TestAcceptingEveryDefaultGivesAWorkingMapping(t *testing.T) { + m := DefaultMapping(linearStates, featureStages) + // In Progress and In Review share the "started" type, so the name has to + // separate them or the run would start straight into review. + if m.RunStarted != "In Progress" { + t.Fatalf("run started: %q", m.RunStarted) + } + if m.AwaitingApproval["approved-change"] != "In Review" { + t.Fatalf("the gated stage should wait in review: %v", m.AwaitingApproval) + } + if m.PRPublished != "Done" || m.Cancelled != "Canceled" { + t.Fatalf("terminal states: published %q cancelled %q", m.PRPublished, m.Cancelled) + } + // A rewind after Done must move the issue back, not leave it Done. + if m.Rewound != "In Progress" { + t.Fatalf("rewound would leave a reopened issue Done: %q", m.Rewound) + } + // Ungated stages have no review moment to map. + if _, ok := m.AwaitingApproval["code"]; ok { + t.Fatalf("mapped approval for a stage that never waits: %v", m.AwaitingApproval) + } +} + +func TestADefaultIsLeftUnmappedWhenTheTeamHasNoStateOfThatType(t *testing.T) { + // A team with no review column must not have review guessed onto some + // other started state. + m := DefaultMapping([]State{{Name: "Doing", Type: "started"}, {Name: "Shipped", Type: "completed"}}, featureStages) + if m.RunStarted != "Doing" || m.PRPublished != "Shipped" { + t.Fatalf("available types not used: %+v", m) + } + if len(m.AwaitingApproval) != 0 { + t.Fatalf("invented a review state: %v", m.AwaitingApproval) + } + if m.Cancelled != "" { + t.Fatalf("invented a cancelled state: %q", m.Cancelled) + } +} + +func TestThePreviewListsTransitionsInTheOrderARunMakesThem(t *testing.T) { + got := Preview(DefaultMapping(linearStates, featureStages), featureStages) + want := []string{"run starts → In Progress", "approved-change waits for approval → In Review", "pull request published → Done"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("preview:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +const project = `# The project this repository builds. +version: 2 +project: shop +repositories: [{id: app, url: /source}] +# Agents run on the default harness. +workflow: {template: feature} +` + +func TestTheWrittenMappingLoadsValidatesAndKeepsTheRestOfTheFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "envctl.yaml") + if err := os.WriteFile(path, []byte(project), 0o600); err != nil { + t.Fatal(err) + } + block, err := TrackerBlock("linear", "/home/me/.config/linear.token", map[string]workflow.TrackerStatusMapping{ + "default": DefaultMapping(linearStates, featureStages), + }) + if err != nil { + t.Fatal(err) + } + updated, err := SpliceTracker([]byte(project), block) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(updated), project) { + t.Fatalf("the existing configuration was rewritten:\n%s", updated) + } + if err = os.WriteFile(path, updated, 0o600); err != nil { + t.Fatal(err) + } + config, err := workflow.Load(dir) + if err != nil { + t.Fatalf("written mapping does not load: %v\n%s", err, updated) + } + if err = config.Validate(); err != nil { + t.Fatalf("written mapping does not validate: %v", err) + } + if config.Tracker == nil || config.Tracker.Mapping["default"].PRPublished != "Done" { + t.Fatalf("mapping lost on reload: %+v", config.Tracker) + } +} + +func TestRerunningTheWizardEditsTheMappingInsteadOfAddingASecond(t *testing.T) { + first, _ := TrackerBlock("linear", "/tokens/a", map[string]workflow.TrackerStatusMapping{"default": {PRPublished: "Done"}}) + once, err := SpliceTracker([]byte(project), first) + if err != nil { + t.Fatal(err) + } + second, _ := TrackerBlock("linear", "/tokens/b", map[string]workflow.TrackerStatusMapping{"default": {PRPublished: "Shipped"}}) + twice, err := SpliceTracker(once, second) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(twice), "tracker:"); n != 1 { + t.Fatalf("tracker: written %d times:\n%s", n, twice) + } + if strings.Contains(string(twice), "Done") || !strings.Contains(string(twice), "Shipped") { + t.Fatalf("the rerun did not replace the old mapping:\n%s", twice) + } + if !strings.Contains(string(twice), "# Agents run on the default harness.") { + t.Fatalf("a comment outside the tracker block was lost:\n%s", twice) + } +} + +func TestTheCredentialValueNeverReachesTheConfiguration(t *testing.T) { + // The wizard reads the token to check it, but only the reference is + // written. A relative path is refused because the token must be locatable + // from any checkout. + block, err := TrackerBlock("linear", "/home/me/.config/linear.token", nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(block, "credential: file:/home/me/.config/linear.token") { + t.Fatalf("credential reference missing:\n%s", block) + } + if _, err = TrackerBlock("linear", "linear.token", nil); err == nil { + t.Fatal("accepted a relative credential path") + } +} + +func TestAOneLineTrackerKeyIsRefusedRatherThanReformatted(t *testing.T) { + raw := project + "tracker: {provider: linear, credential: 'file:/x'}\n" + block, _ := TrackerBlock("linear", "/x", nil) + if _, err := SpliceTracker([]byte(raw), block); err == nil || !strings.Contains(err.Error(), "one line") { + t.Fatalf("flow-style tracker: not refused clearly: %v", err) + } +} diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go index 231398f..2098d1a 100644 --- a/internal/tracker/tracker.go +++ b/internal/tracker/tracker.go @@ -22,6 +22,14 @@ type Tracker interface { Comment(ctx context.Context, issueRef, marker, body string) (commentID string, err error) } +// StatusUpdater reconciles an issue to a named workflow state. Implementations +// must treat an issue already in the requested state as success; that read +// before mutation closes the crash window between a remote update and the +// durable receipt. +type StatusUpdater interface { + SetStatus(ctx context.Context, issueRef, statusName string) error +} + // Prober implements the readiness half of a tracker: verifying credential, // issue existence and comment permission without posting anything. type Prober interface { diff --git a/internal/workflow/config.go b/internal/workflow/config.go index f5838a6..805e028 100644 --- a/internal/workflow/config.go +++ b/internal/workflow/config.go @@ -121,7 +121,146 @@ type Harness struct { type TrackerConfig struct { Provider string `yaml:"provider" json:"provider"` Credential string `yaml:"credential" json:"credential"` + // Mapping is keyed by the selected workflow name (or "default"). A nil + // mapping preserves the identity of configurations that only post comments. + Mapping map[string]TrackerStatusMapping `yaml:"mapping,omitempty" json:"mapping,omitempty"` } + +// TrackerStatusMapping maps workflow events to the issue's workflow-state +// names. Stage events are keyed by stage because named workflows need not +// share stage IDs or state policy. +type TrackerStatusMapping struct { + RunStarted string `yaml:"run_started,omitempty" json:"run_started,omitempty"` + StageStarted map[string]string `yaml:"stage_started,omitempty" json:"stage_started,omitempty"` + StageAccepted map[string]string `yaml:"stage_accepted,omitempty" json:"stage_accepted,omitempty"` + AwaitingApproval map[string]string `yaml:"awaiting_approval,omitempty" json:"awaiting_approval,omitempty"` + Approved string `yaml:"approved,omitempty" json:"approved,omitempty"` + PRPublished string `yaml:"pr_published,omitempty" json:"pr_published,omitempty"` + NeedsAttention string `yaml:"needs_attention,omitempty" json:"needs_attention,omitempty"` + Cancelled string `yaml:"cancelled,omitempty" json:"cancelled,omitempty"` + Rewound string `yaml:"rewound,omitempty" json:"rewound,omitempty"` +} + +var trackerStatusEvents = []string{ + "approved", "awaiting_approval", "cancelled", "needs_attention", "pr_published", + "rewound", "run_started", "stage_accepted", "stage_started", +} + +// UnmarshalYAML accepts both the Go-shaped underscore spelling and the +// hyphenated spelling used by the event names in the CLI and captain's log. +// Parsing the event keys here lets config validation report the complete valid +// event vocabulary instead of exposing a generic YAML field error. +func (m *TrackerStatusMapping) UnmarshalYAML(value *yaml.Node) error { + var fields map[string]yaml.Node + if err := value.Decode(&fields); err != nil { + return err + } + *m = TrackerStatusMapping{} + for name, node := range fields { + canonical := strings.ReplaceAll(name, "-", "_") + var err error + switch canonical { + case "run_started": + err = node.Decode(&m.RunStarted) + case "stage_started": + err = node.Decode(&m.StageStarted) + case "stage_accepted": + err = node.Decode(&m.StageAccepted) + case "awaiting_approval": + err = node.Decode(&m.AwaitingApproval) + case "approved": + err = node.Decode(&m.Approved) + case "pr_published": + err = node.Decode(&m.PRPublished) + case "needs_attention": + err = node.Decode(&m.NeedsAttention) + case "cancelled": + err = node.Decode(&m.Cancelled) + case "rewound": + err = node.Decode(&m.Rewound) + default: + return fmt.Errorf("unknown tracker mapping event %q; valid events are %s", name, strings.Join(trackerStatusEvents, ", ")) + } + if err != nil { + return fmt.Errorf("tracker mapping event %s: status must be a string or stage-to-status map: %w", name, err) + } + } + return nil +} + +// Status returns the mapped status for an event. An empty result means that +// this event is intentionally unmapped. +func (m TrackerStatusMapping) Status(event, stage string) string { + var status string + switch event { + case "run_started": + status = m.RunStarted + case "stage_started": + status = m.StageStarted[stage] + case "stage_accepted": + status = m.StageAccepted[stage] + case "awaiting_approval": + status = m.AwaitingApproval[stage] + case "approved": + status = m.Approved + case "pr_published": + status = m.PRPublished + case "needs_attention": + status = m.NeedsAttention + case "cancelled": + status = m.Cancelled + case "rewound": + status = m.Rewound + } + return strings.TrimSpace(status) +} + +func (c TrackerConfig) mappingFor(workflowName string) (TrackerStatusMapping, bool) { + if workflowName == "" { + workflowName = DefaultWorkflow + } + m, ok := c.Mapping[workflowName] + return m, ok +} + +// TrackerStatus returns a configured status for one selected workflow event. +func (c TrackerConfig) TrackerStatus(workflowName, event, stage string) string { + m, ok := c.mappingFor(workflowName) + if !ok { + return "" + } + return m.Status(event, stage) +} + +// TrackerStatuses returns the distinct status names configured for a selected +// workflow in stable order, for read-only readiness verification. +func (c TrackerConfig) TrackerStatuses(workflowName string) []string { + m, ok := c.mappingFor(workflowName) + if !ok { + return nil + } + seen := map[string]bool{} + var out []string + add := func(status string) { + status = strings.TrimSpace(status) + if status != "" && !seen[status] { + seen[status] = true + out = append(out, status) + } + } + add(m.RunStarted) + for _, statuses := range []map[string]string{m.StageStarted, m.StageAccepted, m.AwaitingApproval} { + for _, status := range statuses { + add(status) + } + } + for _, status := range []string{m.Approved, m.PRPublished, m.NeedsAttention, m.Cancelled, m.Rewound} { + add(status) + } + sort.Strings(out) + return out +} + type PluginRef struct { ID string `yaml:"id" json:"id"` Source string `yaml:"source" json:"source"` @@ -467,6 +606,9 @@ func (c Config) Validate() error { case kind == "file" && !filepath.IsAbs(path): errs = append(errs, errors.New("tracker credential file: reference must be an absolute path")) } + if err := c.validateTrackerMapping(); err != nil { + errs = append(errs, err) + } } if err := c.Workflow.Validate(); err != nil { errs = append(errs, err) @@ -484,7 +626,8 @@ func (c Config) Validate() error { continue } selected := c - selected.Workflow, selected.Workflows = c.Workflows[name], nil + selected.Workflow, selected.Workflows, selected.WorkflowName = c.Workflows[name], nil, name + selected = selected.selectTrackerMapping(name) if err := selected.Validate(); err != nil { errs = append(errs, fmt.Errorf("workflows.%s: %w", name, err)) } @@ -514,6 +657,51 @@ func (c Config) Validate() error { } return errors.Join(errs...) } + +func (c Config) validateTrackerMapping() error { + if c.Tracker == nil || len(c.Tracker.Mapping) == 0 { + return nil + } + definitions := map[string]Definition{} + if c.Workflows == nil { + name := c.WorkflowName + if name == "" { + name = DefaultWorkflow + } + definitions[name] = c.Workflow + } else { + definitions[DefaultWorkflow] = c.Workflow + for name, definition := range c.Workflows { + definitions[name] = definition + } + } + validWorkflowNames := make([]string, 0, len(definitions)) + for name := range definitions { + validWorkflowNames = append(validWorkflowNames, name) + } + slices.Sort(validWorkflowNames) + var errs []error + for name, mapping := range c.Tracker.Mapping { + definition, ok := definitions[name] + if !ok { + errs = append(errs, fmt.Errorf("tracker mapping names unknown workflow %q; valid workflows are %s", name, strings.Join(validWorkflowNames, ", "))) + continue + } + for event, statuses := range map[string]map[string]string{ + "stage_started": mapping.StageStarted, + "stage_accepted": mapping.StageAccepted, + "awaiting_approval": mapping.AwaitingApproval, + } { + for stage := range statuses { + if _, ok := definition.Nodes[stage]; !ok { + validStages, _ := definition.Order() + errs = append(errs, fmt.Errorf("tracker mapping %s.%s names unknown stage %q; valid stages are %s", name, event, stage, strings.Join(validStages, ", "))) + } + } + } + } + return errors.Join(errs...) +} func (d Definition) Validate() error { if _, ok := templates[d.Template]; d.Template != "" && !ok { return fmt.Errorf("unknown workflow template %q; built-in templates are %s", d.Template, strings.Join(TemplateNames(), ", ")) diff --git a/internal/workflow/state.go b/internal/workflow/state.go index f871fdf..987007e 100644 --- a/internal/workflow/state.go +++ b/internal/workflow/state.go @@ -70,9 +70,13 @@ type TrackerLogEntry struct { Occurred time.Time `json:"occurred"` Status string `json:"status"` // pending, posted, failed CommentID string `json:"comment_id,omitempty"` - Attempts int `json:"attempts,omitempty"` - RetryAt time.Time `json:"retry_at,omitempty"` - Error string `json:"error,omitempty"` + // StatusName makes this outbox entry a workflow-state transition instead + // of a comment. It is frozen when the event occurs so later config edits + // cannot reinterpret an already-recorded transition. + StatusName string `json:"status_name,omitempty"` + Attempts int `json:"attempts,omitempty"` + RetryAt time.Time `json:"retry_at,omitempty"` + Error string `json:"error,omitempty"` } const ( @@ -86,6 +90,18 @@ const ( TrackerKindPublished = "published" ) +const ( + TrackerEventRunStarted = "run_started" + TrackerEventStageStarted = "stage_started" + TrackerEventStageAccepted = "stage_accepted" + TrackerEventAwaitingApproval = "awaiting_approval" + TrackerEventApproved = "approved" + TrackerEventPRPublished = "pr_published" + TrackerEventNeedsAttention = "needs_attention" + TrackerEventCancelled = "cancelled" + TrackerEventRewound = "rewound" +) + // AppendTrackerLog is the single enqueue point for every captain's-log event. // It is a pure, synchronous append with no I/O, safe to call inside the same // closure that performs the transition it logs. It is a no-op for runs @@ -99,6 +115,41 @@ func (r *Run) AppendTrackerLog(cfg *TrackerConfig, kind, revision, node, attempt ID: ID("tlog"), Kind: kind, Revision: revision, Node: node, Attempt: attempt, Detail: detail, Occurred: now, Status: "pending", }) + event := "" + switch kind { + case TrackerKindStageCompleted: + event = TrackerEventStageAccepted + case TrackerKindAwaitingApproval: + event = TrackerEventAwaitingApproval + case TrackerKindApproved: + event = TrackerEventApproved + case TrackerKindRewound: + event = TrackerEventRewound + case TrackerKindNeedsAttention: + event = TrackerEventNeedsAttention + } + r.AppendTrackerStatus(cfg, event, revision, node, attempt, detail, now) +} + +// AppendTrackerStatus adds only a mapped status transition. Unlike comments, +// unmapped events do not grow the outbox, which keeps the old tracker behavior +// and digest stable when status mapping is absent. +func (r *Run) AppendTrackerStatus(cfg *TrackerConfig, event, revision, node, attempt, detail string, now time.Time) { + if cfg == nil || strings.TrimSpace(r.TaskRef) == "" || event == "" { + return + } + workflowName := DefaultWorkflow + if rev := r.Revision(revision); rev != nil { + workflowName = rev.Config.WorkflowName + } + status := cfg.TrackerStatus(workflowName, event, node) + if status == "" { + return + } + r.TrackerLog = append(r.TrackerLog, TrackerLogEntry{ + ID: ID("tstatus"), Kind: event, Revision: revision, Node: node, + Attempt: attempt, Detail: detail, Occurred: now, Status: "pending", StatusName: status, + }) } // TrackerLogCounts summarizes pending/failed captain's-log posts for run show diff --git a/internal/workflow/tracker_test.go b/internal/workflow/tracker_test.go index 25e003f..664a9b0 100644 --- a/internal/workflow/tracker_test.go +++ b/internal/workflow/tracker_test.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "slices" "strings" "testing" @@ -53,6 +54,93 @@ func TestTrackerConfigValidation(t *testing.T) { } } +func TestTrackerStatusMappingValidatesEventsStagesAndNamedWorkflows(t *testing.T) { + base := fixture(t) + base.Tracker = &TrackerConfig{Provider: "linear", Credential: "env:LINEAR_TOKEN", Mapping: map[string]TrackerStatusMapping{ + "default": {RunStarted: "In Progress", StageStarted: map[string]string{"code": "In Progress"}}, + }} + if err := base.Validate(); err != nil { + t.Fatalf("valid mapping rejected: %v", err) + } + if got := base.Tracker.TrackerStatus("default", TrackerEventStageStarted, "code"); got != "In Progress" { + t.Fatalf("mapped stage status = %q", got) + } + + base.Workflows = map[string]Definition{"small": base.Workflow} + base.Tracker.Mapping["small"] = TrackerStatusMapping{StageAccepted: map[string]string{"qa": "In Review"}} + if err := base.Validate(); err != nil { + t.Fatalf("mapping for a named workflow rejected by the full config: %v", err) + } + selected, err := base.SelectWorkflow("small") + if err != nil { + t.Fatalf("named workflow mapping rejected: %v", err) + } + if got := selected.Tracker.TrackerStatus(selected.WorkflowName, TrackerEventStageAccepted, "qa"); got != "In Review" { + t.Fatalf("named workflow status = %q", got) + } + + badStage := base + badStage.Tracker = &TrackerConfig{Provider: "linear", Credential: "env:LINEAR_TOKEN", Mapping: map[string]TrackerStatusMapping{ + "default": {StageStarted: map[string]string{"missing": "In Progress"}}, + }} + if err := badStage.Validate(); err == nil || !strings.Contains(err.Error(), "valid stages are") || !strings.Contains(err.Error(), "code") { + t.Fatalf("unknown stage error did not list valid stages: %v", err) + } + + badWorkflow := fixture(t) + badWorkflow.Tracker = &TrackerConfig{Provider: "linear", Credential: "env:LINEAR_TOKEN", Mapping: map[string]TrackerStatusMapping{ + "missing": {RunStarted: "In Progress"}, + }} + if err := badWorkflow.Validate(); err == nil || !strings.Contains(err.Error(), "valid workflows are") || !strings.Contains(err.Error(), "default") { + t.Fatalf("unknown workflow error did not list valid workflows: %v", err) + } +} + +func TestTrackerStatusMappingRejectsUnknownEventAndListsValidEvents(t *testing.T) { + raw := `version: 2 +project: test +repositories: [{id: app, url: /source}] +workflow: {template: feature} +tracker: + provider: linear + credential: env:LINEAR_TOKEN + mapping: + default: + not-an-event: In Progress +` + _, err := Parse([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "unknown tracker mapping event") || !strings.Contains(err.Error(), "run_started") { + t.Fatalf("unknown event error was not useful: %v", err) + } +} + +func TestTrackerStatusMappingIsOmittedWhenUnconfigured(t *testing.T) { + config := fixture(t) + config.Tracker = &TrackerConfig{Provider: "linear", Credential: "env:LINEAR_TOKEN"} + raw, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "mapping") { + t.Fatalf("unconfigured mapping changed the encoded config: %s", raw) + } +} + +func TestMappedTrackerEventAddsOneStatusTransitionAfterItsComment(t *testing.T) { + r := live(t) + r.TaskRef = "ENG-42" + r.Current().Config.Tracker = &TrackerConfig{Provider: "linear", Credential: "env:LINEAR_TOKEN", Mapping: map[string]TrackerStatusMapping{ + "default": {StageAccepted: map[string]string{"code": "In Review"}}, + }} + r.AppendTrackerLog(r.Current().Config.Tracker, TrackerKindStageCompleted, r.CurrentRevision, "code", "attempt_1", "", time.Now()) + if len(r.TrackerLog) != 2 { + t.Fatalf("mapped event produced %d entries, want comment and transition", len(r.TrackerLog)) + } + if r.TrackerLog[0].StatusName != "" || r.TrackerLog[1].StatusName != "In Review" { + t.Fatalf("unexpected mapped event entries: %+v", r.TrackerLog) + } +} + func TestTrackerCredentialNeverAppearsInValidationErrors(t *testing.T) { c := fixture(t) c.Tracker = &TrackerConfig{Provider: "linear", Credential: "super-secret-value-not-a-reference"} diff --git a/internal/workflow/workflows.go b/internal/workflow/workflows.go index 37cc97f..714d69b 100644 --- a/internal/workflow/workflows.go +++ b/internal/workflow/workflows.go @@ -50,6 +50,7 @@ func (c Config) WorkflowNames() []string { func (c Config) SelectWorkflow(name string) (Config, error) { if name == "" || name == DefaultWorkflow { c.Workflows, c.WorkflowName = nil, "" + c = c.selectTrackerMapping(DefaultWorkflow) return c, nil } def, ok := c.Workflows[name] @@ -57,9 +58,24 @@ func (c Config) SelectWorkflow(name string) (Config, error) { return c, fmt.Errorf("unknown workflow %q; choose one of: %s", name, strings.Join(c.WorkflowNames(), ", ")) } c.Workflow, c.Workflows, c.WorkflowName = def, nil, name + c = c.selectTrackerMapping(name) return c, c.Validate() } +func (c Config) selectTrackerMapping(name string) Config { + if c.Tracker == nil { + return c + } + tracker := Clone(*c.Tracker) + if mapping, ok := tracker.Mapping[name]; ok { + tracker.Mapping = map[string]TrackerStatusMapping{name: mapping} + } else { + tracker.Mapping = nil + } + c.Tracker = &tracker + return c +} + // verifies reports whether a node produces check evidence an approved // change can rely on: a QA stage, or any stage with executable checks. func (n Node) verifies() bool { return n.Kind == "qa" || len(n.Checks) > 0 }