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
35 changes: 21 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,15 @@ list that reads as a fetch that broke:
| `enter` | open the full item |
| `^t` | toggle between everyone's items and yours |
| `a` | set the item Active |
| `b` | create a branch, set the item Active, link the branch to it, and copy the checkout |
| `b` | open the new branch screen: create a branch from any branch, set the item Active, link the branch to it, and copy the checkout |

The side pane is a summary — id, title, assignee, tags and iteration — so it
stays readable while the cursor moves. `enter` opens the item itself: every
field, the pull requests it is linked to, and the whole discussion, with the
description, acceptance criteria and comments rendered as markdown rather than
flattened to prose. `p` opens the first of those pull requests, and they are
listed newest first.
listed newest first. `b` works there too, and opens the same new branch screen for the
item you are reading.

### Pull requests

Expand Down Expand Up @@ -425,9 +426,24 @@ on every row — and `t` brings it back when you are timing something.

## The branch flow

`b` on a work item does the whole thing against the REST API — creates the
branch off the repository's default branch, moves the item to Active, and links
the branch to the item — then puts the checkout on your clipboard:
`b` on a work item — on the list, or on the item itself — opens the new
branch screen, which asks three things:

- **repository** — the one you are standing in, when boardwalk can tell:
it reads the `origin` remote and matches it against the project's
repositories. Anywhere else — outside a checkout, or in a repository
belonging to somewhere else — it starts on the first, and `j` and `k` move.
- **name** — prefilled from the item's type, id and title, and editable.
- **from** — the branch to create it from, filtered as you type. The cursor
starts on the repository's default branch, so for the common case there is
nothing to change; for a branch off a feature or hotfix line, type part of
its name and `↑`/`↓` to it.

`tab` and `shift+tab` move between them, `enter` creates, and `esc` goes back
without doing anything. On `enter` boardwalk does the whole thing against the
REST API — creates the branch from the chosen source, moves the item to Active,
and links the branch to the item — then goes back to where you pressed `b` and puts the
checkout on your clipboard:

```
git fetch origin && git checkout feature/4021-retry-webhook-delivery-on-5xx && git pull
Expand All @@ -443,12 +459,6 @@ state change or the link then fails, since the branch is there either way. If
the clipboard command is missing or refuses, the command goes on the status
line to be read off instead.

The repository is usually the one you are standing in: boardwalk reads the
`origin` remote and matches it against the project's repositories. Run it
somewhere else — outside a checkout, or in a repository belonging to somewhere
else — and it asks instead, one repository at a time on the status line, `j`
and `k` to move and `enter` to pick.

## Notes from building it

**`az boards query` caps at 1000 work items.** Going through the REST API
Expand Down Expand Up @@ -512,9 +522,6 @@ Known rough edges, none of them load-bearing:
- A build row whose timeline fetch failed retries every few seconds for as long
as a log pane is tailing, because the hidden build list still receives the
tail's messages.
- A row refresh that lands while the branch prompt is open rebuilds the list
underneath it. If the server's order changed, the branch could be named for
one work item and attached to another.
- Lazily arriving rows scroll the detail pane back to the top. The diff view is
the exception — it re-renders only the pane whose file just landed.
- A copy or open failure is reported, but the raw `exec` error is not always
Expand Down
31 changes: 17 additions & 14 deletions internal/azdo/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,33 +51,36 @@ func (c *Client) Repos() ([]Repo, error) {
return repos, nil
}

// RefHead returns the commit a ref points at. ref is the full form,
// Ref is a Git ref and the commit it points at. Name is the full form,
// refs/heads/main.
func (c *Client) RefHead(repoID, ref string) (string, error) {
type Ref struct {
Name string
ObjectID string
}

// Branches lists the repository's branches, the refs under refs/heads, with
// the commit each one points at — enough to create a new branch from any of
// them without a second lookup.
func (c *Client) Branches(repoID string) ([]Ref, error) {
var resp struct {
Value []struct {
Name string `json:"name"`
ObjectID string `json:"objectId"`
} `json:"value"`
}

endpoint := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs?filter=%s&api-version=%s",
endpoint := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs?filter=heads/&api-version=%s",
c.root(), url.PathEscape(c.Org), url.PathEscape(c.Project),
url.PathEscape(repoID), url.QueryEscape(strings.TrimPrefix(ref, "refs/")), APIVersion)
url.PathEscape(repoID), APIVersion)
if err := c.get(endpoint, &resp); err != nil {
return "", err
return nil, err
}
// The endpoint's filter is a starts-with match, so filter=heads/main also
// returns refs/heads/main-2 and refs/heads/main-hotfix, in unspecified
// order. Taking the first entry back would branch off whichever ref the
// server happened to list first — the right name is the only way to know
// which ref this is.

refs := make([]Ref, 0, len(resp.Value))
for _, v := range resp.Value {
if v.Name == ref {
return v.ObjectID, nil
}
refs = append(refs, Ref{Name: v.Name, ObjectID: v.ObjectID})
}
return "", fmt.Errorf("no ref matching %s", ref)
return refs, nil
}

// MergeRefPullRequestID parses the pull request id out of a merge ref, the
Expand Down
114 changes: 31 additions & 83 deletions internal/azdo/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,89 +48,6 @@ func TestReposParsesTheListing(t *testing.T) {
}
}

func TestRefHeadReturnsTheObjectID(t *testing.T) {
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("filter"); got != "heads/main" {
t.Errorf("filter = %q, want heads/main", got)
}
w.Write([]byte(`{"value":[{"name":"refs/heads/main","objectId":"abc123"}]}`))
})

sha, err := c.RefHead("r1", "refs/heads/main")
if err != nil {
t.Fatalf("RefHead returned %v", err)
}
if sha != "abc123" {
t.Errorf("sha = %q, want abc123", sha)
}
}

func TestRefHeadMatchesTheNameExactly(t *testing.T) {
// The refs endpoint's filter is a starts-with match, so filter=heads/main
// also returns refs/heads/main-hotfix — in unspecified order. Taking the
// first entry would branch a repo off whichever ref happened to come back
// first, silently using the wrong commit.
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"value":[
{"name":"refs/heads/main-hotfix","objectId":"wrong1"},
{"name":"refs/heads/main-2","objectId":"wrong2"},
{"name":"refs/heads/main","objectId":"right"}
]}`))
})

sha, err := c.RefHead("r1", "refs/heads/main")
if err != nil {
t.Fatalf("RefHead returned %v", err)
}
if sha != "right" {
t.Errorf("sha = %q, want the exactly matching ref's commit", sha)
}
}

func TestRefHeadOnABranchNameWithASlash(t *testing.T) {
// Branch names carry the type prefix boardwalk generates, so the common
// case has a slash in it and has to survive both the query escaping and
// the exact-name comparison.
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("filter"); got != "heads/feature/4021-retry" {
t.Errorf("filter = %q, want heads/feature/4021-retry", got)
}
w.Write([]byte(`{"value":[{"name":"refs/heads/feature/4021-retry","objectId":"abc123"}]}`))
})

sha, err := c.RefHead("r1", "refs/heads/feature/4021-retry")
if err != nil {
t.Fatalf("RefHead returned %v", err)
}
if sha != "abc123" {
t.Errorf("sha = %q, want abc123", sha)
}
}

func TestRefHeadOnAPrefixOnlyMatch(t *testing.T) {
// A filter that matches only siblings is a miss, not a hit on the first
// one returned.
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"value":[{"name":"refs/heads/main-hotfix","objectId":"wrong1"}]}`))
})

if _, err := c.RefHead("r1", "refs/heads/main"); err == nil {
t.Fatal("RefHead returned no error for a ref only a sibling matched")
}
}

func TestRefHeadOnAMissingBranch(t *testing.T) {
// A filter that matches nothing comes back 200 with an empty list, so the
// error has to be synthesised rather than read off the status code.
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"value":[]}`))
})

if _, err := c.RefHead("r1", "refs/heads/nope"); err == nil {
t.Fatal("RefHead on a missing branch returned no error")
}
}

func TestCreateBranchPostsAZeroOldObjectID(t *testing.T) {
var body string
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -195,3 +112,34 @@ func TestCreateBranchSurfacesAPerRefFailure(t *testing.T) {
t.Errorf("error = %q, want the custom message", err)
}
}

func TestBranchesListsOnlyBranchHeads(t *testing.T) {
// The source picker offers branches to create from, so the listing asks
// for refs/heads only — tags and pull request refs are not candidates.
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/_apis/git/repositories/r1/refs") {
t.Errorf("path = %q, want the repository's refs endpoint", r.URL.Path)
}
if got := r.URL.Query().Get("filter"); got != "heads/" {
t.Errorf("filter = %q, want heads/", got)
}
w.Write([]byte(`{"value":[
{"name":"refs/heads/main","objectId":"m1"},
{"name":"refs/heads/hotfix/1.2","objectId":"h1"}
]}`))
})

refs, err := c.Branches("r1")
if err != nil {
t.Fatalf("Branches returned %v", err)
}
want := []Ref{{Name: "refs/heads/main", ObjectID: "m1"}, {Name: "refs/heads/hotfix/1.2", ObjectID: "h1"}}
if len(refs) != len(want) {
t.Fatalf("got %d refs, want %d", len(refs), len(want))
}
for i := range want {
if refs[i] != want[i] {
t.Errorf("refs[%d] = %+v, want %+v", i, refs[i], want[i])
}
}
}
Loading
Loading