Skip to content

Commit 02be552

Browse files
authored
Merge pull request #60 from gildas/release/0.17.6
Merge release/0.17.6
2 parents 0751b12 + 0fa64d3 commit 02be552

File tree

16 files changed

+609
-120
lines changed

16 files changed

+609
-120
lines changed

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,18 @@ By default, the password or client secret is stored in the vault of the operatin
183183

184184
Profiles support the following authentications:
185185

186-
- [OAuth 2.0 with Authorization Code Grant](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#1--authorization-code-grant--4-1-) with the `--client-id`, `--client-secret`, and `--callback-port` flags
187-
- [OAuth 2.0 with Client Credentials](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#3--client-credentials-grant--4-4-) with the `--client-id` and `--client-secret` flags
188-
- [App passwords](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) with the `--user` and `--password` flags.
186+
- [OAuth 2.0 with Authorization Code Grant](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#1--authorization-code-grant--4-1-) with the `--client-id`, `--client-secret`, and `--callback-port` flags.
187+
- [OAuth 2.0 with Client Credentials](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#3--client-credentials-grant--4-4-) with the `--client-id` and `--client-secret` flags.
188+
- [API tokens](https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/) with the `--user` and `--password` flags. The user is the **Atlassian account email** and the password is the API token in this case.
189+
- ~~[App passwords](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) with the `--user` and `--password` flags.~~ [App passwords are deprecated by Atlassian in favour of API tokens as of June 9, 2025 and will stop working entirely on June 9, 2026](https://www.atlassian.com/blog/bitbucket/bitbucket-cloud-transitions-to-api-tokens-enhancing-security-with-app-password-deprecation). Use API tokens instead.
189190
- [Repository Access Tokens](https://support.atlassian.com/bitbucket-cloud/docs/repository-access-tokens/), [Project Access Tokens](https://support.atlassian.com/bitbucket-cloud/docs/project-access-tokens/), [Workspace Access Tokens](https://support.atlassian.com/bitbucket-cloud/docs/workspace-access-tokens/) with the `--access-token` flags.
190191

191-
When you use a username/password, the password is stored in the vault of the operating system (Windows Credential Manager, macOS Keychain, or Linux Secret Service). You can pass the `--no-vault` flag to disable this feature and store the password in plain text in the configuration file. This is not recommended, but can be useful for testing purposes. On Linux and macOS, you can also pass the `--vault-key` flag to set the key to use in the system keychain. By default, the key is `bitbucket-cli`. On Windows, this option is not available.
192+
Permission Scopes:
193+
194+
- [OAuth 2.0 scopes](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#bitbucket-oauth-2-0-scopes)
195+
- [API token permissions](https://support.atlassian.com/bitbucket-cloud/docs/api-token-permissions/)
196+
197+
When you use a user/password, the password is stored in the vault of the operating system (Windows Credential Manager, macOS Keychain, or Linux Secret Service). You can pass the `--no-vault` flag to disable this feature and store the password in plain text in the configuration file. This is not recommended, but can be useful for testing purposes. On Linux and macOS, you can also pass the `--vault-key` flag to set the key to use in the system keychain. By default, the key is `bitbucket-cli`. On Windows, this option is not available.
192198

193199
You can also pass the `--clone-protocol` flag to set the default protocol to use when cloning repositories. The supported protocols are `https`, `git`, and `ssh`. This option can be overridden with the `--protocol` flag when using `repo clone`.
194200

@@ -840,6 +846,8 @@ bb pipeline list
840846

841847
By default the current repository is used, you can specify a repository with the `--repository` flag.
842848

849+
If there are too many pipelines and `bb pipeline list` seems to hang, you can use the `--limit` flag to set the maximum number of pipelines to retrieve. By default, the limit is set to 0, which means no limit.
850+
843851
You can get the details of a pipeline with the `bb pipeline get` or `bb pipeline show` command:
844852

845853
```bash

cmd/pipeline/list.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ var listOptions struct {
2525
Columns *flags.EnumSliceFlag
2626
SortBy *flags.EnumFlag
2727
PageLength int
28+
Limit int
2829
}
2930

3031
func init() {
@@ -35,8 +36,9 @@ func init() {
3536
listCmd.Flags().StringVar(&listOptions.Repository, "repository", "", "Repository to list pipelines from. Defaults to the current repository")
3637
listCmd.Flags().StringVar(&listOptions.Query, "query", "", "Query string to filter pipelines")
3738
listCmd.Flags().Var(listOptions.Columns, "columns", "Comma-separated list of columns to display")
38-
listCmd.Flags().Var(listOptions.SortBy, "sort", "Column to sort by")
39+
listCmd.Flags().Var(listOptions.SortBy, "sort", "Column to sort by (applies a local sort on fetched results; server-side sort is always by creation date descending)")
3940
listCmd.Flags().IntVar(&listOptions.PageLength, "page-length", 0, "Number of items per page to retrieve from Bitbucket. Default is the profile's default page length")
41+
listCmd.Flags().IntVar(&listOptions.Limit, "limit", 0, "Maximum total number of items to retrieve. 0 means no limit")
4042
_ = listCmd.RegisterFlagCompletionFunc(listOptions.Columns.CompletionFunc("columns"))
4143
_ = listCmd.RegisterFlagCompletionFunc(listOptions.SortBy.CompletionFunc("sort"))
4244
}
@@ -48,9 +50,9 @@ func listProcess(cmd *cobra.Command, args []string) (err error) {
4850
return errors.ArgumentMissing.With("profile")
4951
}
5052

51-
uripath := "pipelines"
53+
uripath := "pipelines?sort=-created_on"
5254
if len(listOptions.Query) > 0 {
53-
uripath = fmt.Sprintf("pipelines?q=%s", url.QueryEscape(listOptions.Query))
55+
uripath = fmt.Sprintf("pipelines?sort=-created_on&q=%s", url.QueryEscape(listOptions.Query))
5456
}
5557

5658
log.Infof("Listing pipelines for repository: %s", listOptions.Repository)
@@ -63,6 +65,8 @@ func listProcess(cmd *cobra.Command, args []string) (err error) {
6365
fmt.Println("No pipelines found")
6466
return nil
6567
}
66-
core.Sort(pipelines, columns.SortBy(listOptions.SortBy.Value))
68+
if cmd.Flag("sort").Changed {
69+
core.Sort(pipelines, columns.SortBy(listOptions.SortBy.Value))
70+
}
6771
return profile.Current.Print(cmd.Context(), cmd, Pipelines(pipelines))
6872
}

cmd/profile/error.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"github.com/gildas/go-errors"
88
)
99

10+
// BitBucketError represents an error returned by the BitBucket API
11+
//
12+
// See: https://developer.atlassian.com/cloud/bitbucket/rest/intro/#standardized-error-responses
1013
type BitBucketError struct {
1114
Type string `json:"type"`
1215
Message string `json:"-"`

cmd/profile/error_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func (suite *ProfileSuite) TestCanUnmarshalErrorAboutPrivileges() {
1414
suite.Assert().Contains(bberr.Fields["required"], "project")
1515
suite.Require().Contains(bberr.Fields, "granted")
1616
suite.Assert().Contains(bberr.Fields["granted"], "account")
17+
suite.T().Logf("Error string: %s", bberr.Error())
1718
}
1819

1920
func (suite *ProfileSuite) TestCanUnmarshalErrorAboutNoAPI() {
@@ -24,6 +25,7 @@ func (suite *ProfileSuite) TestCanUnmarshalErrorAboutNoAPI() {
2425
suite.Assert().Equal("error", bberr.Type)
2526
suite.Assert().Equal("Resource not found", bberr.Message)
2627
suite.Assert().Equal("There is no API hosted at this URL", bberr.Detail)
28+
suite.T().Logf("Error string: %s", bberr.Error())
2729
}
2830

2931
func (suite *ProfileSuite) TestCanUnmarshalErrorAboutBadRequest() {
@@ -36,4 +38,5 @@ func (suite *ProfileSuite) TestCanUnmarshalErrorAboutBadRequest() {
3638
suite.Require().Len(bberr.Fields, 1)
3739
suite.Require().Contains(bberr.Fields, "links.avatar")
3840
suite.Assert().Contains(bberr.Fields["links.avatar"], "required key not provided")
41+
suite.T().Logf("Error string: %s", bberr.Error())
3942
}

cmd/profile/profile_client.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ func GetAll[T any](context context.Context, cmd *cobra.Command, uripath string)
9696
}
9797
}
9898

99+
limit := 0
100+
if cmd != nil && cmd.Flag("limit") != nil && cmd.Flag("limit").Changed {
101+
if l, err := cmd.Flags().GetInt("limit"); err == nil && l > 0 {
102+
limit = l
103+
log.Debugf("Using limit of %d from the command line flags", limit)
104+
}
105+
}
106+
107+
if limit > 0 && (pageLength == 0 || limit < pageLength) {
108+
pageLength = limit
109+
}
110+
99111
if !strings.Contains(uripath, "pagelen") && pageLength > 0 {
100112
if strings.Contains(uripath, "?") {
101113
uripath = fmt.Sprintf("%s&pagelen=%d", uripath, pageLength)
@@ -104,7 +116,16 @@ func GetAll[T any](context context.Context, cmd *cobra.Command, uripath string)
104116
}
105117
}
106118

107-
log.Infof("Getting all resources for profile %s (%d at a time)", profile.Name, pageLength)
119+
originalQuery := url.Values{}
120+
if parsed, err := url.Parse(uripath); err == nil {
121+
originalQuery = parsed.Query()
122+
}
123+
124+
if limit > 0 {
125+
log.Infof("Getting up to %d resources for profile %s (%d at a time)", limit, profile.Name, pageLength)
126+
} else {
127+
log.Infof("Getting all resources for profile %s (%d at a time)", profile.Name, pageLength)
128+
}
108129
for {
109130
var paginated PaginatedResources[T]
110131

@@ -118,13 +139,38 @@ func GetAll[T any](context context.Context, cmd *cobra.Command, uripath string)
118139
return nil, err
119140
}
120141
resources = append(resources, paginated.Values...)
121-
log.Debugf("Got %d resources", len(paginated.Values))
142+
if limit > 0 && len(resources) >= limit {
143+
resources = resources[:limit]
144+
break
145+
}
146+
log.Debugf("Got %d resources (total: %d)", len(paginated.Values), len(resources))
122147
log.Debugf("Next page: %s", paginated.Next)
123148
log.Debugf("Previous page: %s", paginated.Previous)
124149
if len(paginated.Next) == 0 {
125150
break
126151
}
127-
uripath = paginated.Next
152+
153+
nextURL, parseErr := url.Parse(paginated.Next)
154+
if parseErr != nil {
155+
return nil, parseErr
156+
}
157+
nextQuery := nextURL.Query()
158+
for key, values := range originalQuery {
159+
if _, exists := nextQuery[key]; !exists {
160+
for _, value := range values {
161+
nextQuery.Add(key, value)
162+
}
163+
}
164+
}
165+
if limit > 0 {
166+
remaining := limit - len(resources)
167+
if remaining < pageLength {
168+
// Adjust pagelen on the next URL to only fetch what we still need
169+
nextQuery.Set("pagelen", fmt.Sprintf("%d", remaining))
170+
}
171+
}
172+
nextURL.RawQuery = nextQuery.Encode()
173+
uripath = nextURL.String()
128174
}
129175
return resources, nil
130176
}
@@ -415,7 +461,10 @@ func (profile *Profile) send(context context.Context, cmd *cobra.Command, option
415461
if result != nil {
416462
var bberr *BitBucketError
417463
if jerr := result.UnmarshalContentJSON(&bberr); jerr == nil {
464+
log.Warnf("We have a BitBucketError: %#+v", bberr)
418465
return result, bberr
466+
} else {
467+
log.Debugf("the Error %s is not a bitbucket error: %s", err.Error(), jerr.Error())
419468
}
420469
}
421470
}

cmd/profile/profile_client_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package profile_test
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
9+
"bitbucket.org/gildas_cherruel/bb/cmd/profile"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
type testItem struct {
14+
ID string `json:"id"`
15+
}
16+
17+
func (suite *ProfileSuite) TestGetAll_OriginalQueryIsPreservedForNextMissingParams() {
18+
oldCurrent := profile.Current
19+
defer func() { profile.Current = oldCurrent }()
20+
21+
const filter = `target.ref_name="my-branch"`
22+
var server *httptest.Server
23+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24+
q := r.URL.Query().Get("q")
25+
if r.URL.Path == "/pipelines" {
26+
if r.URL.Query().Get("page") == "" {
27+
suite.Assert().Equal(filter, q, "initial request should include original q")
28+
resp := map[string]interface{}{
29+
"values": []map[string]string{{"id": "1"}},
30+
"next": server.URL + "/pipelines?page=2&pagelen=1",
31+
}
32+
_ = json.NewEncoder(w).Encode(resp)
33+
return
34+
}
35+
suite.Assert().Equal(filter, q, "second request should include original q even when next omits it")
36+
resp := map[string]interface{}{
37+
"values": []map[string]string{{"id": "2"}},
38+
}
39+
_ = json.NewEncoder(w).Encode(resp)
40+
return
41+
}
42+
w.WriteHeader(http.StatusNotFound)
43+
}))
44+
defer server.Close()
45+
46+
apiRoot, err := url.Parse(server.URL)
47+
suite.Require().NoError(err)
48+
profile.Current = &profile.Profile{APIRoot: apiRoot, DefaultPageLength: 0, AccessToken: "fake-token"}
49+
50+
cmd := &cobra.Command{}
51+
cmd.Flags().String("profile", "", "")
52+
cmd.Flags().Int("page-length", 0, "")
53+
items, err := profile.GetAll[testItem](suite.Context, cmd, server.URL+"/pipelines?pagelen=1&q="+url.QueryEscape(filter))
54+
suite.Require().NoError(err)
55+
suite.Require().Len(items, 2)
56+
suite.Require().Equal("1", items[0].ID)
57+
suite.Require().Equal("2", items[1].ID)
58+
}
59+
60+
func (suite *ProfileSuite) TestGetAll_DoesNotOverwriteExistingNextParams() {
61+
oldCurrent := profile.Current
62+
defer func() { profile.Current = oldCurrent }()
63+
64+
const originalFilter = `target.ref_name="original"`
65+
const nextFilter = `target.ref_name="different"`
66+
var server *httptest.Server
67+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68+
q := r.URL.Query().Get("q")
69+
if r.URL.Path == "/pipelines" {
70+
if r.URL.Query().Get("page") == "" {
71+
suite.Assert().Equal(originalFilter, q, "initial request should include original q")
72+
resp := map[string]interface{}{
73+
"values": []map[string]string{{"id": "1"}},
74+
"next": server.URL + "/pipelines?page=2&pagelen=1&q=" + url.QueryEscape(nextFilter),
75+
}
76+
_ = json.NewEncoder(w).Encode(resp)
77+
return
78+
}
79+
suite.Assert().Equal(nextFilter, q, "existing q on next URL must not be overwritten")
80+
resp := map[string]interface{}{
81+
"values": []map[string]string{{"id": "2"}},
82+
}
83+
_ = json.NewEncoder(w).Encode(resp)
84+
return
85+
}
86+
w.WriteHeader(http.StatusNotFound)
87+
}))
88+
defer server.Close()
89+
90+
apiRoot, err := url.Parse(server.URL)
91+
suite.Require().NoError(err)
92+
profile.Current = &profile.Profile{APIRoot: apiRoot, DefaultPageLength: 0, AccessToken: "fake-token"}
93+
94+
cmd := &cobra.Command{}
95+
cmd.Flags().String("profile", "", "")
96+
cmd.Flags().Int("page-length", 0, "")
97+
items, err := profile.GetAll[testItem](suite.Context, cmd, server.URL+"/pipelines?pagelen=1&q="+url.QueryEscape(originalFilter))
98+
suite.Require().NoError(err)
99+
suite.Require().Len(items, 2)
100+
suite.Require().Equal("1", items[0].ID)
101+
suite.Require().Equal("2", items[1].ID)
102+
}

cmd/profile/profile_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package profile_test
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"os"
@@ -16,9 +17,10 @@ import (
1617

1718
type ProfileSuite struct {
1819
suite.Suite
19-
Name string
20-
Logger *logger.Logger
21-
Start time.Time
20+
Name string
21+
Context context.Context
22+
Logger *logger.Logger
23+
Start time.Time
2224
}
2325

2426
func TestProfileSuite(t *testing.T) {
@@ -39,6 +41,7 @@ func (suite *ProfileSuite) SetupSuite() {
3941
FilterLevels: logger.NewLevelSet(logger.TRACE),
4042
},
4143
).Child("test", "test")
44+
suite.Context = suite.Logger.ToContext(context.Background())
4245
suite.Logger.Infof("Suite Start: %s %s", suite.Name, strings.Repeat("=", 80-14-len(suite.Name)))
4346
}
4447

cmd/pullrequest/comment/create.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ import (
1616
type CommentCreator struct {
1717
Content ContentCreator `json:"content" mapstructure:"content"`
1818
Anchor *common.FileAnchor `json:"inline,omitempty" mapstructure:"inline"`
19+
Parent *ParentRef `json:"parent,omitempty" mapstructure:"parent"`
1920
}
2021

2122
type ContentCreator struct {
2223
Raw string `json:"raw" mapstructure:"raw"`
2324
}
2425

26+
type ParentRef struct {
27+
ID int64 `json:"id" mapstructure:"id"`
28+
}
29+
2530
var createCmd = &cobra.Command{
2631
Use: "create",
2732
Aliases: []string{"add", "new"},
@@ -37,6 +42,7 @@ var createOptions struct {
3742
File string
3843
From int
3944
To int
45+
ParentID int64
4046
}
4147

4248
func init() {
@@ -50,6 +56,7 @@ func init() {
5056
createCmd.Flags().IntVar(&createOptions.From, "line", 0, "From line to comment on. Cannot be used with --to")
5157
createCmd.Flags().IntVar(&createOptions.From, "from", 0, "From line to comment on. Cannot be used with --line")
5258
createCmd.Flags().IntVar(&createOptions.To, "to", 0, "To line to comment on. Cannot be used with --line")
59+
createCmd.Flags().Int64Var(&createOptions.ParentID, "parent", 0, "Parent comment ID to reply to")
5360
createCmd.MarkFlagsMutuallyExclusive("line", "from")
5461
createCmd.MarkFlagsMutuallyExclusive("line", "to")
5562
_ = createCmd.MarkFlagRequired("pullrequest")
@@ -69,6 +76,10 @@ func createProcess(cmd *cobra.Command, args []string) (err error) {
6976
Content: ContentCreator{Raw: createOptions.Comment},
7077
}
7178

79+
if createOptions.ParentID > 0 {
80+
payload.Parent = &ParentRef{ID: createOptions.ParentID}
81+
}
82+
7283
if createOptions.File != "" {
7384
payload.Anchor = &common.FileAnchor{
7485
Path: createOptions.File,

0 commit comments

Comments
 (0)