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
62 changes: 54 additions & 8 deletions internal/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func (c *AzureConfig) newPRAzureEvidenceV2(pr git.GitPullRequest) (*types.PREvid
return evidence, err
}
evidence.Commits = commits
evidence.Approvers, err = c.GetPullRequestApprovers(*pr.PullRequestId, 2)
evidence.Approvers, evidence.Reviews, err = c.GetPullRequestReviews(*pr.PullRequestId)
if err != nil {
return evidence, err
}
Expand Down Expand Up @@ -233,25 +233,49 @@ func (c *AzureConfig) PullRequestsForCommit(commit string) ([]git.GitPullRequest

// GetPullRequestApprovers returns a list of approvers for a given pull request
func (c *AzureConfig) GetPullRequestApprovers(prNumber, version int) ([]any, error) {
var approvers []any
reviewers, err := c.pullRequestReviewers(prNumber)
if err != nil {
return nil, err
}
return azureApprovers(reviewers, version), nil
}

// GetPullRequestReviews returns a PR's approvers and its reviews: each
// reviewer's current vote, since Azure keeps no history of them.
func (c *AzureConfig) GetPullRequestReviews(prNumber int) ([]any, []types.PRApprovals, error) {
reviewers, err := c.pullRequestReviewers(prNumber)
if err != nil {
return nil, nil, err
}
return azureApprovers(reviewers, 2), azureReviews(reviewers), nil
}

func (c *AzureConfig) pullRequestReviewers(prNumber int) ([]git.IdentityRefWithVote, error) {
ctx := context.Background()
client, err := NewAzureClientFromToken(ctx, c.Token, c.OrgURL)
if err != nil {
return approvers, err
return nil, err
}

reviewers, err := client.GetPullRequestReviewers(ctx, git.GetPullRequestReviewersArgs{
RepositoryId: &c.Repository,
PullRequestId: &prNumber,
Project: &c.Project,
})
if err != nil {
return approvers, err
return nil, err
}
return *reviewers, nil
}

for _, r := range *reviewers {
func azureReviewerName(r git.IdentityRefWithVote) string {
return fmt.Sprintf("%s (%s)", *r.DisplayName, *r.UniqueName)
}

func azureApprovers(reviewers []git.IdentityRefWithVote, version int) []any {
var approvers []any
for _, r := range reviewers {
if *r.Vote == 10 {
approverName := fmt.Sprintf("%s (%s)", *r.DisplayName, *r.UniqueName)
approverName := azureReviewerName(r)
if version == 1 {
approvers = append(approvers, approverName)
} else {
Expand All @@ -262,5 +286,27 @@ func (c *AzureConfig) GetPullRequestApprovers(prNumber, version int) ([]any, err
}
}
}
return approvers, nil
return approvers
}

// azureReviews leaves out groups: a group's vote is its members' votes, and
// each member who voted is listed in their own right.
func azureReviews(reviewers []git.IdentityRefWithVote) []types.PRApprovals {
reviews := []types.PRApprovals{}
for _, r := range reviewers {
if (r.IsContainer != nil && *r.IsContainer) || r.Vote == nil {
continue
}
var state string
switch {
case *r.Vote > 0:
state = types.ReviewApproved
case *r.Vote < 0:
state = types.ReviewChangesRequested
default:
continue
}
reviews = append(reviews, types.PRApprovals{Username: azureReviewerName(r), State: state})
}
return reviews
}
33 changes: 33 additions & 0 deletions internal/azure/reviews_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package azure

import (
"testing"

"github.com/kosli-dev/cli/internal/types"
"github.com/microsoft/azure-devops-go-api/azuredevops/git"
"github.com/stretchr/testify/require"
)

func azureReviewer(name string, vote int, isGroup bool) git.IdentityRefWithVote {
unique := name + "@example.com"
return git.IdentityRefWithVote{DisplayName: &name, UniqueName: &unique, Vote: &vote, IsContainer: &isGroup}
}

// Votes per IdentityRefWithVote: 10 approved, 5 approved with suggestions,
// 0 no vote, -5 waiting for author, -10 rejected.
func TestAzureReviews(t *testing.T) {
reviewers := []git.IdentityRefWithVote{
azureReviewer("Ada", 10, false),
azureReviewer("Bob", 5, false),
azureReviewer("Cy", 0, false),
azureReviewer("Di", -5, false),
azureReviewer("Ed", -10, false),
azureReviewer("Team", 10, true),
}
require.Equal(t, []types.PRApprovals{
{Username: "Ada (Ada@example.com)", State: types.ReviewApproved},
{Username: "Bob (Bob@example.com)", State: types.ReviewApproved},
{Username: "Di (Di@example.com)", State: types.ReviewChangesRequested},
{Username: "Ed (Ed@example.com)", State: types.ReviewChangesRequested},
}, azureReviews(reviewers))
}
50 changes: 50 additions & 0 deletions internal/bitbucket/bitbucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,63 @@ func (c *Config) getPullRequestDetailsFromBitbucket(prApiUrl, prHtmlLink, commit
return evidence, err
}
evidence.Commits = prCommits

evidence.Reviews, err = bitbucketReviews(participants)
if err != nil {
return evidence, err
}
}
} else {
return evidence, fmt.Errorf("failed to get PR details, got HTTP status %d. Please review repository permissions", response.Resp.StatusCode)
}
return evidence, nil
}

// bitbucketReviews gives each participant's current decision. A participant
// with no state who has commented is a comment; one who has done neither is
// left out.
func bitbucketReviews(participants []any) ([]types.PRApprovals, error) {
reviews := []types.PRApprovals{}
for _, participantInterface := range participants {
p := participantInterface.(map[string]any)
user, _ := p["user"].(map[string]any)
username, _ := user["display_name"].(string)
participatedOn, _ := p["participated_on"].(string)
var state string
switch p["state"] {
case "approved":
state = types.ReviewApproved
case "changes_requested":
state = types.ReviewChangesRequested
case nil:
if participatedOn == "" {
continue
}
state = types.ReviewCommented
default:
continue
}
// A change request is kept without a name: dropping it would read as
// none given.
if username == "" && state != types.ReviewChangesRequested {
continue
}
review := types.PRApprovals{
Username: username,
State: state,
}
if participatedOn != "" {
timestamp, err := parseRFC3339NanoTimestamp(participatedOn, "participated_on")
if err != nil {
Comment thread
AlexKantor87 marked this conversation as resolved.
return nil, err
}
review.Timestamp = timestamp
}
reviews = append(reviews, review)
}
return reviews, nil
}

// bitbucketBranchName extracts a branch name from a Bitbucket PR response for
// the given side: "source" for the head branch, "destination" for the base.
func bitbucketBranchName(prData map[string]any, side string) string {
Expand Down
45 changes: 45 additions & 0 deletions internal/bitbucket/reviews_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package bitbucket

import (
"encoding/json"
"testing"

"github.com/kosli-dev/cli/internal/types"
"github.com/stretchr/testify/require"
)

// Participant shapes follow the participant definition in
// https://api.bitbucket.org/swagger.json: state is approved, changes_requested
// or null; participated_on is the approval time, else the last comment, else null.
func TestBitbucketReviews(t *testing.T) {
var participants []any
require.NoError(t, json.Unmarshal([]byte(`[
{"user":{"display_name":"Ada"},"role":"REVIEWER","approved":true,"state":"approved","participated_on":"2026-03-01T13:00:00.123456+00:00"},
{"user":{"display_name":"Bob"},"role":"REVIEWER","approved":false,"state":"changes_requested","participated_on":"2026-03-01T14:00:00+00:00"},
{"user":{"display_name":"Cy"},"role":"REVIEWER","approved":false,"state":"changes_requested","participated_on":null},
{"user":{"display_name":"Di"},"role":"PARTICIPANT","approved":false,"state":null,"participated_on":"2026-03-01T15:00:00+00:00"},
{"user":{"display_name":"Ed"},"role":"REVIEWER","approved":false,"state":null,"participated_on":null},
{"user":null,"role":"REVIEWER","approved":false,"state":"changes_requested","participated_on":null},
{"user":null,"role":"PARTICIPANT","approved":false,"state":null,"participated_on":"2026-03-01T16:00:00+00:00"}
]`), &participants))

reviews, err := bitbucketReviews(participants)
require.NoError(t, err)
require.Equal(t, []types.PRApprovals{
{Username: "Ada", State: types.ReviewApproved, Timestamp: 1772370000},
{Username: "Bob", State: types.ReviewChangesRequested, Timestamp: 1772373600},
{Username: "Cy", State: types.ReviewChangesRequested},
{Username: "Di", State: types.ReviewCommented, Timestamp: 1772377200},
{State: types.ReviewChangesRequested},
}, reviews)
}

func TestBitbucketReviews_RejectsABadTimestamp(t *testing.T) {
var participants []any
require.NoError(t, json.Unmarshal([]byte(`[
{"user":{"display_name":"Ada"},"approved":true,"state":"approved","participated_on":"yesterday"}
]`), &participants))

_, err := bitbucketReviews(participants)
require.ErrorContains(t, err, "participated_on")
}
44 changes: 44 additions & 0 deletions internal/github/build_pr_evidence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,50 @@ func TestBuildPREvidence_RecordsWhichCommitAnApprovalCovered(t *testing.T) {
}, evidence.Approvers, "a review with no commit is kept, without a commit_sha")
}

// Sequence from kosli-dev/server PR 7200: approve, request changes, approve,
// all on one commit, plus a comment and a dismissal.
func TestBuildPREvidence_RecordsEveryReviewAndEachApprovalOnce(t *testing.T) {
const c1 = "1111111111111111111111111111111111111111"
evidence, err := buildPREvidence(
"https://github.com/kosli-dev/cli/pull/671",
"0e723254516c841126e81f76100be57258ff1386",
"MERGED", "tooky", "2026-03-01T09:00:00Z", "",
"title", "feature", "main", c1,
nil, []graphqlReviewNode{
reviewNode("grace", "APPROVED", "2026-03-01T13:00:00Z", c1),
reviewNode("grace", "CHANGES_REQUESTED", "2026-03-01T14:00:00Z", c1),
reviewNode("ada", "COMMENTED", "2026-03-01T14:30:00Z", c1),
reviewNode("ada", "DISMISSED", "2026-03-01T14:45:00Z", c1),
reviewNode("grace", "APPROVED", "2026-03-01T15:00:00Z", c1),
},
)
require.NoError(t, err)

require.Equal(t, []types.PRApprovals{
{Username: "grace", State: "APPROVED", Timestamp: 1772370000, CommitSHA: c1},
{Username: "grace", State: "CHANGES_REQUESTED", Timestamp: 1772373600, CommitSHA: c1},
{Username: "ada", State: "COMMENTED", Timestamp: 1772375400, CommitSHA: c1},
{Username: "ada", State: "DISMISSED", Timestamp: 1772376300, CommitSHA: c1},
{Username: "grace", State: "APPROVED", Timestamp: 1772377200, CommitSHA: c1},
}, evidence.Reviews)
require.Equal(t, []any{
types.PRApprovals{Username: "grace", State: "APPROVED", Timestamp: 1772377200, CommitSHA: c1},
}, evidence.Approvers)
}

func TestBuildPREvidence_RecordsThatAPRHasNoReviews(t *testing.T) {
evidence, err := buildPREvidence(
"https://github.com/kosli-dev/cli/pull/671",
"0e723254516c841126e81f76100be57258ff1386",
"MERGED", "tooky", "2026-03-01T09:00:00Z", "",
"title", "feature", "main", "",
nil, nil,
)
require.NoError(t, err)
require.NotNil(t, evidence.Reviews, "no reviews is a fact, not a gap")
require.Empty(t, evidence.Reviews)
}

func TestBuildPREvidence_RecordsWhoSignedACommit(t *testing.T) {
node := graphqlCommitNode{}
node.Commit.Oid = "0e723254516c841126e81f76100be57258ff1386"
Expand Down
16 changes: 9 additions & 7 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ type graphqlSignature struct {
WasSignedByGitHub graphql.Boolean
}

// graphqlReviewNode is the shared GraphQL node type for approved reviews on a PR.
// graphqlReviewNode is the shared GraphQL node type for reviews on a PR.
// PENDING is never queried: it is unsubmitted, so it has no submittedAt.
type graphqlReviewNode struct {
Author struct {
Login graphql.String
Expand Down Expand Up @@ -332,8 +333,8 @@ func buildPREvidence(
HeadRef: headRef,
HeadSHA: headSHA,
BaseRef: baseRef,
Approvers: []any{},
Commits: []types.Commit{},
Reviews: []types.PRApprovals{},
}

for _, n := range commitNodes {
Expand Down Expand Up @@ -388,16 +389,17 @@ func buildPREvidence(
if err != nil {
return nil, err
}
approval := types.PRApprovals{
review := types.PRApprovals{
Username: string(r.Author.Login),
State: string(r.State),
Timestamp: submittedAt.Unix(),
}
if r.Commit != nil {
approval.CommitSHA = string(r.Commit.Oid)
review.CommitSHA = string(r.Commit.Oid)
}
evidence.Approvers = append(evidence.Approvers, approval)
evidence.Reviews = append(evidence.Reviews, review)
}
evidence.Approvers = types.ApprovalsFrom(evidence.Reviews)

return evidence, nil
}
Expand Down Expand Up @@ -438,7 +440,7 @@ func (c *GithubConfig) PREvidenceByPRNumber(prNumber int) (*types.PREvidence, er
Reviews struct {
Nodes []graphqlReviewNode
PageInfo pageInfo
} `graphql:"reviews(first: 100, states: APPROVED, after: $reviewCursor)"`
} `graphql:"reviews(first: 100, states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED], after: $reviewCursor)"`
} `graphql:"pullRequest(number: $prNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
Expand Down Expand Up @@ -531,7 +533,7 @@ func (c *GithubConfig) PREvidenceForCommitV2(commit string) ([]*types.PREvidence
Reviews struct {
Nodes []graphqlReviewNode
PageInfo pageInfo
} `graphql:"reviews(first: 100, states: APPROVED, after: $reviewCursor)"`
} `graphql:"reviews(first: 100, states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED], after: $reviewCursor)"`
}
// Intentionally not paginated, so no cursor is selected: a
// commit with more than 100 associated PRs is not a realistic
Expand Down
4 changes: 2 additions & 2 deletions internal/github/pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func (c *GithubConfig) allPRReviews(ctx context.Context, run graphqlQueryFunc, r
Reviews struct {
Nodes []graphqlReviewNode
PageInfo pageInfo
} `graphql:"reviews(first: 100, states: APPROVED, after: $cursor)"`
} `graphql:"reviews(first: 100, states: [APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED], after: $cursor)"`
Comment thread
AlexKantor87 marked this conversation as resolved.
} `graphql:"pullRequest(number: $prNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
Expand All @@ -183,7 +183,7 @@ func (c *GithubConfig) allPRReviews(ctx context.Context, run graphqlQueryFunc, r
return page.Nodes, page.PageInfo, nil
})
if err != nil {
return nil, fmt.Errorf("draining approvals for %s: %w", ref, err)
return nil, fmt.Errorf("draining reviews for %s: %w", ref, err)
}
return reviews, nil
}
Loading
Loading