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
8 changes: 7 additions & 1 deletion internal/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ func (c *AzureConfig) newPRAzureEvidenceV2(pr git.GitPullRequest) (*types.PREvid
Author: fmt.Sprintf("%s (%s)", *pr.CreatedBy.DisplayName, *pr.CreatedBy.UniqueName),
CreatedAt: pr.CreationDate.Time.Unix(),
Title: *pr.Title,
HeadRef: *pr.SourceRefName,
}
evidence.HeadRef, evidence.BaseRef = azurePRRefs(pr)
if pr.Status != nil && pr.ClosedDate != nil && *pr.Status == git.PullRequestStatusValues.Completed {
evidence.MergedAt = pr.ClosedDate.Time.Unix()
}
Expand All @@ -147,6 +147,12 @@ func (c *AzureConfig) newPRAzureEvidenceV2(pr git.GitPullRequest) (*types.PREvid
return evidence, nil
}

// azurePRRefs returns the head (source) and base (target) ref names of an Azure
// DevOps pull request, kept raw (e.g. "refs/heads/main") to match head_ref.
func azurePRRefs(pr git.GitPullRequest) (head, base string) {
return *pr.SourceRefName, *pr.TargetRefName
}
Comment thread
AlexKantor87 marked this conversation as resolved.

// GetPullRequestCommits returns a list of commits for a given pull request
func (c *AzureConfig) GetPullRequestCommits(pr git.GitPullRequest) ([]types.Commit, error) {
commits := []types.Commit{}
Expand Down
19 changes: 19 additions & 0 deletions internal/azure/pr_refs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package azure

import (
"testing"

"github.com/microsoft/azure-devops-go-api/azuredevops/git"
"github.com/stretchr/testify/require"
)

// TestAzurePRRefs verifies head (source) and base (target) ref extraction. The
// refs are kept raw with the refs/heads/ prefix to match head_ref (server#5892).
func TestAzurePRRefs(t *testing.T) {
head, base := azurePRRefs(git.GitPullRequest{
SourceRefName: azStr("refs/heads/feature-branch"),
TargetRefName: azStr("refs/heads/main"),
})
require.Equal(t, "refs/heads/feature-branch", head)
require.Equal(t, "refs/heads/main", base)
}
9 changes: 8 additions & 1 deletion internal/bitbucket/bitbucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ func (c *Config) getPullRequestDetailsFromBitbucket(prApiUrl, prHtmlLink, commit
}
evidence.MergedAt = mergedAt
evidence.Title = responseData["title"].(string)
evidence.HeadRef = responseData["source"].(map[string]any)["branch"].(map[string]any)["name"].(string)
evidence.HeadRef = bitbucketBranchName(responseData, "source")
evidence.BaseRef = bitbucketBranchName(responseData, "destination")

prCommits, err := c.getPullRequestCommitsFromBitbucket(int(responseData["id"].(float64)))
if err != nil {
Expand All @@ -196,6 +197,12 @@ func (c *Config) getPullRequestDetailsFromBitbucket(prApiUrl, prHtmlLink, commit
return evidence, 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 {
return prData[side].(map[string]any)["branch"].(map[string]any)["name"].(string)
}

// getPullRequestCommitsFromBitbucket gets the commits of a pull request from the Bitbucket API
func (c *Config) getPullRequestCommitsFromBitbucket(prID int) ([]types.Commit, error) {
url, err := url.JoinPath("https://api.bitbucket.org/2.0/repositories", c.Workspace, c.Repository, "pullrequests", strconv.Itoa(prID), "commits")
Expand Down
22 changes: 22 additions & 0 deletions internal/bitbucket/branch_mapping_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package bitbucket

import (
"testing"

"github.com/stretchr/testify/require"
)

// TestBitbucketBranchName verifies head (source) and base (destination) branch
// extraction from a Bitbucket PR response (server#5892).
func TestBitbucketBranchName(t *testing.T) {
prData := map[string]any{
"source": map[string]any{
"branch": map[string]any{"name": "feature-branch"},
},
"destination": map[string]any{
"branch": map[string]any{"name": "main"},
},
}
require.Equal(t, "feature-branch", bitbucketBranchName(prData, "source"))
require.Equal(t, "main", bitbucketBranchName(prData, "destination"))
}
75 changes: 73 additions & 2 deletions internal/github/build_pr_evidence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func TestBuildPREvidence_RecordsAuthorNotCommitter(t *testing.T) {
"",
"Introduce kosli evaluate",
"introduce-kosli-evaluate",
"main",
[]graphqlCommitNode{node},
nil,
)
Expand Down Expand Up @@ -69,7 +70,7 @@ func TestBuildPREvidence_UsesAuthoredDate(t *testing.T) {
"https://github.com/kosli-dev/cli/pull/671",
"0e723254516c841126e81f76100be57258ff1386",
"MERGED", "tooky", "2026-03-01T09:00:00Z", "",
"Introduce kosli evaluate", "introduce-kosli-evaluate",
"Introduce kosli evaluate", "introduce-kosli-evaluate", "main",
[]graphqlCommitNode{node}, nil,
)
require.NoError(t, err)
Expand All @@ -95,7 +96,7 @@ func TestBuildPREvidence_FallsBackToCommittedDate(t *testing.T) {
"https://github.com/kosli-dev/cli/pull/671",
"0e723254516c841126e81f76100be57258ff1386",
"MERGED", "tooky", "2026-03-01T09:00:00Z", "",
"title", "branch",
"title", "branch", "main",
[]graphqlCommitNode{node}, nil,
)
require.NoError(t, err)
Expand All @@ -105,3 +106,73 @@ func TestBuildPREvidence_FallsBackToCommittedDate(t *testing.T) {
require.Equal(t, wantCommitted.Unix(), evidence.Commits[0].Timestamp,
"timestamp must fall back to the committed date when authored date is absent")
}

// TestBuildPREvidence_RecordsBaseRef verifies the PR's base (target) branch is
// captured, enabling a "merged into main" policy (server#5892).
func TestBuildPREvidence_RecordsBaseRef(t *testing.T) {
evidence, err := buildPREvidence(
"https://github.com/kosli-dev/cli/pull/671",
"0e723254516c841126e81f76100be57258ff1386",
"MERGED", "tooky", "2026-03-01T09:00:00Z", "",
"title", "feature-branch", "main",
nil, nil,
)
require.NoError(t, err)
require.Equal(t, "main", evidence.BaseRef,
"base_ref must record the PR target branch")
}

// TestBuildPREvidence_RecordsCommitSignature verifies a verified commit
// signature is captured (server#5892, control 1.13).
func TestBuildPREvidence_RecordsCommitSignature(t *testing.T) {
node := graphqlCommitNode{}
node.Commit.Oid = "0e723254516c841126e81f76100be57258ff1386"
node.Commit.MessageHeadline = "signed work"
node.Commit.CommittedDate = "2026-03-01T12:00:00Z"
node.Commit.Author.Name = "Steve Tooke"
node.Commit.Author.Email = "tooky@kosli.com"
node.Commit.Signature = &struct {
IsValid graphql.Boolean
State graphql.String
}{IsValid: true, State: "VALID"}

evidence, err := buildPREvidence(
"https://github.com/kosli-dev/cli/pull/671",
"0e723254516c841126e81f76100be57258ff1386",
"MERGED", "tooky", "2026-03-01T09:00:00Z", "",
"title", "feature", "main",
[]graphqlCommitNode{node}, nil,
)
require.NoError(t, err)
require.Len(t, evidence.Commits, 1)
c := evidence.Commits[0]
require.NotNil(t, c.Verified, "verified must be populated for a signed commit")
require.True(t, *c.Verified, "verified must be true for a valid signature")
require.NotNil(t, c.SignatureState)
require.Equal(t, "VALID", *c.SignatureState)
}

// TestBuildPREvidence_UnsignedCommitHasNoSignatureFields verifies an unsigned
// commit (no signature node) leaves verified/signature_state nil, so "unsigned"
// stays distinct from "present-but-invalid" (verified=false).
func TestBuildPREvidence_UnsignedCommitHasNoSignatureFields(t *testing.T) {
node := graphqlCommitNode{}
node.Commit.Oid = "0e723254516c841126e81f76100be57258ff1386"
node.Commit.MessageHeadline = "unsigned work"
node.Commit.CommittedDate = "2026-03-01T12:00:00Z"
node.Commit.Author.Name = "Steve Tooke"
node.Commit.Author.Email = "tooky@kosli.com"
// node.Commit.Signature left nil — unsigned commit

evidence, err := buildPREvidence(
"https://github.com/kosli-dev/cli/pull/671",
"0e723254516c841126e81f76100be57258ff1386",
"MERGED", "tooky", "2026-03-01T09:00:00Z", "",
"title", "feature", "main",
[]graphqlCommitNode{node}, nil,
)
require.NoError(t, err)
require.Len(t, evidence.Commits, 1)
require.Nil(t, evidence.Commits[0].Verified, "unsigned commit must leave verified nil")
require.Nil(t, evidence.Commits[0].SignatureState)
}
26 changes: 23 additions & 3 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ type graphqlCommitNode struct {
Login graphql.String
}
}
Signature *struct {
IsValid graphql.Boolean
State graphql.String
}
}
}

Expand All @@ -288,7 +292,7 @@ type graphqlReviewNode struct {
// raw GraphQL commit/review nodes. mergeCommit must be resolved by the caller
// (it differs between commit-SHA queries and PR-number queries).
func buildPREvidence(
url, mergeCommit, state, author, createdAtStr, mergedAtStr, title, headRef string,
url, mergeCommit, state, author, createdAtStr, mergedAtStr, title, headRef, baseRef string,
commitNodes []graphqlCommitNode,
reviewNodes []graphqlReviewNode,
) (*types.PREvidence, error) {
Expand All @@ -314,6 +318,7 @@ func buildPREvidence(
MergedAt: mergedAt,
Title: title,
HeadRef: headRef,
BaseRef: baseRef,
Approvers: []any{},
Commits: []types.Commit{},
}
Expand All @@ -333,6 +338,17 @@ func buildPREvidence(
if n.Commit.Author.User != nil {
authorUsername = string(n.Commit.Author.User.Login)
}
// Capture the commit signature when present. A nil signature node means
// the commit is unsigned, which must stay distinct from a present but
// invalid signature (verified=false) — so leave the fields nil (server#5892).
var verified *bool
var signatureState *string
if n.Commit.Signature != nil {
v := bool(n.Commit.Signature.IsValid)
s := string(n.Commit.Signature.State)
verified = &v
signatureState = &s
}
evidence.Commits = append(evidence.Commits, types.Commit{
SHA: string(n.Commit.Oid),
Message: string(n.Commit.MessageHeadline),
Expand All @@ -341,6 +357,8 @@ func buildPREvidence(
Timestamp: timestamp.Unix(),
Branch: headRef,
URL: string(n.Commit.URL),
Verified: verified,
SignatureState: signatureState,
})
}

Expand Down Expand Up @@ -377,6 +395,7 @@ func (c *GithubConfig) PREvidenceByPRNumber(prNumber int) (*types.PREvidence, er
Title graphql.String
State graphql.String
HeadRefName graphql.String
BaseRefName graphql.String
URL graphql.String
CreatedAt graphql.String
MergedAt graphql.String
Expand Down Expand Up @@ -442,7 +461,7 @@ func (c *GithubConfig) PREvidenceByPRNumber(prNumber int) (*types.PREvidence, er

return buildPREvidence(
string(pr.URL), mergeCommit, string(pr.State), string(pr.Author.Login),
string(pr.CreatedAt), string(pr.MergedAt), string(pr.Title), string(pr.HeadRefName),
string(pr.CreatedAt), string(pr.MergedAt), string(pr.Title), string(pr.HeadRefName), string(pr.BaseRefName),
pr.Commits.Nodes, pr.Reviews.Nodes,
)
}
Expand All @@ -469,6 +488,7 @@ func (c *GithubConfig) PREvidenceForCommitV2(commit string) ([]*types.PREvidence
Title graphql.String
State graphql.String
HeadRefName graphql.String
BaseRefName graphql.String
URL graphql.String
CreatedAt graphql.String
MergedAt graphql.String
Expand Down Expand Up @@ -522,7 +542,7 @@ func (c *GithubConfig) PREvidenceForCommitV2(commit string) ([]*types.PREvidence
// so the commit is by definition the merge commit.
evidence, err := buildPREvidence(
string(pr.URL), commit, string(pr.State), string(pr.Author.Login),
string(pr.CreatedAt), string(pr.MergedAt), string(pr.Title), string(pr.HeadRefName),
string(pr.CreatedAt), string(pr.MergedAt), string(pr.Title), string(pr.HeadRefName), string(pr.BaseRefName),
pr.Commits.Nodes, pr.Reviews.Nodes,
)
if err != nil {
Expand Down
65 changes: 65 additions & 0 deletions internal/gitlab/commit_mapping_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package gitlab

import (
"errors"
"net/http"
"testing"
"time"

Expand Down Expand Up @@ -70,3 +72,66 @@ func TestCommitFromGitlabCommit_FallsBackToCreatedAt(t *testing.T) {
require.Equal(t, int64(1772635812), c.Timestamp,
"timestamp must fall back to created_at when authored date is absent")
}

// TestGitlabCommitVerification maps GitLab's verification_status to the neutral
// verified/signature_state fields (server#5892). verified is true only for a
// cryptographically valid signature; a non-"verified" status records
// verified=false (distinct from unsigned), and an empty status leaves both nil.
func TestGitlabCommitVerification(t *testing.T) {
verified, state := gitlabCommitVerification("verified")
require.NotNil(t, verified)
require.True(t, *verified)
require.NotNil(t, state)
require.Equal(t, "verified", *state)

verified, state = gitlabCommitVerification("unverified")
require.NotNil(t, verified)
require.False(t, *verified, "a non-'verified' status must record verified=false, not nil")
require.Equal(t, "unverified", *state)

verified, state = gitlabCommitVerification("")
require.Nil(t, verified, "empty status (unsigned) must leave verified nil")
require.Nil(t, state)
}

// TestResolveGitlabSignature covers the unsigned (404), fatal-error, and
// verified/unverified outcomes of a GetGPGSignature call (server#5892).
func TestResolveGitlabSignature(t *testing.T) {
notFound := &gitlab.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}
verified, state, err := resolveGitlabSignature(nil, notFound, errors.New("404"))
require.NoError(t, err, "404 (unsigned commit) must not be a fatal error")
require.Nil(t, verified, "unsigned commit must leave verified nil")
require.Nil(t, state)

serverErr := &gitlab.Response{Response: &http.Response{StatusCode: http.StatusInternalServerError}}
_, _, err = resolveGitlabSignature(nil, serverErr, errors.New("boom"))
require.Error(t, err, "a non-404 error must propagate")

_, _, err = resolveGitlabSignature(nil, nil, errors.New("transport failure"))
require.Error(t, err, "an error with no response must propagate")

verified, state, err = resolveGitlabSignature(
&gitlab.GPGSignature{VerificationStatus: "verified"}, &gitlab.Response{}, nil,
)
require.NoError(t, err)
require.NotNil(t, verified)
require.True(t, *verified)
require.Equal(t, "verified", *state)

verified, _, err = resolveGitlabSignature(
&gitlab.GPGSignature{VerificationStatus: "unverified"}, &gitlab.Response{}, nil,
)
require.NoError(t, err)
require.NotNil(t, verified)
require.False(t, *verified, "a non-'verified' status must record verified=false")
}

// TestGitlabMRRefs verifies the head (source) / base (target) branch mapping.
func TestGitlabMRRefs(t *testing.T) {
head, base := gitlabMRRefs(&gitlab.BasicMergeRequest{
SourceBranch: "feature-branch",
TargetBranch: "main",
})
require.Equal(t, "feature-branch", head)
require.Equal(t, "main", base)
}
Loading