Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ baton needs a config file, `config.json` in `~/Library/Application Support/baton
- `branchPrefix`: what goes before a feature's slug in its branch's name (by default `feature/`).
- `planApproval`: whether baton asks you before each feature starts, so that you can check the orchestrator's plan first (by default `true`).
- `gate`: the command that checks the repo's code, such as `mise run check`, with no default: baton starts neither the orchestrator nor a feature in a repo without one. The engineer's handover, QA's approval and the lead's final report run it with `/bin/sh -c` in the feature's worktree, and wait until it passes. `"true"` checks nothing.
- `jira`: your Jira Cloud site's host name and your email address at Atlassian, for `baton jira`, such as `{"site": "example.atlassian.net", "email": "you@example.com"}`, with no default.

For example, one gate and QA on another model with a cap, for every repo, and two rounds with another branch prefix for one repo:

Expand All @@ -33,3 +34,13 @@ For example, one gate and QA on another model with a cap, for every repo, and tw
```

baton checks the whole file each time it reads it, and refuses a file with a mistake, naming the file and the mistake.

## Jira

When you name a Jira Cloud ticket, the orchestrator reads it with `baton jira <KEY>`, such as `baton jira DEMO-1`, which prints the ticket and its comments as Markdown; you can run it too. baton only reads Jira, as you, with an API token that can only read:

1. Set `jira` in the config file to your site and your email address at Atlassian.
2. At [id.atlassian.com](https://id.atlassian.com/manage-profile/security/api-tokens), create an API token with scopes, for Jira, with the scope `read:jira-work` alone.
3. Put it in your login Keychain, as baton's item for your email: `security add-generic-password -s baton-jira -a you@example.com -w`, which asks for the token, so that it stays out of your shell's history. When the token expires, put the new one in its place with the same command and `-U`.

baton sends the token to Atlassian's gateway, `api.atlassian.com`, alone, and never prints it. Any program that runs as you can read the item, though, the roles' commands included, so give the token no other scope.
20 changes: 12 additions & 8 deletions docs/plan.md

Large diffs are not rendered by default.

8 changes: 1 addition & 7 deletions internal/cli/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cli
import (
"context"
"fmt"
"os"

"github.com/codedogapp/baton/internal/config"
"github.com/codedogapp/baton/internal/flow"
Expand Down Expand Up @@ -77,11 +76,6 @@ func gatedSettings(ctx context.Context, dir string) (config.Settings, error) {

// noGate returns the error for a repo without a gate, which names the config file where it goes.
func noGate() error {
file := "baton's config file"
if configDir, err := os.UserConfigDir(); err == nil {
file = config.File(configDir)
}

return fmt.Errorf(`no gate for this repo: set "gate" in %s to the command that checks `+
`the repo's code, such as "mise run check", or to "true" for none`, file)
`the repo's code, such as "mise run check", or to "true" for none`, configFile())
}
10 changes: 6 additions & 4 deletions internal/cli/guard_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ const featureStart = "baton feature start"

// orchestratorCommands are baton's commands that the orchestrator runs unasked, by the names
// that the guard gives them.
var orchestratorCommands = slices.Concat(
[]string{"baton answer", "baton handover", "baton status", "baton wait"}, helpCommands)
var orchestratorCommands = slices.Concat([]string{
"baton answer", "baton handover", "baton jira", "baton status", "baton wait",
}, helpCommands)

// orchestratorWhy says why the guard denies the orchestrator a command of each kind that it
// denies, and what the orchestrator does instead.
Expand All @@ -26,8 +27,9 @@ var orchestratorWhy = map[guard.Kind]string{
"never does: baton runs the panes, and `baton status` shows the features.",
guard.Agent: "starts another agent, which the orchestrator never does: start a feature, " +
"and its team does the work.",
guard.Baton: "isn't the orchestrator's command: it runs `baton feature start`, " +
"`baton wait`, `baton answer`, `baton handover` and `baton status`.",
guard.Baton: "isn't the orchestrator's command: it runs `baton jira`, " +
"`baton feature start`, `baton wait`, `baton answer`, `baton handover` and " +
"`baton status`.",
guard.Unknown: why[guard.Unknown],
}

Expand Down
6 changes: 4 additions & 2 deletions internal/cli/guard_orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ const (
"orchestrator never does: baton runs the panes, and `baton status` shows the features."
orchestratorAgent = "starts another agent, which the orchestrator never does: start a " +
"feature, and its team does the work."
orchestratorBaton = "isn't the orchestrator's command: it runs `baton feature start`, " +
"`baton wait`, `baton answer`, `baton handover` and `baton status`."
orchestratorBaton = "isn't the orchestrator's command: it runs `baton jira`, " +
"`baton feature start`, `baton wait`, `baton answer`, `baton handover` and " +
"`baton status`."
)

// asks returns the guard's question to the user about the orchestrator's outward command, which
Expand Down Expand Up @@ -55,6 +56,7 @@ func TestGuardHookAsksTheUserAboutTheOrchestratorsCommands(t *testing.T) {
"baton answer demo-1 'Keep it'": {},
"baton handover demo-1 --to lead": {},
"baton status": {},
"baton jira DEMO-1": {},
"baton help feature": {},
"baton -h": {},
"baton -help": {},
Expand Down
1 change: 1 addition & 0 deletions internal/cli/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func TestGuardHookDeniesARoleWhatTheGuardWatches(t *testing.T) {
"baton feature start x": denied("baton feature start", toBaton),
"baton hook stop": denied("baton hook stop", toBaton),
"baton pane": denied("baton pane", toBaton),
"baton jira DEMO-1": denied("baton jira", toBaton),
"baton": denied("baton", toBaton),
`baton "$verb"`: denied("baton …", toBaton),
"baton wait": "",
Expand Down
89 changes: 89 additions & 0 deletions internal/cli/jira.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
"unicode"

"github.com/codedogapp/baton/internal/config"
"github.com/codedogapp/baton/internal/jira"
"github.com/codedogapp/baton/internal/keychain"
)

// keychainService is the service of the keychain's item that holds the user's Jira API token,
// whose account is the user's email (decision 60).
const keychainService = "baton-jira"

// showTicket prints a Jira ticket and its comments as Markdown, for the orchestrator to plan from:
// it reads the ticket from the site in the config file, as the user, with their API token from
// the keychain (decision 60).
func showTicket(ctx context.Context, env Env, args []string) error {
keys, err := parseTexts(newFlags(env, "jira"), args, "a ticket's key, such as DEMO-1")
if err != nil {
return err
}

if err := jira.CheckKey(keys[0]); err != nil {
return err
}

site, err := jiraSite(ctx, env.Dir)
if err != nil {
return err
}

token, err := env.Keychain(ctx, keychainService, site.Email)
if errors.Is(err, keychain.ErrNotFound) {
return fmt.Errorf("no Jira API token for %s in the keychain: add it with `security "+
"add-generic-password -s %s -a %s -w`, which asks for it", site.Email,
keychainService, quoted(site.Email))
}

if err != nil {
return err
}

client := jira.Client{HTTP: env.HTTP, Site: site.Site, Email: site.Email, Token: token}

ticket, err := client.Ticket(ctx, keys[0])
if err != nil {
return err
}

_, err = io.WriteString(env.Stdout, printable(ticket.Markdown()))

return err
}

// jiraSite returns the Jira site of the folder's repo, from the config file, and refuses one
// without its host name or the user's email.
func jiraSite(ctx context.Context, dir string) (config.Jira, error) {
settings, err := loadSettings(ctx, dir)
if err == nil && (settings.Jira.Site == "" || settings.Jira.Email == "") {
err = fmt.Errorf(`no Jira site for this repo: set "jira" in %s to the site's host name `+
`and your email address at Atlassian, such as {"site": "example.atlassian.net", `+
`"email": "you@example.com"}`, configFile())
}

return settings.Jira, err
}

// quoted returns the text in single quotes, as the shell reads it.
func quoted(text string) string {
return "'" + strings.ReplaceAll(text, "'", `'\''`) + "'"
}

// printable returns the text without its control characters, but for newlines and tabs, so that a
// ticket can't send escape sequences to the terminal.
func printable(text string) string {
return strings.Map(func(char rune) rune {
if unicode.IsControl(char) && char != '\n' && char != '\t' {
return -1
}

return char
}, text)
}
Loading
Loading