Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
072fb2f
test: add helpers for feeding PR evidence code replies with null fields
AlexKantor87 Oct 21, 2026
21cc91e
fix(bitbucket): never crash on a null field in a Bitbucket reply
AlexKantor87 Oct 21, 2026
02292ec
Merge remote-tracking branch 'origin/main' into claude/1283-bitbucket
AlexKantor87 Oct 22, 2026
95569e7
fix(deps): build with Go 1.26.10 and pin the builder image by digest
AlexKantor87 Oct 22, 2026
a052839
fix(bitbucket): reject nameless approvals and commit pages that don't…
AlexKantor87 Oct 22, 2026
bba2e60
test: fail when the Dockerfile builds with a different Go than go.mod
AlexKantor87 Oct 22, 2026
b49f8b9
fix(bitbucket): leave nameless approvals out of approvers, keep them …
AlexKantor87 Oct 22, 2026
747773f
fix(bitbucket): cap commit paging so cycling next links end in an error
AlexKantor87 Oct 22, 2026
5b6d96b
Merge remote-tracking branch 'origin/claude/go-1.26.10' into claude/1…
AlexKantor87 Oct 23, 2026
507500b
Merge branch 'main' of https://github.com/kosli-dev/cli into claude/1…
AlexKantor87 Oct 23, 2026
423653d
fix(bitbucket): leave nameless approvals and comments out of reviews
AlexKantor87 Oct 25, 2026
224e223
test(bitbucket): check the whole evidence when a participant has no name
AlexKantor87 Oct 25, 2026
e20143f
fix(bitbucket): name the commit or pull request in null-entry errors
AlexKantor87 Oct 25, 2026
c2eb470
fix(bitbucket): error on a commit with no hash
AlexKantor87 Oct 25, 2026
b52ca3a
fix(bitbucket): number commits across pages; name a PR by its API URL
AlexKantor87 Oct 25, 2026
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
173 changes: 112 additions & 61 deletions internal/bitbucket/bitbucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import (
"github.com/kosli-dev/cli/internal/types"
)

// defaultMaxPages bounds commit paging so next links that cycle end in an
// error. It is set far above the pages a real pull request needs.
const defaultMaxPages = 1000

type Config struct {
Username string
Password string
Expand All @@ -26,14 +30,51 @@ type Config struct {

// parseRFC3339NanoTimestamp parses a timestamp string in RFC3339Nano format and returns its Unix timestamp.
// The fieldName parameter is used for error messages to identify which field failed to parse.
// An empty string gives 0: the field was missing.
func parseRFC3339NanoTimestamp(timestampStr, fieldName string) (int64, error) {
if timestampStr == "" {
return 0, nil
}
parsedTime, err := time.Parse(time.RFC3339Nano, timestampStr)
if err != nil {
return 0, fmt.Errorf("failed to parse %s timestamp: %w", fieldName, err)
}
return parsedTime.Unix(), nil
}

// str, obj and boolean read a field from a decoded JSON object, giving the
// zero value when it is missing, null or of another type.
func str(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}

func obj(m map[string]any, key string) map[string]any {
v, _ := m[key].(map[string]any)
return v
}

func boolean(m map[string]any, key string) bool {
v, _ := m[key].(bool)
return v
}

// objects reads a list of objects. A missing list is empty, but an item that
// is not an object is an error: it is an entry we can't read. owner says what
// the list belongs to.
func objects(m map[string]any, key, owner string) ([]map[string]any, error) {
items, _ := m[key].([]any)
out := make([]map[string]any, len(items))
for i, item := range items {
o, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s[%d] for %s in the Bitbucket reply is null or not an object", key, i, owner)
}
out[i] = o
}
return out, nil
}

func (c *Config) ProviderAndLabel() (string, string) {
return "bitbucket", "pull request"
}
Expand Down Expand Up @@ -97,16 +138,17 @@ func (c *Config) parseBitbucketResponse(commit string, response *requests.HTTPRe
if err != nil {
return pullRequestsEvidence, err
}
pullRequests, ok := responseData["values"].([]any)
if !ok {
return pullRequestsEvidence, nil
pullRequests, err := objects(responseData, "values", "commit "+commit)
if err != nil {
return pullRequestsEvidence, err
}
for _, prInterface := range pullRequests {
pr := prInterface.(map[string]any)
linksInterface := pr["links"].(map[string]any)
apiLinkMap := linksInterface["self"].(map[string]any)
htmlLinkMap := linksInterface["html"].(map[string]any)
evidence, err := c.getPullRequestDetailsFromBitbucket(apiLinkMap["href"].(string), htmlLinkMap["href"].(string), commit, version)
for _, pr := range pullRequests {
links := obj(pr, "links")
apiURL := str(obj(links, "self"), "href")
if apiURL == "" {
return pullRequestsEvidence, fmt.Errorf("a pull request in the Bitbucket reply has no links.self.href")
}
evidence, err := c.getPullRequestDetailsFromBitbucket(apiURL, str(obj(links, "html"), "href"), commit, version)
if err != nil {
return pullRequestsEvidence, err
}
Expand Down Expand Up @@ -141,25 +183,26 @@ func (c *Config) getPullRequestDetailsFromBitbucket(prApiUrl, prHtmlLink, commit

evidence.URL = prHtmlLink
evidence.MergeCommit = commit
evidence.State = responseData["state"].(string)
participants := responseData["participants"].([]any)
evidence.State = str(responseData, "state")
participants, err := objects(responseData, "participants", "pull request "+prApiUrl)
if err != nil {
return evidence, err
}
approvers := []any{}

if len(participants) > 0 {
for _, participantInterface := range participants {
p := participantInterface.(map[string]any)
if p["approved"].(bool) {
user := p["user"].(map[string]any)
for _, p := range participants {
if username := approvedBy(p); username != "" {
if version == 1 {
approvers = append(approvers, user["display_name"].(string))
approvers = append(approvers, username)
} else {
approvalTimestamp, err := parseRFC3339NanoTimestamp(p["participated_on"].(string), "participated_on")
approvalTimestamp, err := parseRFC3339NanoTimestamp(str(p, "participated_on"), "participated_on")
if err != nil {
return evidence, err
}
approvers = append(approvers, types.PRApprovals{
Username: user["display_name"].(string),
State: p["state"].(string),
Username: username,
State: str(p, "state"),
Timestamp: approvalTimestamp,
})
}
Expand All @@ -170,22 +213,26 @@ func (c *Config) getPullRequestDetailsFromBitbucket(prApiUrl, prHtmlLink, commit
c.Logger.Debug("no approvers found")
}
if version == 2 {
evidence.Author = responseData["author"].(map[string]any)["display_name"].(string)
createdAt, err := parseRFC3339NanoTimestamp(responseData["created_on"].(string), "created_on")
evidence.Author = str(obj(responseData, "author"), "display_name")
createdAt, err := parseRFC3339NanoTimestamp(str(responseData, "created_on"), "created_on")
if err != nil {
return evidence, err
}
evidence.CreatedAt = createdAt
mergedAt, err := parseRFC3339NanoTimestamp(responseData["updated_on"].(string), "updated_on")
mergedAt, err := parseRFC3339NanoTimestamp(str(responseData, "updated_on"), "updated_on")
if err != nil {
return evidence, err
}
evidence.MergedAt = mergedAt
evidence.Title = responseData["title"].(string)
evidence.Title = str(responseData, "title")
evidence.HeadRef = bitbucketBranchName(responseData, "source")
evidence.BaseRef = bitbucketBranchName(responseData, "destination")

prCommits, err := c.getPullRequestCommitsFromBitbucket(int(responseData["id"].(float64)))
prID, ok := responseData["id"].(float64)
if !ok {
return evidence, fmt.Errorf("the pull request at %s has no id", prApiUrl)
}
prCommits, err := c.getPullRequestCommitsFromBitbucket(int(prID))
if err != nil {
return evidence, err
}
Expand All @@ -202,16 +249,24 @@ func (c *Config) getPullRequestDetailsFromBitbucket(prApiUrl, prHtmlLink, commit
return evidence, nil
}

// approvedBy names who approved in participant p, or gives "" when p is no
// approval. An approval with no identity, as from a deleted account, counts
// as none: approval evidence must name who gave it.
func approvedBy(p map[string]any) string {
if !boolean(p, "approved") {
return ""
}
return str(obj(p, "user"), "display_name")
}

// 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) {
func bitbucketReviews(participants []map[string]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)
for _, p := range participants {
username := str(obj(p, "user"), "display_name")
participatedOn := str(p, "participated_on")
var state string
switch p["state"] {
case "approved":
Expand All @@ -231,26 +286,23 @@ func bitbucketReviews(participants []any) ([]types.PRApprovals, error) {
if username == "" && state != types.ReviewChangesRequested {
continue
}
review := types.PRApprovals{
Username: username,
State: state,
}
if participatedOn != "" {
timestamp, err := parseRFC3339NanoTimestamp(participatedOn, "participated_on")
if err != nil {
return nil, err
}
review.Timestamp = timestamp
timestamp, err := parseRFC3339NanoTimestamp(participatedOn, "participated_on")
if err != nil {
return nil, err
}
reviews = append(reviews, review)
reviews = append(reviews, types.PRApprovals{
Username: username,
State: state,
Timestamp: timestamp,
})
}
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 {
return prData[side].(map[string]any)["branch"].(map[string]any)["name"].(string)
return str(obj(obj(prData, side), "branch"), "name")
}

// getPullRequestCommitsFromBitbucket gets the commits of a pull request from the Bitbucket API
Expand All @@ -264,7 +316,10 @@ func (c *Config) getPullRequestCommitsFromBitbucket(prID int) ([]types.Commit, e
allCommits := []types.Commit{}
currentURL := url

for currentURL != "" {
for pages := 0; currentURL != ""; pages++ {
if pages == defaultMaxPages {
return nil, fmt.Errorf("aborting after %d pages of commits for pull request %d", defaultMaxPages, prID)
}
reqParams := &requests.RequestParams{
Method: http.MethodGet,
URL: currentURL,
Expand All @@ -286,36 +341,32 @@ func (c *Config) getPullRequestCommitsFromBitbucket(prID int) ([]types.Commit, e
return nil, err
}

commits, ok := responseData["values"].([]any)
if !ok {
break
commits, err := objects(responseData, "values", fmt.Sprintf("pull request %d, page %d", prID, pages+1))
if err != nil {
return nil, err
}

for _, commitInterface := range commits {
commit := commitInterface.(map[string]any)
timestamp, err := parseRFC3339NanoTimestamp(commit["date"].(string), "date")
for _, commit := range commits {
if str(commit, "hash") == "" {
return nil, fmt.Errorf("commit %d for pull request %d has no hash", len(allCommits), prID)
}
timestamp, err := parseRFC3339NanoTimestamp(str(commit, "date"), "date")
if err != nil {
Comment thread
AlexKantor87 marked this conversation as resolved.
return nil, err
}
allCommits = append(allCommits, types.Commit{
SHA: commit["hash"].(string),
Message: commit["message"].(string),
Author: commit["author"].(map[string]any)["raw"].(string),
SHA: str(commit, "hash"),
Message: str(commit, "message"),
Author: str(obj(commit, "author"), "raw"),
Timestamp: timestamp,
URL: commit["links"].(map[string]any)["html"].(map[string]any)["href"].(string),
URL: str(obj(obj(commit, "links"), "html"), "href"),
})
}

// Check for next page
nextInterface, hasNext := responseData["next"]
if !hasNext {
break
}
nextURL, ok := nextInterface.(string)
if !ok || nextURL == "" {
currentURL = str(responseData, "next")
if currentURL == "" {
break
}
currentURL = nextURL
c.Logger.Debug("fetching next page of commits from %s", currentURL)
}

Expand Down
Loading
Loading