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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ listed newest first.
| key | |
|---|---|
| `enter` | open the full pull request |
| `B` | from an opened pull request, the runs its build gates produced |
| `v` | only pull requests waiting on your review |
| `d` | cycle drafts hidden → drafts only → all |
| `P` | publish a draft, or put a published pull request back into draft |
Expand All @@ -205,6 +206,17 @@ first. `w` opens the first linked work item. `c` replies to the first
unresolved thread and `R` resolves it, both without leaving the view. `D` opens
the diff.

`B` opens the runs behind the pull request's build gates — the build list
again, over those runs rather than the project's recent ones, so `enter`
reads the failing gate's logs and `Q` re-runs it from there. The runs come
from the pull request's policy evaluations rather than from a branch search,
which is what makes them the gates rather than whatever happened to build
that branch: a gate that has not started a run yet is left out, and a pull
request nothing gates says so on the status line instead of opening an empty
list. The status line carries the policy's own verdict over with it, because
it is not the build's — a rejected gate over a green run means the policy was
evaluated against an earlier iteration.

`A` approves, `W` waits for the author and `X` rejects, each on a second press
of the same key. They show when the pull request is yours to vote on — named
directly, or through a group of yours, whether it was resolved at startup or
Expand Down
86 changes: 86 additions & 0 deletions internal/azdo/policies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package azdo

import (
"fmt"
"net/url"
)

// buildPolicyTypeID is Azure DevOps's identifier for the build validation
// policy. The evaluations endpoint returns every policy on a pull request —
// reviewer counts, linked work items, comment resolution — and this is the one
// that runs a pipeline, so it is the only one that can name a build.
const buildPolicyTypeID = "0609b952-1397-4640-95ec-e00a01b2c241"

// evaluationsAPIVersion is APIVersion's preview twin for this endpoint, the
// same arrangement connectionData needs. Policy evaluations are still under
// preview, and asking for a plain version is refused outright: "The requested
// version \"7.1\" of the resource is under preview. The -preview flag must be
// supplied in the api-version for such requests." The .1 is the revision the
// shape read below belongs to; a bare -preview is accepted too, but pinning
// the revision is the point of pinning the version at all.
const evaluationsAPIVersion = APIVersion + "-preview.1"

// unnamedPolicy is what a build validation with no display name is called. A
// policy is named when it is configured and most are, but an unnamed one still
// gates the pull request and still has a run worth opening.
const unnamedPolicy = "build validation"

// GateBuild is one build validation policy on a pull request, and the run it
// produced. Status is the policy's own verdict — queued, running, approved,
// rejected — which is not the same as the build's: a rejected gate whose build
// succeeded means the policy was evaluated against an older iteration.
type GateBuild struct {
BuildID int
Policy string
Status string
}

// PullRequestGateBuilds lists the runs the build validation policies on pr
// have produced, newest evaluation first as the endpoint returns them.
//
// A policy with no run yet is left out rather than returned with a zero id:
// the caller's whole purpose is opening the build, and a row that cannot be
// opened is worse than a shorter list that says so.
func (c *Client) PullRequestGateBuilds(pr PullRequest) ([]GateBuild, error) {
if pr.ProjectID == "" {
return nil, fmt.Errorf("!%d does not say which project its repository belongs to, so its build gates cannot be looked up", pr.ID)
}

var resp struct {
Value []struct {
Configuration struct {
Type struct {
ID string `json:"id"`
} `json:"type"`
Settings struct {
DisplayName string `json:"displayName"`
} `json:"settings"`
} `json:"configuration"`
Status string `json:"status"`
Context struct {
BuildID int `json:"buildId"`
} `json:"context"`
} `json:"value"`
}

artifact := fmt.Sprintf("vstfs:///CodeReview/CodeReviewId/%s/%d", pr.ProjectID, pr.ID)
endpoint := fmt.Sprintf("%s/%s/%s/_apis/policy/evaluations?artifactId=%s&api-version=%s",
c.root(), url.PathEscape(c.Org), url.PathEscape(c.Project),
url.QueryEscape(artifact), evaluationsAPIVersion)
if err := c.get(endpoint, &resp); err != nil {
return nil, err
}

var gates []GateBuild
for _, v := range resp.Value {
if v.Configuration.Type.ID != buildPolicyTypeID || v.Context.BuildID == 0 {
continue
}
name := v.Configuration.Settings.DisplayName
if name == "" {
name = unnamedPolicy
}
gates = append(gates, GateBuild{BuildID: v.Context.BuildID, Policy: name, Status: v.Status})
}
return gates, nil
}
89 changes: 89 additions & 0 deletions internal/azdo/policies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package azdo

import (
"net/http"
"strings"
"testing"
)

// gateBody is one pull request's policy evaluations: two build validations —
// one that has produced a run and one still queued behind the policy itself —
// and a reviewer policy that is not a build at all.
const gateBody = `{"value":[
{"configuration":{"type":{"id":"0609b952-1397-4640-95ec-e00a01b2c241","displayName":"Build"},
"settings":{"displayName":"CI gate","buildDefinitionId":12}},
"status":"rejected","context":{"buildId":9001}},
{"configuration":{"type":{"id":"0609b952-1397-4640-95ec-e00a01b2c241","displayName":"Build"},
"settings":{"displayName":"Nightly gate","buildDefinitionId":13}},
"status":"queued","context":{}},
{"configuration":{"type":{"id":"fa4e907d-c16b-4a4c-9dfa-4906e5d171dd","displayName":"Minimum number of reviewers"},
"settings":{"minimumApproverCount":2}},
"status":"approved","context":{}}
]}`

func TestPullRequestGateBuildsKeepsTheBuildsAPolicyHasRun(t *testing.T) {
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
want := "vstfs:///CodeReview/CodeReviewId/p-guid/512"
if got := r.URL.Query().Get("artifactId"); got != want {
t.Errorf("artifactId = %q, want %q", got, want)
}
if !strings.Contains(r.URL.Path, "/_apis/policy/evaluations") {
t.Errorf("path = %q, want the policy evaluations endpoint", r.URL.Path)
}
// Azure DevOps refuses this endpoint at a plain version: "The
// requested version "7.1" of the resource is under preview. The
// -preview flag must be supplied in the api-version for such
// requests."
if got := r.URL.Query().Get("api-version"); !strings.HasSuffix(got, "-preview.1") {
t.Errorf("api-version = %q, want the preview form the endpoint requires", got)
}
w.Write([]byte(gateBody))
})

gates, err := c.PullRequestGateBuilds(PullRequest{ID: 512, ProjectID: "p-guid"})
if err != nil {
t.Fatalf("PullRequestGateBuilds returned %v", err)
}
if len(gates) != 1 {
t.Fatalf("got %d gates, want only the one with a build", len(gates))
}
got := gates[0]
if got.BuildID != 9001 {
t.Errorf("build id = %d, want 9001", got.BuildID)
}
if got.Policy != "CI gate" {
t.Errorf("policy = %q, want CI gate", got.Policy)
}
if got.Status != "rejected" {
t.Errorf("status = %q, want rejected", got.Status)
}
}

func TestPullRequestGateBuildsNamesAnUnnamedPolicy(t *testing.T) {
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"value":[
{"configuration":{"type":{"id":"0609b952-1397-4640-95ec-e00a01b2c241","displayName":"Build"},"settings":{}},
"status":"approved","context":{"buildId":42}}]}`))
})

gates, err := c.PullRequestGateBuilds(PullRequest{ID: 7, ProjectID: "p-guid"})
if err != nil {
t.Fatalf("PullRequestGateBuilds returned %v", err)
}
if len(gates) != 1 {
t.Fatalf("got %d gates, want 1", len(gates))
}
if gates[0].Policy != "build validation" {
t.Errorf("policy = %q, want the fallback name", gates[0].Policy)
}
}

func TestPullRequestGateBuildsWithoutAProjectIDSaysSo(t *testing.T) {
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("the evaluations endpoint was called without a project id")
})

if _, err := c.PullRequestGateBuilds(PullRequest{ID: 512}); err == nil {
t.Fatal("PullRequestGateBuilds returned no error for a pull request with no project id")
}
}
21 changes: 15 additions & 6 deletions internal/azdo/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,15 @@ func (r Reviewer) VoteLabel() string {

// PullRequest is one active pull request, in any repository in the project.
type PullRequest struct {
ID int
Title string
Repo string
RepoID string
ID int
Title string
Repo string
RepoID string
// ProjectID is the GUID of the team project the repository belongs to.
// The project is known by name everywhere else in boardwalk, but the
// policy endpoint addresses a pull request by an artifact id built from
// the GUID, and the name will not do there.
ProjectID string
Author string
AuthorKey string // uniqueName, lowercased, compared against Client.Me
IsDraft bool
Expand Down Expand Up @@ -193,8 +198,11 @@ type pullRequestJSON struct {
UniqueName string `json:"uniqueName"`
} `json:"createdBy"`
Repository struct {
ID string `json:"id"`
Name string `json:"name"`
ID string `json:"id"`
Name string `json:"name"`
Project struct {
ID string `json:"id"`
} `json:"project"`
} `json:"repository"`
LastMergeSourceCommit struct {
CommitID string `json:"commitId"`
Expand All @@ -217,6 +225,7 @@ func (v pullRequestJSON) pullRequest() PullRequest {
Title: v.Title,
Repo: v.Repository.Name,
RepoID: v.Repository.ID,
ProjectID: v.Repository.Project.ID,
Author: v.CreatedBy.DisplayName,
AuthorKey: strings.ToLower(v.CreatedBy.UniqueName),
IsDraft: v.IsDraft,
Expand Down
17 changes: 17 additions & 0 deletions internal/azdo/pullrequests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,3 +602,20 @@ func TestCreateThreadReportsTheServersRefusal(t *testing.T) {
t.Errorf("error = %v, want the server's own message in it", err)
}
}

func TestPullRequestParsesTheProjectItsRepositoryBelongsTo(t *testing.T) {
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"value":[
{"pullRequestId":512,"title":"Retry webhooks","sourceRefName":"refs/heads/x","targetRefName":"refs/heads/main",
"creationDate":"2026-09-10T09:00:00Z","createdBy":{"displayName":"Dev","uniqueName":"dev@acme.test"},
"repository":{"id":"r1","name":"platform-api","project":{"id":"p-guid","name":"Platform"}}}]}`))
})

prs, err := c.PullRequests()
if err != nil {
t.Fatalf("PullRequests returned %v", err)
}
if prs[0].ProjectID != "p-guid" {
t.Errorf("project id = %q, want p-guid", prs[0].ProjectID)
}
}
Loading
Loading