From 8ec15ad3da2e50aee0b26e31cd7c7dbec6d5cf3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 08:05:36 +0000 Subject: [PATCH 01/11] feat(integration): add bitbucket workspace link commands Add the CLI half of the Bitbucket Forge integration (safedep/control-tower#1137): - integration bitbucket link create issues the single-use workspace link code the Forge app settings page redeems. Re-running it re-generates the code and invalidates earlier unredeemed codes. - integration bitbucket link list lists linked workspaces with their link IDs. - integration bitbucket repository list lists a workspace's repositories with scan state and scan scope. - integration bitbucket allowlist update changes the scan scope and edits the repository allowlist. A fresh link is default deny, so this command is what turns scans on. Extract the cursor pagination helpers from internal/cmd/project into internal/paging: sibling command packages cannot import each other, and this is the second list-shaped domain that needs them. Bump the API SDK modules to 20260917155422-10bdf18a09ea.1 for the Bitbucket integration RPCs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- README.md | 4 + .../integration-bitbucket-allowlist-update.md | 78 ++++ docs/cmd/integration-bitbucket-link-create.md | 50 +++ docs/cmd/integration-bitbucket-link-list.md | 46 +++ .../integration-bitbucket-repository-list.md | 59 +++ go.mod | 4 +- go.sum | 8 +- .../cmd/integration/bitbucket/allowlist.go | 226 +++++++++++ .../integration/bitbucket/bitbucket_test.go | 383 ++++++++++++++++++ internal/cmd/integration/bitbucket/client.go | 133 ++++++ internal/cmd/integration/bitbucket/cmd.go | 50 +++ internal/cmd/integration/bitbucket/errors.go | 22 + internal/cmd/integration/bitbucket/link.go | 190 +++++++++ .../cmd/integration/bitbucket/repository.go | 174 ++++++++ internal/cmd/integration/cmd.go | 2 + internal/cmd/project/github.go | 10 +- internal/cmd/project/grpc.go | 42 +- internal/cmd/project/resolver.go | 5 +- internal/paging/paging.go | 53 +++ 19 files changed, 1487 insertions(+), 52 deletions(-) create mode 100644 docs/cmd/integration-bitbucket-allowlist-update.md create mode 100644 docs/cmd/integration-bitbucket-link-create.md create mode 100644 docs/cmd/integration-bitbucket-link-list.md create mode 100644 docs/cmd/integration-bitbucket-repository-list.md create mode 100644 internal/cmd/integration/bitbucket/allowlist.go create mode 100644 internal/cmd/integration/bitbucket/bitbucket_test.go create mode 100644 internal/cmd/integration/bitbucket/client.go create mode 100644 internal/cmd/integration/bitbucket/cmd.go create mode 100644 internal/cmd/integration/bitbucket/errors.go create mode 100644 internal/cmd/integration/bitbucket/link.go create mode 100644 internal/cmd/integration/bitbucket/repository.go create mode 100644 internal/paging/paging.go diff --git a/README.md b/README.md index 9eb52d0..d48eb33 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,10 @@ Push SafeDep malware findings to external security tools. JFrog XRay is supporte | [`safedep protect mcp status`](./docs/cmd/protect-mcp-status.md) | Show SafeDep MCP integration status for detected AI agents | | [`safedep protect mcp install`](./docs/cmd/protect-mcp-install.md) | Inject SafeDep MCP server config into detected AI agents | | [`safedep protect mcp uninstall`](./docs/cmd/protect-mcp-uninstall.md) | Remove SafeDep MCP server config from detected AI agents | +| [`safedep integration bitbucket link create`](./docs/cmd/integration-bitbucket-link-create.md) | Issue a link code for a Bitbucket workspace | +| [`safedep integration bitbucket link list`](./docs/cmd/integration-bitbucket-link-list.md) | List Bitbucket workspaces linked to the tenant | +| [`safedep integration bitbucket repository list`](./docs/cmd/integration-bitbucket-repository-list.md) | List repositories of a linked Bitbucket workspace | +| [`safedep integration bitbucket allowlist update`](./docs/cmd/integration-bitbucket-allowlist-update.md) | Update the Bitbucket scan scope and repository allowlist | | [`safedep integration jfrog run`](./docs/cmd/integration-jfrog-run.md) | Push SafeDep malware findings to JFrog XRay | | [`safedep integration jfrog cursor set`](./docs/cmd/integration-jfrog-cursor-set.md) | Set the JFrog feed cursor to a timestamp for the active profile | | [`safedep integration jfrog cursor remove`](./docs/cmd/integration-jfrog-cursor-remove.md) | Remove the saved JFrog feed cursor for the active profile | diff --git a/docs/cmd/integration-bitbucket-allowlist-update.md b/docs/cmd/integration-bitbucket-allowlist-update.md new file mode 100644 index 0000000..2f7113b --- /dev/null +++ b/docs/cmd/integration-bitbucket-allowlist-update.md @@ -0,0 +1,78 @@ +# safedep integration bitbucket allowlist update + +Change which repositories of a linked Bitbucket workspace SafeDep scans. + +## Synopsis + +``` +safedep integration bitbucket allowlist update [--link-id LINK_ID] + [--scope all|selected] + [--enable REPOSITORY_UUID]... + [--disable REPOSITORY_UUID]... + [--output table|plain|json] +``` + +## Description + +A linked workspace has a scan scope. Scope `all` scans every repository in +the workspace, including repositories created later. Scope `selected` scans +only the repositories on the scan allowlist. A fresh link starts with scope +`selected` and an empty allowlist, so nothing is scanned until this command +opens the scope or fills the allowlist. + +## Flags + +| Flag | Description | +|------|-------------| +| `--link-id ` | Bitbucket workspace link to update. Resolved automatically when the tenant has exactly one link. | +| `--scope ` | Scan scope after the update. Omit to keep the stored scope. Switching scope does not change the stored allowlist. | +| `--enable ` | Repository UUID to add to the allowlist. Repeat for multiple repositories. A UUID already on the allowlist is a no-op. | +| `--disable ` | Repository UUID to remove from the allowlist. Repeat for multiple repositories. A UUID not on the allowlist is a no-op. | + +Pass at least one of `--scope`, `--enable`, or `--disable`. Scope `all` +takes no `--enable` or `--disable` values. At most 500 UUIDs per flag per +request: a larger allowlist grows over several calls. Get repository UUIDs +from `safedep integration bitbucket repository list`. + +## Examples + +Scan every repository in the workspace: + +```bash +safedep integration bitbucket allowlist update --scope all +``` + +Scan two selected repositories only: + +```bash +safedep integration bitbucket allowlist update --scope selected \ + --enable 9a2b3c4d-0000-0000-0000-000000000001 \ + --enable 9a2b3c4d-0000-0000-0000-000000000002 +``` + +Remove one repository from the allowlist: + +```bash +safedep integration bitbucket allowlist update \ + --disable 9a2b3c4d-0000-0000-0000-000000000001 +``` + +## Output + +| Field | Description | +|-------|-------------| +| `link_id` | The workspace link the update ran against, supplied or resolved. | +| `scan_scope` | The scan scope after the update. | +| `allowlisted_repository_count` | The number of repositories on the allowlist after the update. | + +## Authentication + +Requires a control-plane OAuth session with the tenant admin role. Run +`safedep auth login` first. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The update applied. | +| non-zero | Local validation, link resolution, authentication, or the request failed. | diff --git a/docs/cmd/integration-bitbucket-link-create.md b/docs/cmd/integration-bitbucket-link-create.md new file mode 100644 index 0000000..4476dd2 --- /dev/null +++ b/docs/cmd/integration-bitbucket-link-create.md @@ -0,0 +1,50 @@ +# safedep integration bitbucket link create + +Issue the link code that connects a Bitbucket workspace to the active SafeDep +tenant. + +## Synopsis + +``` +safedep integration bitbucket link create [--output table|plain|json] +``` + +## Description + +The SafeDep Forge app adds a SafeDep settings page to every Bitbucket +workspace it is installed on. A workspace admin pastes a link code into that +page to link the workspace to a SafeDep tenant. This command issues that +code for the active tenant. + +The code is short-lived and single-use. It is shown once: the server stores +only a digest. Issuing a new code invalidates every earlier unredeemed code +of the tenant, so re-running the command is the way to re-generate a code. + +## Examples + +```bash +safedep integration bitbucket link create +``` + +```bash +safedep integration bitbucket link create --output json | jq -r '.link_code' +``` + +## Output + +| Field | Description | +|-------|-------------| +| `link_code` | The code to paste into the workspace's SafeDep settings page in Bitbucket. | +| `expires_at` | The instant the code expires, RFC 3339 UTC. | + +## Authentication + +Requires a control-plane OAuth session with the tenant admin role. Run +`safedep auth login` first. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | A code was issued. | +| non-zero | Authentication or the request failed. | diff --git a/docs/cmd/integration-bitbucket-link-list.md b/docs/cmd/integration-bitbucket-link-list.md new file mode 100644 index 0000000..f9db2b2 --- /dev/null +++ b/docs/cmd/integration-bitbucket-link-list.md @@ -0,0 +1,46 @@ +# safedep integration bitbucket link list + +List the Bitbucket workspaces linked to the active SafeDep tenant. + +## Synopsis + +``` +safedep integration bitbucket link list [--output table|plain|json] +``` + +## Description + +Each row is one linked workspace with its link ID. The link ID feeds +`safedep integration bitbucket repository list` and +`safedep integration bitbucket allowlist update`. The command walks every +page of the listing. + +## Examples + +```bash +safedep integration bitbucket link list +``` + +```bash +safedep integration bitbucket link list --output json | jq -r '.links[].link_id' +``` + +## Output + +| Field | Description | +|-------|-------------| +| `link_id` | The tenant-owned workspace link ID. | +| `workspace_uuid` | The immutable Bitbucket workspace UUID: lowercase, hyphenated, no braces. | +| `workspace_slug` | The workspace slug at the last sync. Mutable metadata, not identity. | +| `workspace_name` | The workspace display name at the last sync. Mutable metadata. | + +## Authentication + +Requires a control-plane OAuth session. Run `safedep auth login` first. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The listing completed, with zero or more links. | +| non-zero | Authentication or the request failed. | diff --git a/docs/cmd/integration-bitbucket-repository-list.md b/docs/cmd/integration-bitbucket-repository-list.md new file mode 100644 index 0000000..6b3c004 --- /dev/null +++ b/docs/cmd/integration-bitbucket-repository-list.md @@ -0,0 +1,59 @@ +# safedep integration bitbucket repository list + +List the repositories of a linked Bitbucket workspace with their scan state. + +## Synopsis + +``` +safedep integration bitbucket repository list [--link-id LINK_ID] + [--output table|plain|json] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--link-id ` | Bitbucket workspace link to list through. Resolved automatically when the tenant has exactly one link. | + +Inherits root flags `--output` and `--profile`. + +When `--link-id` is omitted, the CLI lists the tenant's workspace links and +uses the only one it finds. With no link, the command fails and points to +`link create`. With more than one link, it fails and lists the link IDs and +workspace slugs so you can pick one. + +## Examples + +```bash +safedep integration bitbucket repository list +``` + +```bash +safedep integration bitbucket repository list --link-id 01K88WX3G9RGAK8T3N5YJMFQN1 \ + --output json | jq -r '.repositories[] | select(.scan_state == "disabled") | .repository_uuid' +``` + +## Output + +| Field | Description | +|-------|-------------| +| `link_id` | The workspace link the listing ran through, supplied or resolved. | +| `scan_scope` | The workspace's scan scope: `all` or `selected`. | +| `repository_uuid` | The immutable Bitbucket repository UUID. Feed it to `allowlist update`. | +| `full_name` | The `workspace/repository` name. | +| `visibility` | `public` or `private`. | +| `default_branch` | The repository's default branch. | +| `scan_state` | Whether SafeDep scans the repository under the current scope: `enabled` or `disabled`. | + +The command walks every page of the listing. + +## Authentication + +Requires a control-plane OAuth session. Run `safedep auth login` first. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The listing completed. | +| non-zero | Link resolution, authentication, or the request failed. | diff --git a/go.mod b/go.mod index b0d84e8..a747732 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/safedep/cli go 1.26.2 require ( - buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260901164227-b6b5c7191208.1 - buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.12-20260901164227-b6b5c7191208.1 + buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260917155422-10bdf18a09ea.1 + buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.12-20260917155422-10bdf18a09ea.1 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/cli/browser v1.3.0 diff --git a/go.sum b/go.sum index d4a0081..4f94766 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1 h1:6nlcxMOui23ZRVAfJM451duu79P1npA5JRdZqMilrrQ= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260709200747-435963d16310.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= -buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260901164227-b6b5c7191208.1 h1:PUyUT9YcjFSBlyN9hDjgkcLXEuNb3YpYScTxh1q5JwM= -buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260901164227-b6b5c7191208.1/go.mod h1:OoYPvlMSbPCp17jBaJUTrUk11RQkKYOX5wPJ2ZVpcEc= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.12-20260901164227-b6b5c7191208.1 h1:QYg0K/jKBQDzN1SN5T8Ofc+1uzQbfTC8/MGWbG4TA5g= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.12-20260901164227-b6b5c7191208.1/go.mod h1:NugBwafT1reaWNql+nCCbiObpwyfyo+631WnRlO7Hqo= +buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260917155422-10bdf18a09ea.1 h1:ZmDBz75YYvNJSGN8qxeviXG4gpYJRqsRITIzVHSEx8Q= +buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260917155422-10bdf18a09ea.1/go.mod h1:ps2qBg6+ap/9iTISqy69gJwEWcDYlEhw6/PQnpLGTaQ= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.12-20260917155422-10bdf18a09ea.1 h1:PzidGpzBbWvUCooQRSSeMVaznrH6M83emPmyjAQGSis= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.12-20260917155422-10bdf18a09ea.1/go.mod h1:NugBwafT1reaWNql+nCCbiObpwyfyo+631WnRlO7Hqo= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= diff --git a/internal/cmd/integration/bitbucket/allowlist.go b/internal/cmd/integration/bitbucket/allowlist.go new file mode 100644 index 0000000..46bba9a --- /dev/null +++ b/internal/cmd/integration/bitbucket/allowlist.go @@ -0,0 +1,226 @@ +package bitbucket + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/tui/table" + "github.com/spf13/cobra" + + "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/tui" +) + +// UpdateBitbucketRepositoryAllowlist caps enable and disable at 500 UUIDs +// per request. A larger allowlist grows over several calls. +const maxAllowlistItems = 500 + +type allowlistUpdateInput struct { + LinkID string + Scope string + Enable []string + Disable []string +} + +type allowlistUpdateResult struct { + linkID string + scanScope string + allowlistCount uint32 +} + +type allowlistUpdateResultJSON struct { + LinkID string `json:"link_id"` + ScanScope string `json:"scan_scope"` + AllowlistedRepositoryCount uint32 `json:"allowlisted_repository_count"` +} + +func allowlistUpdateCmd(a *app.App) *cobra.Command { + var in allowlistUpdateInput + cmd := &cobra.Command{ + Use: "update", + Short: "Update the Bitbucket scan scope and repository allowlist", + Long: "Change which repositories of a linked Bitbucket workspace SafeDep scans. " + + "Scope `all` scans every repository in the workspace. Scope `selected` scans only " + + "the repositories on the allowlist, which --enable and --disable edit by repository " + + "UUID. A fresh link starts with scope `selected` and an empty allowlist, so nothing " + + "is scanned until this command opens the scope or fills the allowlist.", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := a.ControlPlane() + if err != nil { + return err + } + + integration := newIntegrationClient(client.Connection()) + result, err := runAllowlistUpdate(cmd.Context(), integration, integration, in) + if err != nil { + return err + } + return a.Output.Print(result) + }, + } + + f := cmd.Flags() + f.StringVar(&in.LinkID, "link-id", "", + "Bitbucket workspace link to update; resolved automatically when the tenant has exactly one link") + f.StringVar(&in.Scope, "scope", "", + "scan scope after the update: all or selected (omit to keep the stored scope)") + f.StringArrayVar(&in.Enable, "enable", nil, + "repository UUID to add to the scan allowlist; repeat for multiple repositories") + f.StringArrayVar(&in.Disable, "disable", nil, + "repository UUID to remove from the scan allowlist; repeat for multiple repositories") + return cmd +} + +func runAllowlistUpdate( + ctx context.Context, + links linkLister, + updater allowlistUpdater, + in allowlistUpdateInput, +) (*allowlistUpdateResult, error) { + const label = "integration bitbucket allowlist update" + + scope, err := parseScanScope(in) + if err != nil { + return nil, err + } + if err := validateAllowlistSelection(in); err != nil { + return nil, err + } + + linkID := in.LinkID + if linkID == "" { + resolved, err := resolveLinkID(ctx, links, label) + if err != nil { + return nil, err + } + linkID = resolved + } + + req := &controltowerv1.UpdateBitbucketRepositoryAllowlistRequest{} + req.SetLinkId(linkID) + req.SetScanScope(scope) + req.SetEnable(in.Enable) + req.SetDisable(in.Disable) + + res, err := updater.UpdateBitbucketRepositoryAllowlist(ctx, req) + if err != nil { + return nil, fmt.Errorf("%s: %w", label, err) + } + return &allowlistUpdateResult{ + linkID: linkID, + scanScope: tui.EnumToken(res.GetScanScope().String(), scanScopePrefix), + allowlistCount: res.GetAllowlistedRepositoryCount(), + }, nil +} + +// parseScanScope maps the --scope token to the proto enum. An empty flag +// keeps the stored scope: the server treats UNSPECIFIED as no change. +func parseScanScope(in allowlistUpdateInput) (controltowerv1.BitbucketScanScope, error) { + if in.Scope == "" { + return controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_UNSPECIFIED, nil + } + number, ok := tui.ParseEnumToken(controltowerv1.BitbucketScanScope_name, scanScopePrefix, in.Scope) + if !ok { + allowed := strings.Join(tui.EnumTokens(controltowerv1.BitbucketScanScope_name, scanScopePrefix), ", ") + cause := fmt.Errorf("unknown --scope value %q: allowed values are %s", in.Scope, allowed) + return 0, invalidSelectionError(cause, fmt.Sprintf("Retry --scope with one of: %s.", allowed)) + } + scope := controltowerv1.BitbucketScanScope(number) + if scope == controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_ALL && + (len(in.Enable) > 0 || len(in.Disable) > 0) { + cause := fmt.Errorf("scope all takes no --enable or --disable values") + return 0, invalidSelectionError( + cause, + "Scope all scans every repository, so drop --enable and --disable, or use --scope selected.", + ) + } + return scope, nil +} + +func validateAllowlistSelection(in allowlistUpdateInput) error { + if in.Scope == "" && len(in.Enable) == 0 && len(in.Disable) == 0 { + cause := fmt.Errorf("nothing to update") + return invalidSelectionError(cause, "Pass --scope, --enable, or --disable.") + } + if len(in.Enable) > maxAllowlistItems || len(in.Disable) > maxAllowlistItems { + cause := fmt.Errorf("at most %d repository UUIDs per flag per request", maxAllowlistItems) + return invalidSelectionError( + cause, + fmt.Sprintf("Pass at most %d --enable and %d --disable values and repeat the command for the rest.", + maxAllowlistItems, maxAllowlistItems), + ) + } + if err := validateUniqueUUIDs(in.Enable, "enable"); err != nil { + return err + } + if err := validateUniqueUUIDs(in.Disable, "disable"); err != nil { + return err + } + disable := make(map[string]struct{}, len(in.Disable)) + for _, uuid := range in.Disable { + disable[uuid] = struct{}{} + } + for _, uuid := range in.Enable { + if _, both := disable[uuid]; both { + cause := fmt.Errorf("repository UUID %q is in both --enable and --disable", uuid) + return invalidSelectionError( + cause, + fmt.Sprintf("Remove repository UUID %q from one of the flags and retry.", uuid), + ) + } + } + return nil +} + +func validateUniqueUUIDs(values []string, flag string) error { + seen := make(map[string]struct{}, len(values)) + for i, value := range values { + if value == "" { + cause := fmt.Errorf("--%s value at position %d must not be empty", flag, i+1) + return invalidSelectionError( + cause, + fmt.Sprintf("Provide a repository UUID at --%s position %d.", flag, i+1), + ) + } + if _, duplicate := seen[value]; duplicate { + cause := fmt.Errorf("duplicate --%s value %q", flag, value) + return invalidSelectionError( + cause, + fmt.Sprintf("Remove duplicate --%s value %q and retry.", flag, value), + ) + } + seen[value] = struct{}{} + } + return nil +} + +func (r *allowlistUpdateResult) RenderJSON() ([]byte, error) { + return json.MarshalIndent(allowlistUpdateResultJSON{ + LinkID: r.linkID, + ScanScope: r.scanScope, + AllowlistedRepositoryCount: r.allowlistCount, + }, "", " ") +} + +func (r *allowlistUpdateResult) RenderPlain() string { + return "link_id\tscan_scope\tallowlisted_repository_count\n" + strings.Join(r.cells(), "\t") +} + +func (r *allowlistUpdateResult) RenderTable() string { + return table.New(). + Title("Bitbucket scan allowlist"). + Headers("LINK ID", "SCAN SCOPE", "ALLOWLISTED REPOSITORIES"). + Rows(r.cells()). + Render() +} + +func (r *allowlistUpdateResult) cells() []string { + return []string{ + r.linkID, + r.scanScope, + fmt.Sprintf("%d", r.allowlistCount), + } +} diff --git a/internal/cmd/integration/bitbucket/bitbucket_test.go b/internal/cmd/integration/bitbucket/bitbucket_test.go new file mode 100644 index 0000000..355be41 --- /dev/null +++ b/internal/cmd/integration/bitbucket/bitbucket_test.go @@ -0,0 +1,383 @@ +package bitbucket + +import ( + "context" + "errors" + "testing" + "time" + + messagescontroltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/usefulerror" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type fakeLinkCodeCreator struct { + calls int + res *controltowerv1.CreateBitbucketWorkspaceLinkCodeResponse + err error +} + +func (f *fakeLinkCodeCreator) CreateBitbucketWorkspaceLinkCode( + _ context.Context, + _ *controltowerv1.CreateBitbucketWorkspaceLinkCodeRequest, + _ ...grpc.CallOption, +) (*controltowerv1.CreateBitbucketWorkspaceLinkCodeResponse, error) { + f.calls++ + return f.res, f.err +} + +type fakeLinkLister struct { + pages []*controltowerv1.ListBitbucketWorkspaceLinksResponse + calls int + err error +} + +func (f *fakeLinkLister) ListBitbucketWorkspaceLinks( + _ context.Context, + _ *controltowerv1.ListBitbucketWorkspaceLinksRequest, + _ ...grpc.CallOption, +) (*controltowerv1.ListBitbucketWorkspaceLinksResponse, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + if len(f.pages) == 0 { + return nil, errors.New("unexpected ListBitbucketWorkspaceLinks call") + } + return f.pages[min(f.calls-1, len(f.pages)-1)], nil +} + +type fakeRepositoryLister struct { + pages []*controltowerv1.ListBitbucketRepositoriesResponse + requests []*controltowerv1.ListBitbucketRepositoriesRequest + err error +} + +func (f *fakeRepositoryLister) ListBitbucketRepositories( + _ context.Context, + req *controltowerv1.ListBitbucketRepositoriesRequest, + _ ...grpc.CallOption, +) (*controltowerv1.ListBitbucketRepositoriesResponse, error) { + f.requests = append(f.requests, req) + if f.err != nil { + return nil, f.err + } + if len(f.pages) == 0 { + return nil, errors.New("unexpected ListBitbucketRepositories call") + } + return f.pages[min(len(f.requests)-1, len(f.pages)-1)], nil +} + +type fakeAllowlistUpdater struct { + calls int + req *controltowerv1.UpdateBitbucketRepositoryAllowlistRequest + res *controltowerv1.UpdateBitbucketRepositoryAllowlistResponse + err error +} + +func (f *fakeAllowlistUpdater) UpdateBitbucketRepositoryAllowlist( + _ context.Context, + req *controltowerv1.UpdateBitbucketRepositoryAllowlistRequest, + _ ...grpc.CallOption, +) (*controltowerv1.UpdateBitbucketRepositoryAllowlistResponse, error) { + f.calls++ + f.req = req + return f.res, f.err +} + +var ( + _ linkCodeCreator = (*fakeLinkCodeCreator)(nil) + _ linkLister = (*fakeLinkLister)(nil) + _ repositoryLister = (*fakeRepositoryLister)(nil) + _ allowlistUpdater = (*fakeAllowlistUpdater)(nil) +) + +func newWorkspaceLinkPage(nextPageToken string, links ...*controltowerv1.ListBitbucketWorkspaceLinksResponse_WorkspaceLink) *controltowerv1.ListBitbucketWorkspaceLinksResponse { + res := &controltowerv1.ListBitbucketWorkspaceLinksResponse{} + res.SetLinks(links) + if nextPageToken != "" { + pagination := &messagescontroltowerv1.PaginationResponse{} + pagination.SetNextPageToken(nextPageToken) + res.SetPagination(pagination) + } + return res +} + +func newWorkspaceLink(linkID, uuid, slug, name string) *controltowerv1.ListBitbucketWorkspaceLinksResponse_WorkspaceLink { + link := &controltowerv1.ListBitbucketWorkspaceLinksResponse_WorkspaceLink{} + link.SetLinkId(linkID) + link.SetWorkspaceUuid(uuid) + link.SetWorkspaceSlug(slug) + link.SetWorkspaceName(name) + return link +} + +func newRepositoryPage(nextPageToken string, scope controltowerv1.BitbucketScanScope, repositories ...*controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository) *controltowerv1.ListBitbucketRepositoriesResponse { + res := &controltowerv1.ListBitbucketRepositoriesResponse{} + res.SetRepositories(repositories) + res.SetScanScope(scope) + if nextPageToken != "" { + pagination := &messagescontroltowerv1.PaginationResponse{} + pagination.SetNextPageToken(nextPageToken) + res.SetPagination(pagination) + } + return res +} + +func newRepository(uuid, fullName string, scanState controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository_ScanState) *controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository { + repository := &controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository{} + repository.SetRepositoryUuid(uuid) + repository.SetFullName(fullName) + repository.SetVisibility(controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository_VISIBILITY_PRIVATE) + repository.SetDefaultBranch("main") + repository.SetScanState(scanState) + return repository +} + +func TestRunLinkCreate(t *testing.T) { + expiresAt := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + + t.Run("returns the code and its expiry", func(t *testing.T) { + res := &controltowerv1.CreateBitbucketWorkspaceLinkCodeResponse{} + res.SetLinkCode("code-1234") + res.SetExpiresAt(timestamppb.New(expiresAt)) + creator := &fakeLinkCodeCreator{res: res} + + result, err := runLinkCreate(context.Background(), creator) + require.NoError(t, err) + assert.Equal(t, 1, creator.calls) + assert.Equal(t, "code-1234", result.linkCode) + require.NotNil(t, result.expiresAt) + assert.Equal(t, expiresAt, *result.expiresAt) + }) + + t.Run("rejects a response without a code", func(t *testing.T) { + creator := &fakeLinkCodeCreator{res: &controltowerv1.CreateBitbucketWorkspaceLinkCodeResponse{}} + + _, err := runLinkCreate(context.Background(), creator) + require.ErrorContains(t, err, "invalid response: missing link code") + }) + + t.Run("wraps an RPC failure", func(t *testing.T) { + creator := &fakeLinkCodeCreator{err: errors.New("boom")} + + _, err := runLinkCreate(context.Background(), creator) + require.ErrorContains(t, err, "integration bitbucket link create: boom") + }) +} + +func TestRunLinkList(t *testing.T) { + t.Run("walks every page", func(t *testing.T) { + lister := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + newWorkspaceLinkPage("page-2", newWorkspaceLink("link-1", "uuid-1", "one", "One")), + newWorkspaceLinkPage("", newWorkspaceLink("link-2", "uuid-2", "two", "Two")), + }} + + result, err := runLinkList(context.Background(), lister) + require.NoError(t, err) + assert.Equal(t, 2, lister.calls) + require.Len(t, result.links, 2) + assert.Equal(t, "link-1", result.links[0].linkID) + assert.Equal(t, "uuid-2", result.links[1].workspaceUUID) + }) + + t.Run("rejects a link without an ID", func(t *testing.T) { + lister := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + newWorkspaceLinkPage("", newWorkspaceLink("", "uuid-1", "one", "One")), + }} + + _, err := runLinkList(context.Background(), lister) + require.ErrorContains(t, err, "invalid response: link 1 is missing its ID") + }) +} + +func TestRunRepositoryList(t *testing.T) { + t.Run("resolves the only link and translates repositories", func(t *testing.T) { + links := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + newWorkspaceLinkPage("", newWorkspaceLink("link-1", "uuid-1", "one", "One")), + }} + repositories := &fakeRepositoryLister{pages: []*controltowerv1.ListBitbucketRepositoriesResponse{ + newRepositoryPage("page-2", controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, + newRepository("repo-uuid-1", "one/alpha", controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository_SCAN_STATE_ENABLED)), + newRepositoryPage("", controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, + newRepository("repo-uuid-2", "one/beta", controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository_SCAN_STATE_DISABLED)), + }} + + result, err := runRepositoryList(context.Background(), links, repositories, repositoryListInput{}) + require.NoError(t, err) + assert.Equal(t, "link-1", result.linkID) + assert.Equal(t, "selected", result.scanScope) + require.Len(t, result.repositories, 2) + assert.Equal(t, listedRepository{ + RepositoryUUID: "repo-uuid-1", + FullName: "one/alpha", + Visibility: "private", + DefaultBranch: "main", + ScanState: "enabled", + }, result.repositories[0]) + assert.Equal(t, "disabled", result.repositories[1].ScanState) + require.Len(t, repositories.requests, 2) + assert.Equal(t, "link-1", repositories.requests[0].GetLinkId()) + }) + + t.Run("uses the supplied link without resolution", func(t *testing.T) { + links := &fakeLinkLister{} + repositories := &fakeRepositoryLister{pages: []*controltowerv1.ListBitbucketRepositoriesResponse{ + newRepositoryPage("", controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_ALL), + }} + + result, err := runRepositoryList(context.Background(), links, repositories, repositoryListInput{LinkID: "link-9"}) + require.NoError(t, err) + assert.Equal(t, 0, links.calls) + assert.Equal(t, "link-9", result.linkID) + assert.Equal(t, "all", result.scanScope) + assert.Empty(t, result.repositories) + }) + + t.Run("fails without a linked workspace", func(t *testing.T) { + links := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + newWorkspaceLinkPage(""), + }} + + _, err := runRepositoryList(context.Background(), links, &fakeRepositoryLister{}, repositoryListInput{}) + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrNotFound, usefulErr.Code()) + }) + + t.Run("fails on an ambiguous link", func(t *testing.T) { + links := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + newWorkspaceLinkPage("", + newWorkspaceLink("link-1", "uuid-1", "one", "One"), + newWorkspaceLink("link-2", "uuid-2", "two", "Two")), + }} + + _, err := runRepositoryList(context.Background(), links, &fakeRepositoryLister{}, repositoryListInput{}) + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Contains(t, usefulErr.Help(), "link-1 (one)") + assert.Contains(t, usefulErr.Help(), "link-2 (two)") + }) + + t.Run("rejects a repository without a UUID", func(t *testing.T) { + repositories := &fakeRepositoryLister{pages: []*controltowerv1.ListBitbucketRepositoriesResponse{ + newRepositoryPage("", controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, + newRepository("", "one/alpha", controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository_SCAN_STATE_ENABLED)), + }} + + _, err := runRepositoryList(context.Background(), &fakeLinkLister{}, repositories, repositoryListInput{LinkID: "link-1"}) + require.ErrorContains(t, err, "invalid response: repository 1 is missing its UUID") + }) +} + +func TestRunAllowlistUpdate(t *testing.T) { + newUpdaterResponse := func(scope controltowerv1.BitbucketScanScope, count uint32) *controltowerv1.UpdateBitbucketRepositoryAllowlistResponse { + res := &controltowerv1.UpdateBitbucketRepositoryAllowlistResponse{} + res.SetScanScope(scope) + res.SetAllowlistedRepositoryCount(count) + return res + } + + t.Run("sends the delta and reports the outcome", func(t *testing.T) { + updater := &fakeAllowlistUpdater{ + res: newUpdaterResponse(controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, 2), + } + + result, err := runAllowlistUpdate(context.Background(), &fakeLinkLister{}, updater, allowlistUpdateInput{ + LinkID: "link-1", + Scope: "selected", + Enable: []string{"uuid-a", "uuid-b"}, + Disable: []string{"uuid-c"}, + }) + require.NoError(t, err) + require.Equal(t, 1, updater.calls) + assert.Equal(t, "link-1", updater.req.GetLinkId()) + assert.Equal(t, controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, updater.req.GetScanScope()) + assert.Equal(t, []string{"uuid-a", "uuid-b"}, updater.req.GetEnable()) + assert.Equal(t, []string{"uuid-c"}, updater.req.GetDisable()) + assert.Equal(t, "selected", result.scanScope) + assert.Equal(t, uint32(2), result.allowlistCount) + }) + + t.Run("keeps the stored scope when the flag is omitted", func(t *testing.T) { + updater := &fakeAllowlistUpdater{ + res: newUpdaterResponse(controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, 1), + } + + _, err := runAllowlistUpdate(context.Background(), &fakeLinkLister{}, updater, allowlistUpdateInput{ + LinkID: "link-1", + Enable: []string{"uuid-a"}, + }) + require.NoError(t, err) + assert.Equal(t, controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_UNSPECIFIED, updater.req.GetScanScope()) + }) + + t.Run("resolves the only link", func(t *testing.T) { + links := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + newWorkspaceLinkPage("", newWorkspaceLink("link-1", "uuid-1", "one", "One")), + }} + updater := &fakeAllowlistUpdater{ + res: newUpdaterResponse(controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_ALL, 0), + } + + result, err := runAllowlistUpdate(context.Background(), links, updater, allowlistUpdateInput{Scope: "all"}) + require.NoError(t, err) + assert.Equal(t, "link-1", updater.req.GetLinkId()) + assert.Equal(t, "all", result.scanScope) + }) + + t.Run("validation failures", func(t *testing.T) { + cases := []struct { + name string + in allowlistUpdateInput + want string + }{ + { + name: "nothing to update", + in: allowlistUpdateInput{LinkID: "link-1"}, + want: "nothing to update", + }, + { + name: "unknown scope", + in: allowlistUpdateInput{LinkID: "link-1", Scope: "some"}, + want: "unknown --scope value", + }, + { + name: "scope all with a delta", + in: allowlistUpdateInput{LinkID: "link-1", Scope: "all", Enable: []string{"uuid-a"}}, + want: "scope all takes no --enable or --disable values", + }, + { + name: "duplicate enable", + in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{"uuid-a", "uuid-a"}}, + want: "duplicate --enable value", + }, + { + name: "empty disable", + in: allowlistUpdateInput{LinkID: "link-1", Disable: []string{""}}, + want: "--disable value at position 1 must not be empty", + }, + { + name: "enable and disable overlap", + in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{"uuid-a"}, Disable: []string{"uuid-a"}}, + want: "in both --enable and --disable", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + updater := &fakeAllowlistUpdater{} + + _, err := runAllowlistUpdate(context.Background(), &fakeLinkLister{}, updater, tc.in) + require.ErrorContains(t, err, tc.want) + assert.Equal(t, 0, updater.calls) + }) + } + }) +} diff --git a/internal/cmd/integration/bitbucket/client.go b/internal/cmd/integration/bitbucket/client.go new file mode 100644 index 0000000..8975d24 --- /dev/null +++ b/internal/cmd/integration/bitbucket/client.go @@ -0,0 +1,133 @@ +package bitbucket + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + controltowerv1grpc "buf.build/gen/go/safedep/api/grpc/go/safedep/services/controltower/v1/controltowerv1grpc" + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/usefulerror" + "google.golang.org/grpc" + + "github.com/safedep/cli/internal/paging" +) + +// Every list RPC in the integration service caps a page at 100 items. +const pageSize = 100 + +type linkCodeCreator interface { + CreateBitbucketWorkspaceLinkCode( + context.Context, + *controltowerv1.CreateBitbucketWorkspaceLinkCodeRequest, + ...grpc.CallOption, + ) (*controltowerv1.CreateBitbucketWorkspaceLinkCodeResponse, error) +} + +type linkLister interface { + ListBitbucketWorkspaceLinks( + context.Context, + *controltowerv1.ListBitbucketWorkspaceLinksRequest, + ...grpc.CallOption, + ) (*controltowerv1.ListBitbucketWorkspaceLinksResponse, error) +} + +type repositoryLister interface { + ListBitbucketRepositories( + context.Context, + *controltowerv1.ListBitbucketRepositoriesRequest, + ...grpc.CallOption, + ) (*controltowerv1.ListBitbucketRepositoriesResponse, error) +} + +type allowlistUpdater interface { + UpdateBitbucketRepositoryAllowlist( + context.Context, + *controltowerv1.UpdateBitbucketRepositoryAllowlistRequest, + ...grpc.CallOption, + ) (*controltowerv1.UpdateBitbucketRepositoryAllowlistResponse, error) +} + +func newIntegrationClient(conn grpc.ClientConnInterface) controltowerv1grpc.IntegrationServiceClient { + return controltowerv1grpc.NewIntegrationServiceClient(conn) +} + +type workspaceLink struct { + linkID string + workspaceUUID string + workspaceSlug string + workspaceName string +} + +func listWorkspaceLinks(ctx context.Context, client linkLister, label string) ([]workspaceLink, error) { + var links []workspaceLink + err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { + req := &controltowerv1.ListBitbucketWorkspaceLinksRequest{} + req.SetPagination(paging.NewPaginationRequest(pageSize, pageToken)) + res, err := client.ListBitbucketWorkspaceLinks(ctx, req) + if err != nil { + return "", fmt.Errorf("%s: %w", label, err) + } + for i, item := range res.GetLinks() { + if item.GetLinkId() == "" { + return "", fmt.Errorf("%s: invalid response: link %d is missing its ID", label, i+1) + } + links = append(links, workspaceLink{ + linkID: item.GetLinkId(), + workspaceUUID: item.GetWorkspaceUuid(), + workspaceSlug: item.GetWorkspaceSlug(), + workspaceName: item.GetWorkspaceName(), + }) + } + return res.GetPagination().GetNextPageToken(), nil + }) + if err != nil { + return nil, err + } + return links, nil +} + +// resolveLinkID returns the tenant's only workspace link when the caller did +// not pick one with --link-id. With no link or more than one, it fails with +// guidance, mirroring the GitHub link resolution in `project sync`. +func resolveLinkID(ctx context.Context, client linkLister, label string) (string, error) { + links, err := listWorkspaceLinks(ctx, client, label) + if err != nil { + return "", err + } + + switch len(links) { + case 0: + return "", newBitbucketError( + usefulerror.ErrNotFound, + "No linked Bitbucket workspace", + "Create a link code with `safedep integration bitbucket link create`, redeem it in the "+ + "workspace's SafeDep settings page in Bitbucket, then retry.", + fmt.Errorf("%s: the active tenant has no linked bitbucket workspace", label), + ) + case 1: + return links[0].linkID, nil + default: + return "", ambiguousLinkError(links) + } +} + +func ambiguousLinkError(links []workspaceLink) error { + choices := make([]string, 0, len(links)) + for _, link := range links { + slug := link.workspaceSlug + if slug == "" { + slug = link.workspaceUUID + } + choices = append(choices, fmt.Sprintf("%s (%s)", link.linkID, slug)) + } + sort.Strings(choices) + return newBitbucketError( + usefulerror.ErrBadRequest, + "More than one linked Bitbucket workspace", + fmt.Sprintf("Pick one with --link-id: %s.", strings.Join(choices, ", ")), + errors.New("the active tenant has more than one linked bitbucket workspace"), + ) +} diff --git a/internal/cmd/integration/bitbucket/cmd.go b/internal/cmd/integration/bitbucket/cmd.go new file mode 100644 index 0000000..9033487 --- /dev/null +++ b/internal/cmd/integration/bitbucket/cmd.go @@ -0,0 +1,50 @@ +// Package bitbucket manages the Bitbucket Cloud integration: workspace link +// codes for the SafeDep Forge app, linked workspaces, and the repository +// scan allowlist. +package bitbucket + +import ( + "github.com/safedep/cli/internal/app" + "github.com/spf13/cobra" +) + +// Register attaches the `bitbucket` sub-noun and its commands to the +// `integration` parent command. +func Register(parent *cobra.Command, a *app.App) { + cmd := &cobra.Command{ + Use: "bitbucket", + Short: "Manage the Bitbucket Cloud integration", + Long: "Commands for linking Bitbucket workspaces to the active SafeDep tenant " + + "through the SafeDep Forge app and for selecting which repositories SafeDep scans.", + } + + link := &cobra.Command{ + Use: "link", + Short: "Manage Bitbucket workspace links", + Long: "Commands for issuing workspace link codes and listing the Bitbucket " + + "workspaces linked to the active tenant.", + } + link.AddCommand(linkCreateCmd(a)) + link.AddCommand(linkListCmd(a)) + + repository := &cobra.Command{ + Use: "repository", + Short: "Inspect repositories of a linked Bitbucket workspace", + Long: "Commands for listing the repositories of a linked Bitbucket workspace " + + "with their scan state.", + } + repository.AddCommand(repositoryListCmd(a)) + + allowlist := &cobra.Command{ + Use: "allowlist", + Short: "Manage the Bitbucket repository scan allowlist", + Long: "Commands for changing the scan scope of a linked Bitbucket workspace " + + "and the repositories on its scan allowlist.", + } + allowlist.AddCommand(allowlistUpdateCmd(a)) + + cmd.AddCommand(link) + cmd.AddCommand(repository) + cmd.AddCommand(allowlist) + parent.AddCommand(cmd) +} diff --git a/internal/cmd/integration/bitbucket/errors.go b/internal/cmd/integration/bitbucket/errors.go new file mode 100644 index 0000000..67147c1 --- /dev/null +++ b/internal/cmd/integration/bitbucket/errors.go @@ -0,0 +1,22 @@ +package bitbucket + +import ( + "github.com/safedep/dry/usefulerror" +) + +func newBitbucketError(code, humanError, help string, cause error) error { + return usefulerror.NewUsefulError(). + WithCode(code). + WithHumanError(humanError). + WithHelp(help). + Wrap(cause) +} + +func invalidSelectionError(cause error, help string) error { + return newBitbucketError( + usefulerror.ErrBadRequest, + "Invalid allowlist selection", + help, + cause, + ) +} diff --git a/internal/cmd/integration/bitbucket/link.go b/internal/cmd/integration/bitbucket/link.go new file mode 100644 index 0000000..3ebd9bc --- /dev/null +++ b/internal/cmd/integration/bitbucket/link.go @@ -0,0 +1,190 @@ +package bitbucket + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/tui/table" + "github.com/spf13/cobra" + + "github.com/safedep/cli/internal/app" +) + +type linkCreateResult struct { + linkCode string + expiresAt *time.Time +} + +type linkCreateResultJSON struct { + LinkCode string `json:"link_code"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +func linkCreateCmd(a *app.App) *cobra.Command { + return &cobra.Command{ + Use: "create", + Short: "Issue a link code for a Bitbucket workspace", + Long: "Issue the short-lived, single-use code a Bitbucket workspace admin pastes into " + + "the workspace's SafeDep settings page (added by the SafeDep Forge app) to link the " + + "workspace to the active tenant. The code is shown once. Issuing a new code " + + "invalidates every earlier unredeemed code of the tenant.", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := a.ControlPlane() + if err != nil { + return err + } + + result, err := runLinkCreate(cmd.Context(), newIntegrationClient(client.Connection())) + if err != nil { + return err + } + return a.Output.Print(result) + }, + } +} + +func runLinkCreate(ctx context.Context, client linkCodeCreator) (*linkCreateResult, error) { + const label = "integration bitbucket link create" + + res, err := client.CreateBitbucketWorkspaceLinkCode( + ctx, + &controltowerv1.CreateBitbucketWorkspaceLinkCodeRequest{}, + ) + if err != nil { + return nil, fmt.Errorf("%s: %w", label, err) + } + if res.GetLinkCode() == "" { + return nil, fmt.Errorf("%s: invalid response: missing link code", label) + } + + result := &linkCreateResult{linkCode: res.GetLinkCode()} + if timestamp := res.GetExpiresAt(); timestamp != nil { + expiresAt := timestamp.AsTime().UTC() + result.expiresAt = &expiresAt + } + return result, nil +} + +func (r *linkCreateResult) RenderJSON() ([]byte, error) { + item := linkCreateResultJSON{LinkCode: r.linkCode} + if r.expiresAt != nil { + item.ExpiresAt = r.expiresAt.Format(time.RFC3339) + } + return json.MarshalIndent(item, "", " ") +} + +func (r *linkCreateResult) RenderPlain() string { + return "link_code\texpires_at\n" + strings.Join(r.cells(), "\t") +} + +func (r *linkCreateResult) RenderTable() string { + return table.New(). + Title("Bitbucket workspace link code"). + Headers("LINK CODE", "EXPIRES AT"). + Rows(r.cells()). + Footer("Paste the code into the workspace's SafeDep settings page in Bitbucket before it expires."). + Render() +} + +func (r *linkCreateResult) cells() []string { + expiresAt := "" + if r.expiresAt != nil { + expiresAt = r.expiresAt.Format(time.RFC3339) + } + return []string{r.linkCode, expiresAt} +} + +type linkListResult struct { + links []workspaceLink +} + +type workspaceLinkJSON struct { + LinkID string `json:"link_id"` + WorkspaceUUID string `json:"workspace_uuid"` + WorkspaceSlug string `json:"workspace_slug,omitempty"` + WorkspaceName string `json:"workspace_name,omitempty"` +} + +type linkListResultJSON struct { + Links []workspaceLinkJSON `json:"links"` +} + +func linkListCmd(a *app.App) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List Bitbucket workspaces linked to the tenant", + Long: "List every Bitbucket workspace linked to the active tenant, with the link ID " + + "the repository and allowlist commands take.", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := a.ControlPlane() + if err != nil { + return err + } + + result, err := runLinkList(cmd.Context(), newIntegrationClient(client.Connection())) + if err != nil { + return err + } + return a.Output.Print(result) + }, + } +} + +func runLinkList(ctx context.Context, client linkLister) (*linkListResult, error) { + links, err := listWorkspaceLinks(ctx, client, "integration bitbucket link list") + if err != nil { + return nil, err + } + return &linkListResult{links: links}, nil +} + +func (r *linkListResult) RenderJSON() ([]byte, error) { + links := make([]workspaceLinkJSON, 0, len(r.links)) + for _, link := range r.links { + links = append(links, workspaceLinkJSON{ + LinkID: link.linkID, + WorkspaceUUID: link.workspaceUUID, + WorkspaceSlug: link.workspaceSlug, + WorkspaceName: link.workspaceName, + }) + } + return json.MarshalIndent(linkListResultJSON{Links: links}, "", " ") +} + +func (r *linkListResult) RenderPlain() string { + var output strings.Builder + output.WriteString("link_id\tworkspace_uuid\tworkspace_slug\tworkspace_name") + for _, link := range r.links { + output.WriteByte('\n') + output.WriteString(strings.Join(workspaceLinkCells(link), "\t")) + } + return output.String() +} + +func (r *linkListResult) RenderTable() string { + rows := make([][]string, 0, len(r.links)) + for _, link := range r.links { + rows = append(rows, workspaceLinkCells(link)) + } + return table.New(). + Title("Linked Bitbucket workspaces"). + Headers("LINK ID", "WORKSPACE UUID", "SLUG", "NAME"). + Rows(rows...). + Footer(fmt.Sprintf("%d %s", len(rows), pluralWorkspaces(len(rows)))). + Render() +} + +func workspaceLinkCells(link workspaceLink) []string { + return []string{link.linkID, link.workspaceUUID, link.workspaceSlug, link.workspaceName} +} + +func pluralWorkspaces(n int) string { + if n == 1 { + return "workspace" + } + return "workspaces" +} diff --git a/internal/cmd/integration/bitbucket/repository.go b/internal/cmd/integration/bitbucket/repository.go new file mode 100644 index 0000000..4dc15f8 --- /dev/null +++ b/internal/cmd/integration/bitbucket/repository.go @@ -0,0 +1,174 @@ +package bitbucket + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/tui/table" + "github.com/spf13/cobra" + + "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/paging" + "github.com/safedep/cli/internal/tui" +) + +const ( + scanScopePrefix = "BITBUCKET_SCAN_SCOPE_" + repositoryVisibilityPrefix = "VISIBILITY_" + repositoryScanStatePrefix = "SCAN_STATE_" +) + +type repositoryListInput struct { + LinkID string +} + +type listedRepository struct { + RepositoryUUID string `json:"repository_uuid"` + FullName string `json:"full_name"` + Visibility string `json:"visibility"` + DefaultBranch string `json:"default_branch,omitempty"` + ScanState string `json:"scan_state"` +} + +type repositoryListResult struct { + linkID string + scanScope string + repositories []listedRepository +} + +type repositoryListResultJSON struct { + LinkID string `json:"link_id"` + ScanScope string `json:"scan_scope"` + Repositories []listedRepository `json:"repositories"` +} + +func repositoryListCmd(a *app.App) *cobra.Command { + var in repositoryListInput + cmd := &cobra.Command{ + Use: "list", + Short: "List repositories of a linked Bitbucket workspace", + Long: "List the repositories of a linked Bitbucket workspace with their scan state " + + "and the workspace's scan scope. The repository UUIDs feed " + + "`integration bitbucket allowlist update`.", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := a.ControlPlane() + if err != nil { + return err + } + + integration := newIntegrationClient(client.Connection()) + result, err := runRepositoryList(cmd.Context(), integration, integration, in) + if err != nil { + return err + } + return a.Output.Print(result) + }, + } + cmd.Flags().StringVar(&in.LinkID, "link-id", "", + "Bitbucket workspace link to list through; resolved automatically when the tenant has exactly one link") + return cmd +} + +func runRepositoryList( + ctx context.Context, + links linkLister, + repositories repositoryLister, + in repositoryListInput, +) (*repositoryListResult, error) { + const label = "integration bitbucket repository list" + + linkID := in.LinkID + if linkID == "" { + resolved, err := resolveLinkID(ctx, links, label) + if err != nil { + return nil, err + } + linkID = resolved + } + + result := &repositoryListResult{linkID: linkID} + err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { + req := &controltowerv1.ListBitbucketRepositoriesRequest{} + req.SetLinkId(linkID) + req.SetPagination(paging.NewPaginationRequest(pageSize, pageToken)) + res, err := repositories.ListBitbucketRepositories(ctx, req) + if err != nil { + return "", fmt.Errorf("%s: %w", label, err) + } + + result.scanScope = tui.EnumToken(res.GetScanScope().String(), scanScopePrefix) + for i, item := range res.GetRepositories() { + if item.GetRepositoryUuid() == "" { + return "", fmt.Errorf("%s: invalid response: repository %d is missing its UUID", label, i+1) + } + result.repositories = append(result.repositories, listedRepository{ + RepositoryUUID: item.GetRepositoryUuid(), + FullName: item.GetFullName(), + Visibility: tui.EnumToken(item.GetVisibility().String(), repositoryVisibilityPrefix), + DefaultBranch: item.GetDefaultBranch(), + ScanState: tui.EnumToken(item.GetScanState().String(), repositoryScanStatePrefix), + }) + } + return res.GetPagination().GetNextPageToken(), nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (r *repositoryListResult) RenderJSON() ([]byte, error) { + repositories := r.repositories + if repositories == nil { + repositories = []listedRepository{} + } + return json.MarshalIndent(repositoryListResultJSON{ + LinkID: r.linkID, + ScanScope: r.scanScope, + Repositories: repositories, + }, "", " ") +} + +func (r *repositoryListResult) RenderPlain() string { + var output strings.Builder + output.WriteString("full_name\trepository_uuid\tvisibility\tdefault_branch\tscan_state") + for _, repository := range r.repositories { + output.WriteByte('\n') + output.WriteString(strings.Join(repositoryCells(repository), "\t")) + } + return output.String() +} + +func (r *repositoryListResult) RenderTable() string { + rows := make([][]string, 0, len(r.repositories)) + for _, repository := range r.repositories { + rows = append(rows, repositoryCells(repository)) + } + return table.New(). + Title("Bitbucket repositories"). + Headers("FULL NAME", "REPOSITORY UUID", "VISIBILITY", "DEFAULT BRANCH", "SCAN STATE"). + Rows(rows...). + Footer(fmt.Sprintf("%d %s in link %s, scan scope %s", + len(rows), pluralRepositories(len(rows)), r.linkID, r.scanScope)). + Render() +} + +func repositoryCells(repository listedRepository) []string { + return []string{ + repository.FullName, + repository.RepositoryUUID, + repository.Visibility, + repository.DefaultBranch, + repository.ScanState, + } +} + +func pluralRepositories(n int) string { + if n == 1 { + return "repository" + } + return "repositories" +} diff --git a/internal/cmd/integration/cmd.go b/internal/cmd/integration/cmd.go index 79955f0..ac55026 100644 --- a/internal/cmd/integration/cmd.go +++ b/internal/cmd/integration/cmd.go @@ -5,6 +5,7 @@ package integration import ( "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/cmd/integration/bitbucket" "github.com/safedep/cli/internal/cmd/integration/jfrog" "github.com/spf13/cobra" ) @@ -18,6 +19,7 @@ func Register(root *cobra.Command, a *app.App) { Long: "Commands for integrating SafeDep threat intelligence with third-party tools.", } + bitbucket.Register(cmd, a) jfrog.Register(cmd, a) root.AddCommand(cmd) } diff --git a/internal/cmd/project/github.go b/internal/cmd/project/github.go index d7a54d6..a8208d9 100644 --- a/internal/cmd/project/github.go +++ b/internal/cmd/project/github.go @@ -11,6 +11,8 @@ import ( controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" "github.com/safedep/dry/usefulerror" "google.golang.org/grpc" + + "github.com/safedep/cli/internal/paging" ) const ( @@ -116,9 +118,9 @@ func resolveGitHubLink(ctx context.Context, client githubLinkLister) (string, er const label = "project sync: resolve installation link" var links []githubLink - err := paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { + err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { req := &controltowerv1.ListGitHubAppInstallationLinksRequest{} - req.SetPagination(newPaginationRequest(githubLinkPageSize, pageToken)) + req.SetPagination(paging.NewPaginationRequest(githubLinkPageSize, pageToken)) res, err := client.ListGitHubAppInstallationLinks(ctx, req) if err != nil { return "", fmt.Errorf("%s: %w", label, err) @@ -170,10 +172,10 @@ func resolveRepositoryNames( } matches := make(map[string]githubRepository, len(names)) - err := paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { + err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { req := &controltowerv1.ListGitHubInstallationRepositoriesRequest{} req.SetLinkId(linkID) - req.SetPagination(newPaginationRequest(githubRepositoryPageSize, pageToken)) + req.SetPagination(paging.NewPaginationRequest(githubRepositoryPageSize, pageToken)) res, err := client.ListGitHubInstallationRepositories(ctx, req) if err != nil { return "", fmt.Errorf("%s: %w", label, err) diff --git a/internal/cmd/project/grpc.go b/internal/cmd/project/grpc.go index 1c64735..f7548c7 100644 --- a/internal/cmd/project/grpc.go +++ b/internal/cmd/project/grpc.go @@ -9,6 +9,7 @@ import ( controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" "google.golang.org/grpc" + "github.com/safedep/cli/internal/paging" "github.com/safedep/cli/internal/tui" ) @@ -67,7 +68,7 @@ func newListRequest(in *listInput) (*controltowerv1.ListScansRequest, error) { } req := &controltowerv1.ListScansRequest{} - req.SetPagination(newPaginationRequest(in.PageSize, in.PageToken)) + req.SetPagination(paging.NewPaginationRequest(in.PageSize, in.PageToken)) // The server treats an absent filter and an all-default filter differently // only in cost, but keeping it absent avoids sending empty selectors. @@ -142,45 +143,6 @@ func translateListResponse(res *controltowerv1.ListScansResponse) (*listResult, }, nil } -// paginate walks a cursor-paginated list RPC until the server stops handing out -// a next page token. fetch receives the current page token and returns the next -// one. Returning an empty token from fetch also stops the walk, which lets a -// caller finish early once it has everything it needs. The repeated-token guard -// keeps a server that cycles tokens from looping forever. -func paginate( - ctx context.Context, - label string, - fetch func(ctx context.Context, pageToken string) (string, error), -) error { - pageToken := "" - seen := map[string]struct{}{} - for { - next, err := fetch(ctx, pageToken) - if err != nil { - return err - } - if next == "" { - return nil - } - if _, repeated := seen[next]; repeated { - return fmt.Errorf("%s: invalid response: repeated page token", label) - } - seen[next] = struct{}{} - pageToken = next - } -} - -func newPaginationRequest(pageSize uint32, pageToken string) *messagescontroltowerv1.PaginationRequest { - pagination := &messagescontroltowerv1.PaginationRequest{} - if pageSize > 0 { - pagination.SetPageSize(pageSize) - } - if pageToken != "" { - pagination.SetPageToken(pageToken) - } - return pagination -} - func createProjectScans( ctx context.Context, client createProjectScansClient, diff --git a/internal/cmd/project/resolver.go b/internal/cmd/project/resolver.go index f6b6d7d..6975b69 100644 --- a/internal/cmd/project/resolver.go +++ b/internal/cmd/project/resolver.go @@ -11,6 +11,7 @@ import ( "github.com/safedep/dry/usefulerror" "google.golang.org/grpc" + "github.com/safedep/cli/internal/paging" "github.com/safedep/cli/internal/tui" ) @@ -124,7 +125,7 @@ func resolveProjectNameChunk( } const label = "project scan create: resolve projects" - return paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { + return paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { res, err := client.ListProjects(ctx, newListProjectsRequest(names, pageToken)) if err != nil { return "", fmt.Errorf("%s: %w", label, err) @@ -141,7 +142,7 @@ func newListProjectsRequest(names []string, pageToken string) *controltowerv1.Li filter.SetProjectNames(names) req := &controltowerv1.ListProjectsRequest{} req.SetFilterV2(filter) - req.SetPagination(newPaginationRequest(projectLookupPageSize, pageToken)) + req.SetPagination(paging.NewPaginationRequest(projectLookupPageSize, pageToken)) return req } diff --git a/internal/paging/paging.go b/internal/paging/paging.go new file mode 100644 index 0000000..f0888d2 --- /dev/null +++ b/internal/paging/paging.go @@ -0,0 +1,53 @@ +// Package paging holds the cursor-pagination helpers shared by every +// domain that walks a Control Tower list RPC. It lives outside internal/cmd +// because sibling command packages must not import each other. +package paging + +import ( + "context" + "fmt" + + messagescontroltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" +) + +// Paginate walks a cursor-paginated list RPC until the server stops handing +// out a next page token. fetch receives the current page token and returns +// the next one. Returning an empty token from fetch also stops the walk, +// which lets a caller finish early once it has everything it needs. The +// repeated-token guard keeps a server that cycles tokens from looping +// forever. +func Paginate( + ctx context.Context, + label string, + fetch func(ctx context.Context, pageToken string) (string, error), +) error { + pageToken := "" + seen := map[string]struct{}{} + for { + next, err := fetch(ctx, pageToken) + if err != nil { + return err + } + if next == "" { + return nil + } + if _, repeated := seen[next]; repeated { + return fmt.Errorf("%s: invalid response: repeated page token", label) + } + seen[next] = struct{}{} + pageToken = next + } +} + +// NewPaginationRequest builds a PaginationRequest, leaving zero values unset +// so the server applies its defaults. +func NewPaginationRequest(pageSize uint32, pageToken string) *messagescontroltowerv1.PaginationRequest { + pagination := &messagescontroltowerv1.PaginationRequest{} + if pageSize > 0 { + pagination.SetPageSize(pageSize) + } + if pageToken != "" { + pagination.SetPageToken(pageToken) + } + return pagination +} From 2d823f7fd5377fb81c9599f233e2b964e36e9d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 08:10:18 +0000 Subject: [PATCH 02/11] fix(integration): reject positional arguments on bitbucket commands A stray argument on `link create` reached the RPC and issued a new code, which invalidates every earlier unredeemed code. All four leaf commands take flags only, so they now use cobra.NoArgs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- internal/cmd/integration/bitbucket/allowlist.go | 1 + internal/cmd/integration/bitbucket/link.go | 2 ++ internal/cmd/integration/bitbucket/repository.go | 1 + 3 files changed, 4 insertions(+) diff --git a/internal/cmd/integration/bitbucket/allowlist.go b/internal/cmd/integration/bitbucket/allowlist.go index 46bba9a..5a617a5 100644 --- a/internal/cmd/integration/bitbucket/allowlist.go +++ b/internal/cmd/integration/bitbucket/allowlist.go @@ -41,6 +41,7 @@ func allowlistUpdateCmd(a *app.App) *cobra.Command { var in allowlistUpdateInput cmd := &cobra.Command{ Use: "update", + Args: cobra.NoArgs, Short: "Update the Bitbucket scan scope and repository allowlist", Long: "Change which repositories of a linked Bitbucket workspace SafeDep scans. " + "Scope `all` scans every repository in the workspace. Scope `selected` scans only " + diff --git a/internal/cmd/integration/bitbucket/link.go b/internal/cmd/integration/bitbucket/link.go index 3ebd9bc..4d3053a 100644 --- a/internal/cmd/integration/bitbucket/link.go +++ b/internal/cmd/integration/bitbucket/link.go @@ -27,6 +27,7 @@ type linkCreateResultJSON struct { func linkCreateCmd(a *app.App) *cobra.Command { return &cobra.Command{ Use: "create", + Args: cobra.NoArgs, Short: "Issue a link code for a Bitbucket workspace", Long: "Issue the short-lived, single-use code a Bitbucket workspace admin pastes into " + "the workspace's SafeDep settings page (added by the SafeDep Forge app) to link the " + @@ -116,6 +117,7 @@ type linkListResultJSON struct { func linkListCmd(a *app.App) *cobra.Command { return &cobra.Command{ Use: "list", + Args: cobra.NoArgs, Short: "List Bitbucket workspaces linked to the tenant", Long: "List every Bitbucket workspace linked to the active tenant, with the link ID " + "the repository and allowlist commands take.", diff --git a/internal/cmd/integration/bitbucket/repository.go b/internal/cmd/integration/bitbucket/repository.go index 4dc15f8..1be6697 100644 --- a/internal/cmd/integration/bitbucket/repository.go +++ b/internal/cmd/integration/bitbucket/repository.go @@ -49,6 +49,7 @@ func repositoryListCmd(a *app.App) *cobra.Command { var in repositoryListInput cmd := &cobra.Command{ Use: "list", + Args: cobra.NoArgs, Short: "List repositories of a linked Bitbucket workspace", Long: "List the repositories of a linked Bitbucket workspace with their scan state " + "and the workspace's scan scope. The repository UUIDs feed " + From 3c1b202e3ef20cf784663f077f8cae1b8fbfcc11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 14:57:18 +0000 Subject: [PATCH 03/11] feat(project): generalize project sync to GitHub and Bitbucket The sync command now serves both sources. A new --source flag picks the source, and the command infers it from --repository-id, --repository-uuid, or the tenant's links when only one source is linked. The Bitbucket path resolves workspace links and repository names, and syncs through SyncBitbucketProjects. Extract shared workspace-link resolution into internal/bitbucketlink so the project and integration commands reuse one implementation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- README.md | 2 +- docs/cmd/project-sync.md | 71 ++-- internal/bitbucketlink/bitbucketlink.go | 109 ++++++ .../integration/bitbucket/bitbucket_test.go | 4 +- internal/cmd/integration/bitbucket/client.go | 91 +---- internal/cmd/integration/bitbucket/link.go | 10 +- internal/cmd/project/bitbucket.go | 320 ++++++++++++++++++ internal/cmd/project/bitbucket_test.go | 304 +++++++++++++++++ internal/cmd/project/github.go | 13 +- internal/cmd/project/github_test.go | 10 +- internal/cmd/project/sync.go | 133 +++++++- 11 files changed, 936 insertions(+), 131 deletions(-) create mode 100644 internal/bitbucketlink/bitbucketlink.go create mode 100644 internal/cmd/project/bitbucket.go create mode 100644 internal/cmd/project/bitbucket_test.go diff --git a/README.md b/README.md index d48eb33..1c43227 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Push SafeDep malware findings to external security tools. JFrog XRay is supporte | [`safedep query schema list`](./docs/cmd/query-schema-list.md) | List tables in the query schema | | [`safedep query schema show`](./docs/cmd/query-schema-show.md) | Show one table from the query schema | | [`safedep query schema get`](./docs/cmd/query-schema-get.md) | Get the full schema in one call (for AI agents and scripts) | -| [`safedep project sync`](./docs/cmd/project-sync.md) | Sync GitHub repositories into SafeDep projects | +| [`safedep project sync`](./docs/cmd/project-sync.md) | Sync GitHub or Bitbucket repositories into SafeDep projects | | [`safedep project scan create`](./docs/cmd/project-scan-create.md) | Submit project scans by name to SafeDep Cloud-hosted scanners | | [`safedep project scan list`](./docs/cmd/project-scan-list.md) | List project scans with filters | | [`safedep package scan run`](./docs/cmd/package-scan-run.md) | Submit a package for on-demand scanning | diff --git a/docs/cmd/project-sync.md b/docs/cmd/project-sync.md index 04cec8d..ce8f49b 100644 --- a/docs/cmd/project-sync.md +++ b/docs/cmd/project-sync.md @@ -1,12 +1,13 @@ # safedep project sync -Materialize SafeDep projects from GitHub repositories reachable through a linked -GitHub App installation. +Materialize SafeDep projects from repositories reachable through a linked +source integration: a GitHub App installation or a Bitbucket workspace. ## Synopsis ``` -safedep project sync [OWNER/REPOSITORY...] [--repository-id ID] +safedep project sync [OWNER/REPOSITORY...] [--source github|bitbucket] + [--repository-id ID] [--repository-uuid UUID] [--link-id LINK_ID] [--output table|plain|json] ``` @@ -14,36 +15,52 @@ safedep project sync [OWNER/REPOSITORY...] [--repository-id ID] | Argument | Description | |----------|-------------| -| `OWNER/REPOSITORY` | GitHub repository name, for example `safedep/cli`. | +| `OWNER/REPOSITORY` | Repository name in its source, for example `safedep/cli` on GitHub or `safedep/vet-pipe` on Bitbucket. | -Supply between 1 and 100 repositories in total across positional names and -`--repository-id` values. Repository names are matched case-insensitively, the -way GitHub treats them. +Supply between 1 and 100 repositories in total across positional names, +`--repository-id`, and `--repository-uuid` values. Repository names are +matched case-insensitively. -A SafeDep project's canonical identity is the tenant, the GitHub source, and the -immutable GitHub repository ID. Owner and repository names are metadata, so the -CLI resolves every name to a repository ID before sending the request. Syncing -is idempotent: repeating it for the same repository returns the same project ID. +A SafeDep project's canonical identity is the tenant, the source, and the +source's immutable repository identity: the GitHub repository ID or the +Bitbucket repository UUID. Names are metadata, so the CLI resolves every name +to that identity before sending the request. Syncing is idempotent: repeating +it for the same repository returns the same project ID. + +## Sources + +`--source` picks the repository source. When omitted: + +1. `--repository-id` implies `github` and `--repository-uuid` implies + `bitbucket`. +2. Otherwise the CLI uses the one source the tenant has links for. A tenant + with links to both sources must pass `--source`. A tenant with no link + fails with guidance. + +One invocation serves one source, so `--repository-id` and +`--repository-uuid` do not combine. ## Flags | Flag | Description | |------|-------------| +| `--source ` | Repository source. See Sources for the default. | | `--repository-id ` | Immutable GitHub repository ID to sync instead of a name. Repeat the flag, or pass a comma-separated list, to select multiple repositories. | -| `--link-id ` | GitHub App installation link to sync through. Resolved automatically when the tenant has exactly one link. | +| `--repository-uuid ` | Immutable Bitbucket repository UUID to sync instead of a name: lowercase, hyphenated, no braces. Repeat for multiple repositories. Get UUIDs from `safedep integration bitbucket repository list`. | +| `--link-id ` | Source link to sync through. Resolved automatically when the tenant has exactly one link for the source. | Inherits root flags `--output` and `--profile`. -## Installation links +## Source links -A link associates a GitHub App installation with a SafeDep tenant, and it is the -access path used to resolve repositories. It is not the identity of the resulting -projects. +A link associates a GitHub App installation or a Bitbucket workspace with a +SafeDep tenant, and it is the access path used to resolve repositories. It is +not the identity of the resulting projects. -When `--link-id` is omitted, the CLI lists the tenant's links and uses the only -one it finds. With no link, the command fails and asks you to install and link -the SafeDep GitHub App. With more than one link, it fails and lists the link IDs -and their GitHub account logins so you can pick one with `--link-id`. +When `--link-id` is omitted, the CLI lists the tenant's links for the source +and uses the only one it finds. With no link, the command fails and asks you +to install and link the source integration. With more than one link, it fails +and lists the link IDs so you can pick one with `--link-id`. ## Examples @@ -65,6 +82,13 @@ Sync by immutable repository ID, skipping name resolution: safedep project sync --repository-id 1296269 --repository-id 10270250 ``` +Sync a Bitbucket repository by name, or by UUID: + +```bash +safedep project sync safedep/vet-pipe --source bitbucket +safedep project sync --repository-uuid d6c2f02b-7f78-49a8-b7e3-cf8b86efd115 +``` + Choose the installation link explicitly: ```bash @@ -83,9 +107,10 @@ safedep project sync safedep/cli --output json \ | Field | Description | |-------|-------------| -| `link_id` | The installation link the sync ran through, whether supplied or resolved. Reported once per invocation, in the JSON body and the table footer. | -| `repository_id` | The immutable GitHub repository ID. | -| `repository_name` | The `owner/repository` name as GitHub reports it. Empty for repositories selected by ID, which the CLI never resolved. | +| `link_id` | The source link the sync ran through, whether supplied or resolved. Reported once per invocation, in the JSON body and the table footer. | +| `repository_id` | GitHub only: the immutable repository ID. | +| `repository_uuid` | Bitbucket only: the immutable repository UUID. | +| `repository_name` | The repository name as the source reports it. Empty for repositories selected by ID or UUID, which the CLI never resolved. | | `project_id` | The stable SafeDep project ID for that repository. | An unresolved repository name renders as `-` in table and plain output and is diff --git a/internal/bitbucketlink/bitbucketlink.go b/internal/bitbucketlink/bitbucketlink.go new file mode 100644 index 0000000..5b9f364 --- /dev/null +++ b/internal/bitbucketlink/bitbucketlink.go @@ -0,0 +1,109 @@ +// Package bitbucketlink resolves a tenant's Bitbucket workspace links. It +// lives outside internal/cmd because both the project and the integration +// command trees need it, and sibling command packages must not import each +// other. +package bitbucketlink + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/usefulerror" + "google.golang.org/grpc" + + "github.com/safedep/cli/internal/paging" +) + +// PageSize is the integration service's cap on a listing page. +const PageSize = 100 + +// Lister is the one RPC this package reads. +type Lister interface { + ListBitbucketWorkspaceLinks( + context.Context, + *controltowerv1.ListBitbucketWorkspaceLinksRequest, + ...grpc.CallOption, + ) (*controltowerv1.ListBitbucketWorkspaceLinksResponse, error) +} + +// Link is one Bitbucket workspace link of the tenant. +type Link struct { + ID string + WorkspaceUUID string + WorkspaceSlug string + WorkspaceName string +} + +// List walks every page of the tenant's workspace links. +func List(ctx context.Context, client Lister, label string) ([]Link, error) { + var links []Link + err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { + req := &controltowerv1.ListBitbucketWorkspaceLinksRequest{} + req.SetPagination(paging.NewPaginationRequest(PageSize, pageToken)) + res, err := client.ListBitbucketWorkspaceLinks(ctx, req) + if err != nil { + return "", fmt.Errorf("%s: %w", label, err) + } + for i, item := range res.GetLinks() { + if item.GetLinkId() == "" { + return "", fmt.Errorf("%s: invalid response: link %d is missing its ID", label, i+1) + } + links = append(links, Link{ + ID: item.GetLinkId(), + WorkspaceUUID: item.GetWorkspaceUuid(), + WorkspaceSlug: item.GetWorkspaceSlug(), + WorkspaceName: item.GetWorkspaceName(), + }) + } + return res.GetPagination().GetNextPageToken(), nil + }) + if err != nil { + return nil, err + } + return links, nil +} + +// ResolveSingle returns the tenant's only workspace link when the caller did +// not pick one with --link-id. With no link or more than one, it fails with +// guidance, mirroring the GitHub link resolution in `project sync`. +func ResolveSingle(ctx context.Context, client Lister, label string) (string, error) { + links, err := List(ctx, client, label) + if err != nil { + return "", err + } + + switch len(links) { + case 0: + return "", usefulerror.NewUsefulError(). + WithCode(usefulerror.ErrNotFound). + WithHumanError("No linked Bitbucket workspace"). + WithHelp("Create a link code with `safedep integration bitbucket link create`, redeem it in the " + + "workspace's SafeDep settings page in Bitbucket, then retry."). + Wrap(fmt.Errorf("%s: the active tenant has no linked bitbucket workspace", label)) + case 1: + return links[0].ID, nil + default: + return "", ambiguousLinkError(links) + } +} + +func ambiguousLinkError(links []Link) error { + choices := make([]string, 0, len(links)) + for _, link := range links { + slug := link.WorkspaceSlug + if slug == "" { + slug = link.WorkspaceUUID + } + choices = append(choices, fmt.Sprintf("%s (%s)", link.ID, slug)) + } + sort.Strings(choices) + return usefulerror.NewUsefulError(). + WithCode(usefulerror.ErrBadRequest). + WithHumanError("More than one linked Bitbucket workspace"). + WithHelp(fmt.Sprintf("Pick one with --link-id: %s.", strings.Join(choices, ", "))). + Wrap(errors.New("the active tenant has more than one linked bitbucket workspace")) +} diff --git a/internal/cmd/integration/bitbucket/bitbucket_test.go b/internal/cmd/integration/bitbucket/bitbucket_test.go index 355be41..5ceeb12 100644 --- a/internal/cmd/integration/bitbucket/bitbucket_test.go +++ b/internal/cmd/integration/bitbucket/bitbucket_test.go @@ -181,8 +181,8 @@ func TestRunLinkList(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, lister.calls) require.Len(t, result.links, 2) - assert.Equal(t, "link-1", result.links[0].linkID) - assert.Equal(t, "uuid-2", result.links[1].workspaceUUID) + assert.Equal(t, "link-1", result.links[0].ID) + assert.Equal(t, "uuid-2", result.links[1].WorkspaceUUID) }) t.Run("rejects a link without an ID", func(t *testing.T) { diff --git a/internal/cmd/integration/bitbucket/client.go b/internal/cmd/integration/bitbucket/client.go index 8975d24..c0e8ad2 100644 --- a/internal/cmd/integration/bitbucket/client.go +++ b/internal/cmd/integration/bitbucket/client.go @@ -2,21 +2,16 @@ package bitbucket import ( "context" - "errors" - "fmt" - "sort" - "strings" controltowerv1grpc "buf.build/gen/go/safedep/api/grpc/go/safedep/services/controltower/v1/controltowerv1grpc" controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" - "github.com/safedep/dry/usefulerror" "google.golang.org/grpc" - "github.com/safedep/cli/internal/paging" + "github.com/safedep/cli/internal/bitbucketlink" ) // Every list RPC in the integration service caps a page at 100 items. -const pageSize = 100 +const pageSize = bitbucketlink.PageSize type linkCodeCreator interface { CreateBitbucketWorkspaceLinkCode( @@ -26,13 +21,7 @@ type linkCodeCreator interface { ) (*controltowerv1.CreateBitbucketWorkspaceLinkCodeResponse, error) } -type linkLister interface { - ListBitbucketWorkspaceLinks( - context.Context, - *controltowerv1.ListBitbucketWorkspaceLinksRequest, - ...grpc.CallOption, - ) (*controltowerv1.ListBitbucketWorkspaceLinksResponse, error) -} +type linkLister = bitbucketlink.Lister type repositoryLister interface { ListBitbucketRepositories( @@ -54,80 +43,12 @@ func newIntegrationClient(conn grpc.ClientConnInterface) controltowerv1grpc.Inte return controltowerv1grpc.NewIntegrationServiceClient(conn) } -type workspaceLink struct { - linkID string - workspaceUUID string - workspaceSlug string - workspaceName string -} +type workspaceLink = bitbucketlink.Link func listWorkspaceLinks(ctx context.Context, client linkLister, label string) ([]workspaceLink, error) { - var links []workspaceLink - err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { - req := &controltowerv1.ListBitbucketWorkspaceLinksRequest{} - req.SetPagination(paging.NewPaginationRequest(pageSize, pageToken)) - res, err := client.ListBitbucketWorkspaceLinks(ctx, req) - if err != nil { - return "", fmt.Errorf("%s: %w", label, err) - } - for i, item := range res.GetLinks() { - if item.GetLinkId() == "" { - return "", fmt.Errorf("%s: invalid response: link %d is missing its ID", label, i+1) - } - links = append(links, workspaceLink{ - linkID: item.GetLinkId(), - workspaceUUID: item.GetWorkspaceUuid(), - workspaceSlug: item.GetWorkspaceSlug(), - workspaceName: item.GetWorkspaceName(), - }) - } - return res.GetPagination().GetNextPageToken(), nil - }) - if err != nil { - return nil, err - } - return links, nil + return bitbucketlink.List(ctx, client, label) } -// resolveLinkID returns the tenant's only workspace link when the caller did -// not pick one with --link-id. With no link or more than one, it fails with -// guidance, mirroring the GitHub link resolution in `project sync`. func resolveLinkID(ctx context.Context, client linkLister, label string) (string, error) { - links, err := listWorkspaceLinks(ctx, client, label) - if err != nil { - return "", err - } - - switch len(links) { - case 0: - return "", newBitbucketError( - usefulerror.ErrNotFound, - "No linked Bitbucket workspace", - "Create a link code with `safedep integration bitbucket link create`, redeem it in the "+ - "workspace's SafeDep settings page in Bitbucket, then retry.", - fmt.Errorf("%s: the active tenant has no linked bitbucket workspace", label), - ) - case 1: - return links[0].linkID, nil - default: - return "", ambiguousLinkError(links) - } -} - -func ambiguousLinkError(links []workspaceLink) error { - choices := make([]string, 0, len(links)) - for _, link := range links { - slug := link.workspaceSlug - if slug == "" { - slug = link.workspaceUUID - } - choices = append(choices, fmt.Sprintf("%s (%s)", link.linkID, slug)) - } - sort.Strings(choices) - return newBitbucketError( - usefulerror.ErrBadRequest, - "More than one linked Bitbucket workspace", - fmt.Sprintf("Pick one with --link-id: %s.", strings.Join(choices, ", ")), - errors.New("the active tenant has more than one linked bitbucket workspace"), - ) + return bitbucketlink.ResolveSingle(ctx, client, label) } diff --git a/internal/cmd/integration/bitbucket/link.go b/internal/cmd/integration/bitbucket/link.go index 4d3053a..2c06621 100644 --- a/internal/cmd/integration/bitbucket/link.go +++ b/internal/cmd/integration/bitbucket/link.go @@ -148,10 +148,10 @@ func (r *linkListResult) RenderJSON() ([]byte, error) { links := make([]workspaceLinkJSON, 0, len(r.links)) for _, link := range r.links { links = append(links, workspaceLinkJSON{ - LinkID: link.linkID, - WorkspaceUUID: link.workspaceUUID, - WorkspaceSlug: link.workspaceSlug, - WorkspaceName: link.workspaceName, + LinkID: link.ID, + WorkspaceUUID: link.WorkspaceUUID, + WorkspaceSlug: link.WorkspaceSlug, + WorkspaceName: link.WorkspaceName, }) } return json.MarshalIndent(linkListResultJSON{Links: links}, "", " ") @@ -181,7 +181,7 @@ func (r *linkListResult) RenderTable() string { } func workspaceLinkCells(link workspaceLink) []string { - return []string{link.linkID, link.workspaceUUID, link.workspaceSlug, link.workspaceName} + return []string{link.ID, link.WorkspaceUUID, link.WorkspaceSlug, link.WorkspaceName} } func pluralWorkspaces(n int) string { diff --git a/internal/cmd/project/bitbucket.go b/internal/cmd/project/bitbucket.go new file mode 100644 index 0000000..580bf07 --- /dev/null +++ b/internal/cmd/project/bitbucket.go @@ -0,0 +1,320 @@ +package project + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/tui/table" + "github.com/safedep/dry/usefulerror" + "google.golang.org/grpc" + + "github.com/safedep/cli/internal/bitbucketlink" + "github.com/safedep/cli/internal/paging" +) + +// ListBitbucketRepositories caps a page at 100 repositories. +const bitbucketRepositoryPageSize = 100 + +type bitbucketRepositoryLister interface { + ListBitbucketRepositories( + context.Context, + *controltowerv1.ListBitbucketRepositoriesRequest, + ...grpc.CallOption, + ) (*controltowerv1.ListBitbucketRepositoriesResponse, error) +} + +type bitbucketProjectSyncer interface { + SyncBitbucketProjects( + context.Context, + *controltowerv1.SyncBitbucketProjectsRequest, + ...grpc.CallOption, + ) (*controltowerv1.SyncBitbucketProjectsResponse, error) +} + +type bitbucketRepository struct { + uuid string + fullName string +} + +type syncedBitbucketProject struct { + RepositoryUUID string `json:"repository_uuid"` + RepositoryName string `json:"repository_name,omitempty"` + ProjectID string `json:"project_id"` +} + +type bitbucketSyncResult struct { + linkID string + projects []syncedBitbucketProject +} + +type bitbucketSyncResultJSON struct { + LinkID string `json:"link_id"` + Projects []syncedBitbucketProject `json:"projects"` +} + +func runBitbucketSync( + ctx context.Context, + links bitbucketlink.Lister, + repositories bitbucketRepositoryLister, + syncer bitbucketProjectSyncer, + in syncInput, +) (*bitbucketSyncResult, error) { + linkID := in.LinkID + if linkID == "" { + resolved, err := bitbucketlink.ResolveSingle(ctx, links, "project sync: resolve workspace link") + if err != nil { + return nil, err + } + linkID = resolved + } + + resolved, err := resolveBitbucketRepositoryNames(ctx, repositories, linkID, in.RepositoryNames) + if err != nil { + return nil, err + } + + // Flag-selected UUIDs go first, then resolved names in argument order, + // so the request order matches what the caller typed. + repositoryUUIDs := append([]string{}, in.RepositoryUUIDs...) + namesByUUID := make(map[string]string, len(resolved)) + seen := make(map[string]struct{}, len(repositoryUUIDs)+len(resolved)) + for _, repositoryUUID := range repositoryUUIDs { + seen[repositoryUUID] = struct{}{} + } + for _, repository := range resolved { + if _, duplicate := seen[repository.uuid]; duplicate { + cause := fmt.Errorf( + "duplicate repository UUID %s after name resolution: %q", + repository.uuid, + repository.fullName, + ) + return nil, invalidRepositorySelectionError(cause, fmt.Sprintf( + "Repository %q resolves to repository UUID %s, which is already selected. Remove one of them and retry.", + repository.fullName, + repository.uuid, + )) + } + seen[repository.uuid] = struct{}{} + namesByUUID[repository.uuid] = repository.fullName + repositoryUUIDs = append(repositoryUUIDs, repository.uuid) + } + + return syncBitbucketProjects(ctx, syncer, linkID, repositoryUUIDs, namesByUUID) +} + +// resolveBitbucketRepositoryNames maps workspace/repository names to +// immutable Bitbucket repository UUIDs. The listing RPC is an unfiltered +// walk over the workspace, so it stops as soon as every requested name has +// a match. +func resolveBitbucketRepositoryNames( + ctx context.Context, + client bitbucketRepositoryLister, + linkID string, + names []string, +) ([]bitbucketRepository, error) { + if len(names) == 0 { + return nil, nil + } + + const label = "project sync: resolve repositories" + requested := make(map[string]struct{}, len(names)) + for _, name := range names { + requested[repositoryKey(name)] = struct{}{} + } + + matches := make(map[string]bitbucketRepository, len(names)) + err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { + req := &controltowerv1.ListBitbucketRepositoriesRequest{} + req.SetLinkId(linkID) + req.SetPagination(paging.NewPaginationRequest(bitbucketRepositoryPageSize, pageToken)) + res, err := client.ListBitbucketRepositories(ctx, req) + if err != nil { + return "", fmt.Errorf("%s: %w", label, err) + } + if err := collectBitbucketRepositoryMatches(res, label, requested, matches); err != nil { + return "", err + } + if len(matches) == len(requested) { + return "", nil + } + return res.GetPagination().GetNextPageToken(), nil + }) + if err != nil { + return nil, err + } + + resolved := make([]bitbucketRepository, 0, len(names)) + for _, name := range names { + match, ok := matches[repositoryKey(name)] + if !ok { + return nil, bitbucketRepositoryNotAccessibleError(name, linkID) + } + resolved = append(resolved, match) + } + return resolved, nil +} + +func collectBitbucketRepositoryMatches( + res *controltowerv1.ListBitbucketRepositoriesResponse, + label string, + requested map[string]struct{}, + matches map[string]bitbucketRepository, +) error { + for i, repository := range res.GetRepositories() { + if repository.GetRepositoryUuid() == "" || repository.GetFullName() == "" { + return fmt.Errorf("%s: invalid response: repository %d is missing its identity", label, i+1) + } + key := repositoryKey(repository.GetFullName()) + if _, ok := requested[key]; !ok { + continue + } + matches[key] = bitbucketRepository{ + uuid: repository.GetRepositoryUuid(), + fullName: repository.GetFullName(), + } + } + return nil +} + +func bitbucketRepositoryNotAccessibleError(name, linkID string) error { + cause := fmt.Errorf( + "project sync: repository %q is not accessible through workspace link %q", + name, + linkID, + ) + return newProjectError( + usefulerror.ErrNotFound, + "Bitbucket repository not accessible", + fmt.Sprintf("Confirm %q is in the linked workspace, or retry with --repository-uuid.", name), + cause, + ) +} + +func syncBitbucketProjects( + ctx context.Context, + client bitbucketProjectSyncer, + linkID string, + repositoryUUIDs []string, + namesByUUID map[string]string, +) (*bitbucketSyncResult, error) { + res, err := client.SyncBitbucketProjects(ctx, newBitbucketSyncRequest(linkID, repositoryUUIDs)) + if err != nil { + return nil, fmt.Errorf("project sync: %w", err) + } + projects, err := translateBitbucketSyncResponse(res, repositoryUUIDs, namesByUUID) + if err != nil { + return nil, err + } + return &bitbucketSyncResult{linkID: linkID, projects: projects}, nil +} + +func newBitbucketSyncRequest(linkID string, repositoryUUIDs []string) *controltowerv1.SyncBitbucketProjectsRequest { + selections := make([]*controltowerv1.SyncBitbucketProjectsRequest_RepositorySelection, 0, len(repositoryUUIDs)) + for _, repositoryUUID := range repositoryUUIDs { + selection := &controltowerv1.SyncBitbucketProjectsRequest_RepositorySelection{} + selection.SetRepositoryUuid(repositoryUUID) + selections = append(selections, selection) + } + req := &controltowerv1.SyncBitbucketProjectsRequest{} + req.SetLinkId(linkID) + req.SetRepositories(selections) + return req +} + +func translateBitbucketSyncResponse( + res *controltowerv1.SyncBitbucketProjectsResponse, + repositoryUUIDs []string, + namesByUUID map[string]string, +) ([]syncedBitbucketProject, error) { + mappings := res.GetProjects() + if len(mappings) != len(repositoryUUIDs) { + return nil, fmt.Errorf( + "project sync: invalid response: response count %d does not match request count %d", + len(mappings), + len(repositoryUUIDs), + ) + } + + requested := make(map[string]struct{}, len(repositoryUUIDs)) + for _, repositoryUUID := range repositoryUUIDs { + requested[repositoryUUID] = struct{}{} + } + + seen := make(map[string]struct{}, len(mappings)) + projects := make([]syncedBitbucketProject, 0, len(mappings)) + for _, mapping := range mappings { + repositoryUUID := mapping.GetRepositoryUuid() + if _, ok := requested[repositoryUUID]; !ok { + return nil, fmt.Errorf( + "project sync: invalid response: unexpected repository UUID %s", + repositoryUUID, + ) + } + if _, duplicate := seen[repositoryUUID]; duplicate { + return nil, fmt.Errorf( + "project sync: invalid response: duplicate repository UUID %s", + repositoryUUID, + ) + } + if mapping.GetProjectId() == "" { + return nil, fmt.Errorf( + "project sync: invalid response: repository UUID %s is missing its project ID", + repositoryUUID, + ) + } + seen[repositoryUUID] = struct{}{} + projects = append(projects, syncedBitbucketProject{ + RepositoryUUID: repositoryUUID, + RepositoryName: namesByUUID[repositoryUUID], + ProjectID: mapping.GetProjectId(), + }) + } + return projects, nil +} + +func (r *bitbucketSyncResult) RenderJSON() ([]byte, error) { + projects := r.projects + if projects == nil { + projects = []syncedBitbucketProject{} + } + return json.MarshalIndent(bitbucketSyncResultJSON{LinkID: r.linkID, Projects: projects}, "", " ") +} + +func (r *bitbucketSyncResult) RenderPlain() string { + var output strings.Builder + output.WriteString("repository_name\trepository_uuid\tproject_id") + for _, project := range r.projects { + output.WriteByte('\n') + output.WriteString(strings.Join(syncedBitbucketProjectCells(project), "\t")) + } + return output.String() +} + +func (r *bitbucketSyncResult) RenderTable() string { + rows := make([][]string, 0, len(r.projects)) + for _, project := range r.projects { + rows = append(rows, syncedBitbucketProjectCells(project)) + } + return table.New(). + Title("Synced projects"). + Headers("REPOSITORY", "REPOSITORY UUID", "PROJECT ID"). + Rows(rows...). + Footer(fmt.Sprintf("%d %s synced through link %s", + len(rows), pluralRepositories(len(rows)), r.linkID)). + Render() +} + +func syncedBitbucketProjectCells(project syncedBitbucketProject) []string { + name := project.RepositoryName + if name == "" { + name = unknownRepositoryName + } + return []string{ + name, + project.RepositoryUUID, + project.ProjectID, + } +} diff --git a/internal/cmd/project/bitbucket_test.go b/internal/cmd/project/bitbucket_test.go new file mode 100644 index 0000000..bc3349c --- /dev/null +++ b/internal/cmd/project/bitbucket_test.go @@ -0,0 +1,304 @@ +package project + +import ( + "context" + "errors" + "testing" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/safedep/cli/internal/bitbucketlink" +) + +type fakeBitbucketLinkLister struct { + pages []*controltowerv1.ListBitbucketWorkspaceLinksResponse + calls int + err error +} + +func (f *fakeBitbucketLinkLister) ListBitbucketWorkspaceLinks( + _ context.Context, + _ *controltowerv1.ListBitbucketWorkspaceLinksRequest, + _ ...grpc.CallOption, +) (*controltowerv1.ListBitbucketWorkspaceLinksResponse, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + if len(f.pages) == 0 { + return nil, errors.New("unexpected ListBitbucketWorkspaceLinks call") + } + return f.pages[min(f.calls-1, len(f.pages)-1)], nil +} + +type fakeBitbucketRepositoryLister struct { + pages []*controltowerv1.ListBitbucketRepositoriesResponse + calls int + err error +} + +func (f *fakeBitbucketRepositoryLister) ListBitbucketRepositories( + _ context.Context, + _ *controltowerv1.ListBitbucketRepositoriesRequest, + _ ...grpc.CallOption, +) (*controltowerv1.ListBitbucketRepositoriesResponse, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + if len(f.pages) == 0 { + return nil, errors.New("unexpected ListBitbucketRepositories call") + } + return f.pages[min(f.calls-1, len(f.pages)-1)], nil +} + +type fakeBitbucketProjectSyncer struct { + calls int + req *controltowerv1.SyncBitbucketProjectsRequest + res *controltowerv1.SyncBitbucketProjectsResponse + err error +} + +func (f *fakeBitbucketProjectSyncer) SyncBitbucketProjects( + _ context.Context, + req *controltowerv1.SyncBitbucketProjectsRequest, + _ ...grpc.CallOption, +) (*controltowerv1.SyncBitbucketProjectsResponse, error) { + f.calls++ + f.req = req + return f.res, f.err +} + +var ( + _ bitbucketlink.Lister = (*fakeBitbucketLinkLister)(nil) + _ bitbucketRepositoryLister = (*fakeBitbucketRepositoryLister)(nil) + _ bitbucketProjectSyncer = (*fakeBitbucketProjectSyncer)(nil) +) + +func bitbucketLinksPage(links ...*controltowerv1.ListBitbucketWorkspaceLinksResponse_WorkspaceLink) *controltowerv1.ListBitbucketWorkspaceLinksResponse { + res := &controltowerv1.ListBitbucketWorkspaceLinksResponse{} + res.SetLinks(links) + return res +} + +func bitbucketWorkspaceLink(linkID string) *controltowerv1.ListBitbucketWorkspaceLinksResponse_WorkspaceLink { + link := &controltowerv1.ListBitbucketWorkspaceLinksResponse_WorkspaceLink{} + link.SetLinkId(linkID) + link.SetWorkspaceSlug("safedep") + return link +} + +func bitbucketRepositoriesPage(repositories ...*controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository) *controltowerv1.ListBitbucketRepositoriesResponse { + res := &controltowerv1.ListBitbucketRepositoriesResponse{} + res.SetRepositories(repositories) + return res +} + +func bitbucketListedRepository(uuid, fullName string) *controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository { + repository := &controltowerv1.ListBitbucketRepositoriesResponse_BitbucketRepository{} + repository.SetRepositoryUuid(uuid) + repository.SetFullName(fullName) + return repository +} + +func bitbucketSyncResponse(mappings ...*controltowerv1.SyncBitbucketProjectsResponse_ProjectMapping) *controltowerv1.SyncBitbucketProjectsResponse { + res := &controltowerv1.SyncBitbucketProjectsResponse{} + res.SetProjects(mappings) + return res +} + +func bitbucketProjectMapping(uuid, projectID string) *controltowerv1.SyncBitbucketProjectsResponse_ProjectMapping { + mapping := &controltowerv1.SyncBitbucketProjectsResponse_ProjectMapping{} + mapping.SetRepositoryUuid(uuid) + mapping.SetProjectId(projectID) + return mapping +} + +const ( + testBitbucketRepoUUIDa = "d6c2f02b-7f78-49a8-b7e3-cf8b86efd115" + testBitbucketRepoUUIDb = "aaaa0000-1111-2222-3333-444455556666" +) + +func TestRunBitbucketSync(t *testing.T) { + t.Run("resolves names to UUIDs and syncs through the only link", func(t *testing.T) { + links := &fakeBitbucketLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + bitbucketLinksPage(bitbucketWorkspaceLink("link-1")), + }} + repositories := &fakeBitbucketRepositoryLister{pages: []*controltowerv1.ListBitbucketRepositoriesResponse{ + bitbucketRepositoriesPage( + bitbucketListedRepository(testBitbucketRepoUUIDa, "safedep/vet-pipe"), + ), + }} + syncer := &fakeBitbucketProjectSyncer{res: bitbucketSyncResponse( + bitbucketProjectMapping(testBitbucketRepoUUIDa, "project-1"), + )} + + result, err := runBitbucketSync(context.Background(), links, repositories, syncer, + syncInput{RepositoryNames: []string{"SafeDep/Vet-Pipe"}}) + require.NoError(t, err) + require.Equal(t, 1, syncer.calls) + assert.Equal(t, "link-1", syncer.req.GetLinkId()) + require.Len(t, syncer.req.GetRepositories(), 1) + assert.Equal(t, testBitbucketRepoUUIDa, syncer.req.GetRepositories()[0].GetRepositoryUuid()) + require.Len(t, result.projects, 1) + assert.Equal(t, syncedBitbucketProject{ + RepositoryUUID: testBitbucketRepoUUIDa, + RepositoryName: "safedep/vet-pipe", + ProjectID: "project-1", + }, result.projects[0]) + }) + + t.Run("UUID selectors skip name resolution", func(t *testing.T) { + repositories := &fakeBitbucketRepositoryLister{} + syncer := &fakeBitbucketProjectSyncer{res: bitbucketSyncResponse( + bitbucketProjectMapping(testBitbucketRepoUUIDb, "project-2"), + )} + + result, err := runBitbucketSync(context.Background(), &fakeBitbucketLinkLister{}, + repositories, syncer, syncInput{ + LinkID: "link-9", + RepositoryUUIDs: []string{testBitbucketRepoUUIDb}, + }) + require.NoError(t, err) + assert.Equal(t, 0, repositories.calls) + assert.Equal(t, "link-9", syncer.req.GetLinkId()) + require.Len(t, result.projects, 1) + assert.Equal(t, unknownRepositoryName, syncedBitbucketProjectCells(result.projects[0])[0]) + }) + + t.Run("an unreachable name fails without a sync", func(t *testing.T) { + repositories := &fakeBitbucketRepositoryLister{pages: []*controltowerv1.ListBitbucketRepositoriesResponse{ + bitbucketRepositoriesPage(), + }} + syncer := &fakeBitbucketProjectSyncer{} + + _, err := runBitbucketSync(context.Background(), &fakeBitbucketLinkLister{}, + repositories, syncer, syncInput{ + LinkID: "link-1", + RepositoryNames: []string{"safedep/ghost"}, + }) + require.ErrorContains(t, err, "not accessible through workspace link") + assert.Equal(t, 0, syncer.calls) + }) + + t.Run("a name that resolves to a selected UUID is rejected", func(t *testing.T) { + repositories := &fakeBitbucketRepositoryLister{pages: []*controltowerv1.ListBitbucketRepositoriesResponse{ + bitbucketRepositoriesPage( + bitbucketListedRepository(testBitbucketRepoUUIDa, "safedep/vet-pipe"), + ), + }} + syncer := &fakeBitbucketProjectSyncer{} + + _, err := runBitbucketSync(context.Background(), &fakeBitbucketLinkLister{}, + repositories, syncer, syncInput{ + LinkID: "link-1", + RepositoryNames: []string{"safedep/vet-pipe"}, + RepositoryUUIDs: []string{testBitbucketRepoUUIDa}, + }) + require.ErrorContains(t, err, "duplicate repository UUID") + assert.Equal(t, 0, syncer.calls) + }) + + t.Run("rejects a response that does not cover the request", func(t *testing.T) { + syncer := &fakeBitbucketProjectSyncer{res: bitbucketSyncResponse()} + + _, err := runBitbucketSync(context.Background(), &fakeBitbucketLinkLister{}, + &fakeBitbucketRepositoryLister{}, syncer, syncInput{ + LinkID: "link-1", + RepositoryUUIDs: []string{testBitbucketRepoUUIDa}, + }) + require.ErrorContains(t, err, "response count 0 does not match request count 1") + }) + + t.Run("rejects a mapping without a project ID", func(t *testing.T) { + syncer := &fakeBitbucketProjectSyncer{res: bitbucketSyncResponse( + bitbucketProjectMapping(testBitbucketRepoUUIDa, ""), + )} + + _, err := runBitbucketSync(context.Background(), &fakeBitbucketLinkLister{}, + &fakeBitbucketRepositoryLister{}, syncer, syncInput{ + LinkID: "link-1", + RepositoryUUIDs: []string{testBitbucketRepoUUIDa}, + }) + require.ErrorContains(t, err, "missing its project ID") + }) +} + +func TestResolveSyncSource(t *testing.T) { + githubLinksPage := func(linkIDs ...string) *controltowerv1.ListGitHubAppInstallationLinksResponse { + links := make([]*controltowerv1.ListGitHubAppInstallationLinksResponse_IntegrationWithAttributes, 0, len(linkIDs)) + for _, linkID := range linkIDs { + links = append(links, link(linkID, "safedep")) + } + return linksResponse("", links...) + } + + cases := []struct { + name string + in syncInput + github []*controltowerv1.ListGitHubAppInstallationLinksResponse + bitbucket []*controltowerv1.ListBitbucketWorkspaceLinksResponse + wantSource string + wantErr string + }{ + { + name: "explicit source wins", + in: syncInput{Source: sourceBitbucket}, + wantSource: sourceBitbucket, + }, + { + name: "repository IDs imply github", + in: syncInput{RepositoryIDs: []int64{7}}, + wantSource: sourceGitHub, + }, + { + name: "repository UUIDs imply bitbucket", + in: syncInput{RepositoryUUIDs: []string{testBitbucketRepoUUIDa}}, + wantSource: sourceBitbucket, + }, + { + name: "a bitbucket-only tenant infers bitbucket", + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage()}, + bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage(bitbucketWorkspaceLink("link-1"))}, + wantSource: sourceBitbucket, + }, + { + name: "a github-only tenant infers github", + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, + bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage()}, + wantSource: sourceGitHub, + }, + { + name: "links to both sources need an explicit source", + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, + bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage(bitbucketWorkspaceLink("link-1"))}, + wantErr: "has GitHub and Bitbucket links", + }, + { + name: "no links at all is a not-found failure", + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage()}, + bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage()}, + wantErr: "no GitHub or Bitbucket link", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + deps := syncDeps{ + githubLinks: &fakeGitHubLinkLister{pages: tc.github}, + bitbucketLinks: &fakeBitbucketLinkLister{pages: tc.bitbucket}, + } + + source, err := resolveSyncSource(context.Background(), deps, tc.in) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantSource, source) + }) + } +} diff --git a/internal/cmd/project/github.go b/internal/cmd/project/github.go index a8208d9..69f71af 100644 --- a/internal/cmd/project/github.go +++ b/internal/cmd/project/github.go @@ -60,7 +60,7 @@ func newIntegrationClient(conn grpc.ClientConnInterface) controltowerv1grpc.Inte return controltowerv1grpc.NewIntegrationServiceClient(conn) } -func runSync( +func runGitHubSync( ctx context.Context, links githubLinkLister, repositories githubRepositoryLister, @@ -114,7 +114,7 @@ func runSync( return syncGitHubProjects(ctx, syncer, linkID, repositoryIDs, namesByID) } -func resolveGitHubLink(ctx context.Context, client githubLinkLister) (string, error) { +func listGitHubLinks(ctx context.Context, client githubLinkLister) ([]githubLink, error) { const label = "project sync: resolve installation link" var links []githubLink @@ -133,6 +133,15 @@ func resolveGitHubLink(ctx context.Context, client githubLinkLister) (string, er } return res.GetPagination().GetNextPageToken(), nil }) + if err != nil { + return nil, err + } + + return links, nil +} + +func resolveGitHubLink(ctx context.Context, client githubLinkLister) (string, error) { + links, err := listGitHubLinks(ctx, client) if err != nil { return "", err } diff --git a/internal/cmd/project/github_test.go b/internal/cmd/project/github_test.go index d9a25d9..a3cd456 100644 --- a/internal/cmd/project/github_test.go +++ b/internal/cmd/project/github_test.go @@ -101,7 +101,7 @@ func TestRunSync_ResolvesNamesAndSendsFlagIDsFirst(t *testing.T) { projectMapping(11, "project-11"), )} - result, err := runSync(context.Background(), links, repositories, syncer, syncInput{ + result, err := runGitHubSync(context.Background(), links, repositories, syncer, syncInput{ RepositoryNames: []string{"safedep/cli", "safedep/vet"}, RepositoryIDs: []int64{99}, }) @@ -136,7 +136,7 @@ func TestRunSync_SkipsLinkResolutionWhenLinkIDIsSupplied(t *testing.T) { repositories := &fakeGitHubRepositoryLister{} syncer := &fakeGitHubProjectSyncer{res: syncResponse(projectMapping(99, "project-99"))} - result, err := runSync(context.Background(), links, repositories, syncer, syncInput{ + result, err := runGitHubSync(context.Background(), links, repositories, syncer, syncInput{ LinkID: "link-explicit", RepositoryIDs: []int64{99}, }) @@ -158,7 +158,7 @@ func TestRunSync_MatchesRepositoryNamesCaseInsensitively(t *testing.T) { } syncer := &fakeGitHubProjectSyncer{res: syncResponse(projectMapping(12, "project-12"))} - result, err := runSync(context.Background(), &fakeGitHubLinkLister{}, repositories, syncer, syncInput{ + result, err := runGitHubSync(context.Background(), &fakeGitHubLinkLister{}, repositories, syncer, syncInput{ LinkID: "link-1", RepositoryNames: []string{"safedep/cli"}, }) @@ -179,7 +179,7 @@ func TestRunSync_RejectsANameThatCollidesWithASelectedID(t *testing.T) { } syncer := &fakeGitHubProjectSyncer{} - _, err := runSync(context.Background(), &fakeGitHubLinkLister{}, repositories, syncer, syncInput{ + _, err := runGitHubSync(context.Background(), &fakeGitHubLinkLister{}, repositories, syncer, syncInput{ LinkID: "link-1", RepositoryNames: []string{"safedep/cli"}, RepositoryIDs: []int64{12}, @@ -196,7 +196,7 @@ func TestRunSync_RejectsInvalidInputBeforeAnyRPC(t *testing.T) { repositories := &fakeGitHubRepositoryLister{} syncer := &fakeGitHubProjectSyncer{} - _, err := runSync(context.Background(), links, repositories, syncer, syncInput{}) + _, err := runGitHubSync(context.Background(), links, repositories, syncer, syncInput{}) require.Error(t, err) assert.Zero(t, links.calls) assert.Empty(t, repositories.requests) diff --git a/internal/cmd/project/sync.go b/internal/cmd/project/sync.go index ab74032..fb93d06 100644 --- a/internal/cmd/project/sync.go +++ b/internal/cmd/project/sync.go @@ -1,15 +1,20 @@ package project import ( + "context" "encoding/json" + "errors" "fmt" "strconv" "strings" "github.com/safedep/dry/tui/table" + "github.com/safedep/dry/usefulerror" "github.com/spf13/cobra" "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/bitbucketlink" + "github.com/safedep/cli/internal/tui" ) const ( @@ -23,11 +28,18 @@ const ( ) type syncInput struct { + Source string LinkID string RepositoryNames []string RepositoryIDs []int64 + RepositoryUUIDs []string } +const ( + sourceGitHub = "github" + sourceBitbucket = "bitbucket" +) + // syncedProject carries its own JSON tags because the wire shape and the // display shape are identical here, unlike the scan results whose timestamps and // URLs need reshaping. @@ -52,10 +64,12 @@ func syncCmd(a *app.App) *cobra.Command { cmd := &cobra.Command{ Use: "sync [OWNER/REPOSITORY...]", Short: "Sync GitHub repositories into SafeDep projects", - Long: "Materialize one SafeDep project per GitHub repository reachable through a linked " + - "GitHub App installation. Repository names are resolved to immutable GitHub " + - "repository IDs before the request, and the sync is idempotent: repeating it " + - "returns the same project for the same repository.", + Long: "Materialize one SafeDep project per repository reachable through a linked " + + "source integration: a GitHub App installation or a Bitbucket workspace. " + + "Repository names are resolved to the source's immutable identity before the " + + "request, and the sync is idempotent: repeating it returns the same project " + + "for the same repository. When --source is omitted, the CLI uses the one " + + "source the tenant has links for.", Args: func(_ *cobra.Command, args []string) error { in.RepositoryNames = args return validateSyncInput(in) @@ -68,7 +82,15 @@ func syncCmd(a *app.App) *cobra.Command { in.RepositoryNames = args integration := newIntegrationClient(client.Connection()) - result, err := runSync(cmd.Context(), integration, integration, integration, in) + deps := syncDeps{ + githubLinks: integration, + githubRepositories: integration, + githubSyncer: integration, + bitbucketLinks: integration, + bitbucketRepositories: integration, + bitbucketSyncer: integration, + } + result, err := runSync(cmd.Context(), deps, in) if err != nil { return err } @@ -77,32 +99,127 @@ func syncCmd(a *app.App) *cobra.Command { } f := cmd.Flags() + f.StringVar(&in.Source, "source", "", + "repository source: github or bitbucket (inferred from the tenant's links, or from --repository-id and --repository-uuid, when omitted)") f.StringVar(&in.LinkID, "link-id", "", - "GitHub App installation link to sync through; resolved automatically when the tenant has exactly one link") + "source link to sync through; resolved automatically when the tenant has exactly one link") f.Int64SliceVar(&in.RepositoryIDs, "repository-id", nil, "GitHub repository ID to sync instead of a name; repeat for multiple repositories") + f.StringArrayVar(&in.RepositoryUUIDs, "repository-uuid", nil, + "Bitbucket repository UUID to sync instead of a name; repeat for multiple repositories") // pflag renders an empty slice default as "(default [])", which reads as a // value the flag accepts. An empty DefValue suppresses the default in help // without changing the flag's zero value. f.Lookup("repository-id").DefValue = "" + f.Lookup("repository-uuid").DefValue = "" return cmd } func validateSyncInput(in syncInput) error { - total := len(in.RepositoryIDs) + len(in.RepositoryNames) + if in.Source != "" && in.Source != sourceGitHub && in.Source != sourceBitbucket { + cause := fmt.Errorf("unknown --source value %q: allowed values are github, bitbucket", in.Source) + return invalidRepositorySelectionError(cause, "Retry --source with github or bitbucket.") + } + if len(in.RepositoryIDs) > 0 && len(in.RepositoryUUIDs) > 0 { + cause := fmt.Errorf("--repository-id selects GitHub and --repository-uuid selects Bitbucket") + return invalidRepositorySelectionError(cause, "One sync serves one source. Drop one of the flags and retry.") + } + if in.Source == sourceGitHub && len(in.RepositoryUUIDs) > 0 { + cause := fmt.Errorf("--repository-uuid selects Bitbucket repositories") + return invalidRepositorySelectionError(cause, "Drop --repository-uuid, or use --source bitbucket.") + } + if in.Source == sourceBitbucket && len(in.RepositoryIDs) > 0 { + cause := fmt.Errorf("--repository-id selects GitHub repositories") + return invalidRepositorySelectionError(cause, "Drop --repository-id, or use --source github.") + } + + total := len(in.RepositoryIDs) + len(in.RepositoryNames) + len(in.RepositoryUUIDs) if total < 1 || total > maxSyncRepositories { cause := fmt.Errorf("project sync requires between 1 and %d repositories", maxSyncRepositories) return invalidRepositorySelectionError( cause, - fmt.Sprintf("Provide between 1 and %d repository names or --repository-id values.", maxSyncRepositories), + fmt.Sprintf("Provide between 1 and %d repository names, --repository-id, or --repository-uuid values.", maxSyncRepositories), ) } if err := validateRepositoryNames(in.RepositoryNames); err != nil { return err } + if err := validateUniqueValues(in.RepositoryUUIDs, "repository UUID"); err != nil { + return err + } return validateRepositoryIDs(in.RepositoryIDs) } +type syncDeps struct { + githubLinks githubLinkLister + githubRepositories githubRepositoryLister + githubSyncer githubProjectSyncer + bitbucketLinks bitbucketlink.Lister + bitbucketRepositories bitbucketRepositoryLister + bitbucketSyncer bitbucketProjectSyncer +} + +func runSync(ctx context.Context, deps syncDeps, in syncInput) (tui.Renderable, error) { + if err := validateSyncInput(in); err != nil { + return nil, err + } + + source, err := resolveSyncSource(ctx, deps, in) + if err != nil { + return nil, err + } + + if source == sourceBitbucket { + return runBitbucketSync(ctx, deps.bitbucketLinks, deps.bitbucketRepositories, + deps.bitbucketSyncer, in) + } + return runGitHubSync(ctx, deps.githubLinks, deps.githubRepositories, + deps.githubSyncer, in) +} + +// resolveSyncSource picks the repository source. An explicit --source wins, +// then the source a selector flag implies, then the one source the tenant +// has links for. A tenant with links to both sources must pick one. +func resolveSyncSource(ctx context.Context, deps syncDeps, in syncInput) (string, error) { + if in.Source != "" { + return in.Source, nil + } + if len(in.RepositoryIDs) > 0 { + return sourceGitHub, nil + } + if len(in.RepositoryUUIDs) > 0 { + return sourceBitbucket, nil + } + + githubLinks, err := listGitHubLinks(ctx, deps.githubLinks) + if err != nil { + return "", err + } + bitbucketLinks, err := bitbucketlink.List(ctx, deps.bitbucketLinks, + "project sync: resolve workspace link") + if err != nil { + return "", err + } + + switch { + case len(githubLinks) > 0 && len(bitbucketLinks) > 0: + cause := errors.New("project sync: the active tenant has GitHub and Bitbucket links") + return "", invalidRepositorySelectionError(cause, + "Pass --source github or --source bitbucket.") + case len(bitbucketLinks) > 0: + return sourceBitbucket, nil + case len(githubLinks) > 0: + return sourceGitHub, nil + default: + return "", newProjectError( + usefulerror.ErrNotFound, + "No source integration link", + "Install and link the SafeDep GitHub App, or link a Bitbucket workspace, then retry.", + errors.New("project sync: the active tenant has no GitHub or Bitbucket link"), + ) + } +} + // validateRepositoryNames enforces the owner/repository shape and rejects // duplicates case-insensitively, because GitHub treats owner and repository // names as case-insensitive. From c3a09835b740b09d56d53b7575afad50fb7c1ba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 15:10:12 +0000 Subject: [PATCH 04/11] docs(project): update sync help and doc for both sources Review findings: the Short text still named GitHub only, and the resolution section in the doc did not cover the Bitbucket selector. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- docs/cmd/project-sync.md | 17 +++++++++-------- internal/cmd/conventions_test.go | 2 +- internal/cmd/project/sync.go | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/cmd/project-sync.md b/docs/cmd/project-sync.md index ce8f49b..ade4c95 100644 --- a/docs/cmd/project-sync.md +++ b/docs/cmd/project-sync.md @@ -89,7 +89,7 @@ safedep project sync safedep/vet-pipe --source bitbucket safedep project sync --repository-uuid d6c2f02b-7f78-49a8-b7e3-cf8b86efd115 ``` -Choose the installation link explicitly: +Choose the source link explicitly: ```bash safedep project sync safedep/cli --link-id 01K88WX3G9RGAK8T3N5YJMFQN1 @@ -139,15 +139,16 @@ column. The link ID is not part of plain output: read it from `table` or `json`. ## Resolution and failure behaviour The CLI validates the batch size, the `OWNER/REPOSITORY` shape, and duplicate -selectors locally, then resolves the installation link and every repository name -before it sends the sync request. A name that the installation cannot reach fails +selectors locally, then resolves the source link and every repository name +before it sends the sync request. A name that the source link cannot reach fails the command without materializing any project, and the error names the -repository so you can grant it to the installation or retry with -`--repository-id`. +repository. Grant it to the GitHub App installation and retry, or retry with the +immutable selector: `--repository-id` for GitHub, `--repository-uuid` for +Bitbucket. Both immutable selectors skip name resolution. -After resolution, the request carries `--repository-id` values first, followed by -resolved names in argument order. A name that resolves to an already selected -repository ID is rejected locally. +After resolution, the request carries the immutable selector values first, +followed by resolved names in argument order. A name that resolves to an +already selected repository is rejected locally. Control Tower validates every repository against the link before it writes, so a failed request materializes no projects. Installation, repository access, and diff --git a/internal/cmd/conventions_test.go b/internal/cmd/conventions_test.go index f7c5ffa..091961c 100644 --- a/internal/cmd/conventions_test.go +++ b/internal/cmd/conventions_test.go @@ -164,7 +164,7 @@ func TestProjectSyncCommandTree(t *testing.T) { project := requireSubcommand(t, root, "project") sync := requireSubcommand(t, project, "sync") - assert.Equal(t, "Sync GitHub repositories into SafeDep projects", sync.Short) + assert.Equal(t, "Sync GitHub or Bitbucket repositories into SafeDep projects", sync.Short) assert.Contains(t, sync.Long, "GitHub App installation") assert.Contains(t, sync.Long, "idempotent") diff --git a/internal/cmd/project/sync.go b/internal/cmd/project/sync.go index fb93d06..e8e5b83 100644 --- a/internal/cmd/project/sync.go +++ b/internal/cmd/project/sync.go @@ -63,7 +63,7 @@ func syncCmd(a *app.App) *cobra.Command { var in syncInput cmd := &cobra.Command{ Use: "sync [OWNER/REPOSITORY...]", - Short: "Sync GitHub repositories into SafeDep projects", + Short: "Sync GitHub or Bitbucket repositories into SafeDep projects", Long: "Materialize one SafeDep project per repository reachable through a linked " + "source integration: a GitHub App installation or a Bitbucket workspace. " + "Repository names are resolved to the source's immutable identity before the " + From 5aaf88df36ca6177bf8363a2846896c6879a725f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:24:27 +0000 Subject: [PATCH 05/11] fix(project): resolve sync source and link in one listing pass Review findings from #63: - A names-only sync no longer fails for a GitHub-only tenant when the control plane lacks the Bitbucket RPCs or the caller lacks the permission. The inference treats Unimplemented and PermissionDenied from the Bitbucket listing as a tenant with no Bitbucket links. - --link-id now picks the source that owns it, so a tenant with links to both sources can sync by name without --source. An unknown link ID fails with a clear not-found error. - The inference listing also resolves the link, so the per-source path no longer repeats the walk. bitbucketlink.PickSingle and pickGitHubLink apply the selection to links a caller already fetched. - The project Bitbucket path reuses bitbucketlink.PageSize. - integration/bitbucket callers use bitbucketlink directly. The forwarding wrappers and aliases in client.go are gone, and the single-caller error builder is inlined. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- internal/bitbucketlink/bitbucketlink.go | 6 ++ .../cmd/integration/bitbucket/allowlist.go | 5 +- .../integration/bitbucket/bitbucket_test.go | 10 +- internal/cmd/integration/bitbucket/client.go | 17 ---- internal/cmd/integration/bitbucket/errors.go | 15 +-- internal/cmd/integration/bitbucket/link.go | 9 +- .../cmd/integration/bitbucket/repository.go | 7 +- internal/cmd/project/bitbucket.go | 5 +- internal/cmd/project/bitbucket_test.go | 99 ++++++++++++++++--- internal/cmd/project/github.go | 6 ++ internal/cmd/project/sync.go | 92 ++++++++++++++--- 11 files changed, 197 insertions(+), 74 deletions(-) diff --git a/internal/bitbucketlink/bitbucketlink.go b/internal/bitbucketlink/bitbucketlink.go index 5b9f364..8e2ed27 100644 --- a/internal/bitbucketlink/bitbucketlink.go +++ b/internal/bitbucketlink/bitbucketlink.go @@ -75,7 +75,13 @@ func ResolveSingle(ctx context.Context, client Lister, label string) (string, er if err != nil { return "", err } + return PickSingle(links, label) +} +// PickSingle applies ResolveSingle's selection to links a caller already +// fetched, so one listing serves both the source inference and the link +// resolution in `project sync`. +func PickSingle(links []Link, label string) (string, error) { switch len(links) { case 0: return "", usefulerror.NewUsefulError(). diff --git a/internal/cmd/integration/bitbucket/allowlist.go b/internal/cmd/integration/bitbucket/allowlist.go index 5a617a5..f1d168c 100644 --- a/internal/cmd/integration/bitbucket/allowlist.go +++ b/internal/cmd/integration/bitbucket/allowlist.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/bitbucketlink" "github.com/safedep/cli/internal/tui" ) @@ -77,7 +78,7 @@ func allowlistUpdateCmd(a *app.App) *cobra.Command { func runAllowlistUpdate( ctx context.Context, - links linkLister, + links bitbucketlink.Lister, updater allowlistUpdater, in allowlistUpdateInput, ) (*allowlistUpdateResult, error) { @@ -93,7 +94,7 @@ func runAllowlistUpdate( linkID := in.LinkID if linkID == "" { - resolved, err := resolveLinkID(ctx, links, label) + resolved, err := bitbucketlink.ResolveSingle(ctx, links, label) if err != nil { return nil, err } diff --git a/internal/cmd/integration/bitbucket/bitbucket_test.go b/internal/cmd/integration/bitbucket/bitbucket_test.go index 5ceeb12..c76e7f1 100644 --- a/internal/cmd/integration/bitbucket/bitbucket_test.go +++ b/internal/cmd/integration/bitbucket/bitbucket_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/safedep/cli/internal/bitbucketlink" ) type fakeLinkCodeCreator struct { @@ -90,10 +92,10 @@ func (f *fakeAllowlistUpdater) UpdateBitbucketRepositoryAllowlist( } var ( - _ linkCodeCreator = (*fakeLinkCodeCreator)(nil) - _ linkLister = (*fakeLinkLister)(nil) - _ repositoryLister = (*fakeRepositoryLister)(nil) - _ allowlistUpdater = (*fakeAllowlistUpdater)(nil) + _ linkCodeCreator = (*fakeLinkCodeCreator)(nil) + _ bitbucketlink.Lister = (*fakeLinkLister)(nil) + _ repositoryLister = (*fakeRepositoryLister)(nil) + _ allowlistUpdater = (*fakeAllowlistUpdater)(nil) ) func newWorkspaceLinkPage(nextPageToken string, links ...*controltowerv1.ListBitbucketWorkspaceLinksResponse_WorkspaceLink) *controltowerv1.ListBitbucketWorkspaceLinksResponse { diff --git a/internal/cmd/integration/bitbucket/client.go b/internal/cmd/integration/bitbucket/client.go index c0e8ad2..d142363 100644 --- a/internal/cmd/integration/bitbucket/client.go +++ b/internal/cmd/integration/bitbucket/client.go @@ -6,13 +6,8 @@ import ( controltowerv1grpc "buf.build/gen/go/safedep/api/grpc/go/safedep/services/controltower/v1/controltowerv1grpc" controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" "google.golang.org/grpc" - - "github.com/safedep/cli/internal/bitbucketlink" ) -// Every list RPC in the integration service caps a page at 100 items. -const pageSize = bitbucketlink.PageSize - type linkCodeCreator interface { CreateBitbucketWorkspaceLinkCode( context.Context, @@ -21,8 +16,6 @@ type linkCodeCreator interface { ) (*controltowerv1.CreateBitbucketWorkspaceLinkCodeResponse, error) } -type linkLister = bitbucketlink.Lister - type repositoryLister interface { ListBitbucketRepositories( context.Context, @@ -42,13 +35,3 @@ type allowlistUpdater interface { func newIntegrationClient(conn grpc.ClientConnInterface) controltowerv1grpc.IntegrationServiceClient { return controltowerv1grpc.NewIntegrationServiceClient(conn) } - -type workspaceLink = bitbucketlink.Link - -func listWorkspaceLinks(ctx context.Context, client linkLister, label string) ([]workspaceLink, error) { - return bitbucketlink.List(ctx, client, label) -} - -func resolveLinkID(ctx context.Context, client linkLister, label string) (string, error) { - return bitbucketlink.ResolveSingle(ctx, client, label) -} diff --git a/internal/cmd/integration/bitbucket/errors.go b/internal/cmd/integration/bitbucket/errors.go index 67147c1..959a0fd 100644 --- a/internal/cmd/integration/bitbucket/errors.go +++ b/internal/cmd/integration/bitbucket/errors.go @@ -4,19 +4,10 @@ import ( "github.com/safedep/dry/usefulerror" ) -func newBitbucketError(code, humanError, help string, cause error) error { +func invalidSelectionError(cause error, help string) error { return usefulerror.NewUsefulError(). - WithCode(code). - WithHumanError(humanError). + WithCode(usefulerror.ErrBadRequest). + WithHumanError("Invalid allowlist selection"). WithHelp(help). Wrap(cause) } - -func invalidSelectionError(cause error, help string) error { - return newBitbucketError( - usefulerror.ErrBadRequest, - "Invalid allowlist selection", - help, - cause, - ) -} diff --git a/internal/cmd/integration/bitbucket/link.go b/internal/cmd/integration/bitbucket/link.go index 2c06621..8270af5 100644 --- a/internal/cmd/integration/bitbucket/link.go +++ b/internal/cmd/integration/bitbucket/link.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/bitbucketlink" ) type linkCreateResult struct { @@ -100,7 +101,7 @@ func (r *linkCreateResult) cells() []string { } type linkListResult struct { - links []workspaceLink + links []bitbucketlink.Link } type workspaceLinkJSON struct { @@ -136,8 +137,8 @@ func linkListCmd(a *app.App) *cobra.Command { } } -func runLinkList(ctx context.Context, client linkLister) (*linkListResult, error) { - links, err := listWorkspaceLinks(ctx, client, "integration bitbucket link list") +func runLinkList(ctx context.Context, client bitbucketlink.Lister) (*linkListResult, error) { + links, err := bitbucketlink.List(ctx, client, "integration bitbucket link list") if err != nil { return nil, err } @@ -180,7 +181,7 @@ func (r *linkListResult) RenderTable() string { Render() } -func workspaceLinkCells(link workspaceLink) []string { +func workspaceLinkCells(link bitbucketlink.Link) []string { return []string{link.ID, link.WorkspaceUUID, link.WorkspaceSlug, link.WorkspaceName} } diff --git a/internal/cmd/integration/bitbucket/repository.go b/internal/cmd/integration/bitbucket/repository.go index 1be6697..6b8310f 100644 --- a/internal/cmd/integration/bitbucket/repository.go +++ b/internal/cmd/integration/bitbucket/repository.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/safedep/cli/internal/app" + "github.com/safedep/cli/internal/bitbucketlink" "github.com/safedep/cli/internal/paging" "github.com/safedep/cli/internal/tui" ) @@ -75,7 +76,7 @@ func repositoryListCmd(a *app.App) *cobra.Command { func runRepositoryList( ctx context.Context, - links linkLister, + links bitbucketlink.Lister, repositories repositoryLister, in repositoryListInput, ) (*repositoryListResult, error) { @@ -83,7 +84,7 @@ func runRepositoryList( linkID := in.LinkID if linkID == "" { - resolved, err := resolveLinkID(ctx, links, label) + resolved, err := bitbucketlink.ResolveSingle(ctx, links, label) if err != nil { return nil, err } @@ -94,7 +95,7 @@ func runRepositoryList( err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { req := &controltowerv1.ListBitbucketRepositoriesRequest{} req.SetLinkId(linkID) - req.SetPagination(paging.NewPaginationRequest(pageSize, pageToken)) + req.SetPagination(paging.NewPaginationRequest(bitbucketlink.PageSize, pageToken)) res, err := repositories.ListBitbucketRepositories(ctx, req) if err != nil { return "", fmt.Errorf("%s: %w", label, err) diff --git a/internal/cmd/project/bitbucket.go b/internal/cmd/project/bitbucket.go index 580bf07..f539ca0 100644 --- a/internal/cmd/project/bitbucket.go +++ b/internal/cmd/project/bitbucket.go @@ -15,9 +15,6 @@ import ( "github.com/safedep/cli/internal/paging" ) -// ListBitbucketRepositories caps a page at 100 repositories. -const bitbucketRepositoryPageSize = 100 - type bitbucketRepositoryLister interface { ListBitbucketRepositories( context.Context, @@ -129,7 +126,7 @@ func resolveBitbucketRepositoryNames( err := paging.Paginate(ctx, label, func(ctx context.Context, pageToken string) (string, error) { req := &controltowerv1.ListBitbucketRepositoriesRequest{} req.SetLinkId(linkID) - req.SetPagination(paging.NewPaginationRequest(bitbucketRepositoryPageSize, pageToken)) + req.SetPagination(paging.NewPaginationRequest(bitbucketlink.PageSize, pageToken)) res, err := client.ListBitbucketRepositories(ctx, req) if err != nil { return "", fmt.Errorf("%s: %w", label, err) diff --git a/internal/cmd/project/bitbucket_test.go b/internal/cmd/project/bitbucket_test.go index bc3349c..40cc1ed 100644 --- a/internal/cmd/project/bitbucket_test.go +++ b/internal/cmd/project/bitbucket_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/safedep/cli/internal/bitbucketlink" ) @@ -227,6 +229,38 @@ func TestRunBitbucketSync(t *testing.T) { }) } +func TestRunSync_ListsEachSourceOnceForANamesOnlySync(t *testing.T) { + t.Parallel() + + githubLinks := &fakeGitHubLinkLister{pages: []*controltowerv1.ListGitHubAppInstallationLinksResponse{ + linksResponse("", link("gh-link-1", "safedep")), + }} + bitbucketLinks := &fakeBitbucketLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ + bitbucketLinksPage(), + }} + repositories := &fakeGitHubRepositoryLister{ + pages: []*controltowerv1.ListGitHubInstallationRepositoriesResponse{ + repositoriesResponse("", repository(12, "safedep/cli")), + }, + } + syncer := &fakeGitHubProjectSyncer{res: syncResponse(projectMapping(12, "project-12"))} + + deps := syncDeps{ + githubLinks: githubLinks, + githubRepositories: repositories, + githubSyncer: syncer, + bitbucketLinks: bitbucketLinks, + } + _, err := runSync(context.Background(), deps, syncInput{ + RepositoryNames: []string{"safedep/cli"}, + }) + require.NoError(t, err) + + assert.Equal(t, 1, githubLinks.calls, "the inference walk must also resolve the link") + assert.Equal(t, 1, bitbucketLinks.calls) + assert.Equal(t, "gh-link-1", syncer.req.GetLinkId()) +} + func TestResolveSyncSource(t *testing.T) { githubLinksPage := func(linkIDs ...string) *controltowerv1.ListGitHubAppInstallationLinksResponse { links := make([]*controltowerv1.ListGitHubAppInstallationLinksResponse_IntegrationWithAttributes, 0, len(linkIDs)) @@ -237,17 +271,20 @@ func TestResolveSyncSource(t *testing.T) { } cases := []struct { - name string - in syncInput - github []*controltowerv1.ListGitHubAppInstallationLinksResponse - bitbucket []*controltowerv1.ListBitbucketWorkspaceLinksResponse - wantSource string - wantErr string + name string + in syncInput + github []*controltowerv1.ListGitHubAppInstallationLinksResponse + bitbucket []*controltowerv1.ListBitbucketWorkspaceLinksResponse + bitbucketErr error + wantSource string + wantLinkID string + wantErr string }{ { - name: "explicit source wins", - in: syncInput{Source: sourceBitbucket}, + name: "explicit source wins and keeps the caller's link ID", + in: syncInput{Source: sourceBitbucket, LinkID: "link-1"}, wantSource: sourceBitbucket, + wantLinkID: "link-1", }, { name: "repository IDs imply github", @@ -260,16 +297,18 @@ func TestResolveSyncSource(t *testing.T) { wantSource: sourceBitbucket, }, { - name: "a bitbucket-only tenant infers bitbucket", + name: "a bitbucket-only tenant infers bitbucket and its link", github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage()}, bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage(bitbucketWorkspaceLink("link-1"))}, wantSource: sourceBitbucket, + wantLinkID: "link-1", }, { - name: "a github-only tenant infers github", + name: "a github-only tenant infers github and its link", github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage()}, wantSource: sourceGitHub, + wantLinkID: "gh-link-1", }, { name: "links to both sources need an explicit source", @@ -277,6 +316,41 @@ func TestResolveSyncSource(t *testing.T) { bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage(bitbucketWorkspaceLink("link-1"))}, wantErr: "has GitHub and Bitbucket links", }, + { + name: "a link ID picks its owning source when both are linked", + in: syncInput{LinkID: "link-1"}, + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, + bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage(bitbucketWorkspaceLink("link-1"))}, + wantSource: sourceBitbucket, + wantLinkID: "link-1", + }, + { + name: "an unknown link ID is a not-found failure", + in: syncInput{LinkID: "missing"}, + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, + bitbucket: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{bitbucketLinksPage(bitbucketWorkspaceLink("link-1"))}, + wantErr: `link "missing" is not a GitHub or Bitbucket link`, + }, + { + name: "a control plane without the bitbucket RPCs keeps github working", + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, + bitbucketErr: status.Error(codes.Unimplemented, "unknown method"), + wantSource: sourceGitHub, + wantLinkID: "gh-link-1", + }, + { + name: "a caller without the bitbucket permission keeps github working", + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, + bitbucketErr: status.Error(codes.PermissionDenied, "not allowed"), + wantSource: sourceGitHub, + wantLinkID: "gh-link-1", + }, + { + name: "any other bitbucket listing failure stops the sync", + github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, + bitbucketErr: status.Error(codes.Unavailable, "try again"), + wantErr: "resolve workspace link", + }, { name: "no links at all is a not-found failure", github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage()}, @@ -289,16 +363,17 @@ func TestResolveSyncSource(t *testing.T) { t.Run(tc.name, func(t *testing.T) { deps := syncDeps{ githubLinks: &fakeGitHubLinkLister{pages: tc.github}, - bitbucketLinks: &fakeBitbucketLinkLister{pages: tc.bitbucket}, + bitbucketLinks: &fakeBitbucketLinkLister{pages: tc.bitbucket, err: tc.bitbucketErr}, } - source, err := resolveSyncSource(context.Background(), deps, tc.in) + source, linkID, err := resolveSyncSource(context.Background(), deps, tc.in) if tc.wantErr != "" { require.ErrorContains(t, err, tc.wantErr) return } require.NoError(t, err) assert.Equal(t, tc.wantSource, source) + assert.Equal(t, tc.wantLinkID, linkID) }) } } diff --git a/internal/cmd/project/github.go b/internal/cmd/project/github.go index 69f71af..a60458c 100644 --- a/internal/cmd/project/github.go +++ b/internal/cmd/project/github.go @@ -145,7 +145,13 @@ func resolveGitHubLink(ctx context.Context, client githubLinkLister) (string, er if err != nil { return "", err } + return pickGitHubLink(links) +} +// pickGitHubLink applies resolveGitHubLink's selection to links a caller +// already fetched, so one listing serves both the source inference and the +// link resolution in `project sync`. +func pickGitHubLink(links []githubLink) (string, error) { switch len(links) { case 0: return "", newProjectError( diff --git a/internal/cmd/project/sync.go b/internal/cmd/project/sync.go index e8e5b83..0f6c993 100644 --- a/internal/cmd/project/sync.go +++ b/internal/cmd/project/sync.go @@ -11,6 +11,8 @@ import ( "github.com/safedep/dry/tui/table" "github.com/safedep/dry/usefulerror" "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/safedep/cli/internal/app" "github.com/safedep/cli/internal/bitbucketlink" @@ -164,10 +166,11 @@ func runSync(ctx context.Context, deps syncDeps, in syncInput) (tui.Renderable, return nil, err } - source, err := resolveSyncSource(ctx, deps, in) + source, linkID, err := resolveSyncSource(ctx, deps, in) if err != nil { return nil, err } + in.LinkID = linkID if source == sourceBitbucket { return runBitbucketSync(ctx, deps.bitbucketLinks, deps.bitbucketRepositories, @@ -177,41 +180,57 @@ func runSync(ctx context.Context, deps syncDeps, in syncInput) (tui.Renderable, deps.githubSyncer, in) } -// resolveSyncSource picks the repository source. An explicit --source wins, -// then the source a selector flag implies, then the one source the tenant -// has links for. A tenant with links to both sources must pick one. -func resolveSyncSource(ctx context.Context, deps syncDeps, in syncInput) (string, error) { +// resolveSyncSource picks the repository source and the link to sync +// through. An explicit --source wins, then the source a selector flag +// implies; those paths list nothing and keep the caller's --link-id, so the +// per-source path resolves the link with its own single walk. With names +// only, the tenant's links decide: --link-id picks the source that owns it, +// a tenant with one linked source uses that source, and a tenant with both +// must pick one. The listings also resolve the link, so the per-source path +// never repeats the walk. +func resolveSyncSource(ctx context.Context, deps syncDeps, in syncInput) (string, string, error) { if in.Source != "" { - return in.Source, nil + return in.Source, in.LinkID, nil } if len(in.RepositoryIDs) > 0 { - return sourceGitHub, nil + return sourceGitHub, in.LinkID, nil } if len(in.RepositoryUUIDs) > 0 { - return sourceBitbucket, nil + return sourceBitbucket, in.LinkID, nil } githubLinks, err := listGitHubLinks(ctx, deps.githubLinks) if err != nil { - return "", err + return "", "", err } - bitbucketLinks, err := bitbucketlink.List(ctx, deps.bitbucketLinks, - "project sync: resolve workspace link") + bitbucketLinks, err := listBitbucketLinksForInference(ctx, deps.bitbucketLinks) if err != nil { - return "", err + return "", "", err + } + + if in.LinkID != "" { + return sourceOwningLink(in.LinkID, githubLinks, bitbucketLinks) } switch { case len(githubLinks) > 0 && len(bitbucketLinks) > 0: cause := errors.New("project sync: the active tenant has GitHub and Bitbucket links") - return "", invalidRepositorySelectionError(cause, + return "", "", invalidRepositorySelectionError(cause, "Pass --source github or --source bitbucket.") case len(bitbucketLinks) > 0: - return sourceBitbucket, nil + linkID, err := bitbucketlink.PickSingle(bitbucketLinks, syncLinkLabel) + if err != nil { + return "", "", err + } + return sourceBitbucket, linkID, nil case len(githubLinks) > 0: - return sourceGitHub, nil + linkID, err := pickGitHubLink(githubLinks) + if err != nil { + return "", "", err + } + return sourceGitHub, linkID, nil default: - return "", newProjectError( + return "", "", newProjectError( usefulerror.ErrNotFound, "No source integration link", "Install and link the SafeDep GitHub App, or link a Bitbucket workspace, then retry.", @@ -220,6 +239,47 @@ func resolveSyncSource(ctx context.Context, deps syncDeps, in syncInput) (string } } +const syncLinkLabel = "project sync: resolve workspace link" + +// listBitbucketLinksForInference treats a control plane without the +// Bitbucket RPCs, or a caller without the permission for them, as a tenant +// with no Bitbucket links. Without this, a GitHub-only tenant against such a +// control plane loses every names-only sync to the failing listing. Any +// other failure leaves the source undecidable and stops the sync. +func listBitbucketLinksForInference(ctx context.Context, + client bitbucketlink.Lister, +) ([]bitbucketlink.Link, error) { + links, err := bitbucketlink.List(ctx, client, syncLinkLabel) + switch status.Code(err) { + case codes.Unimplemented, codes.PermissionDenied: + return nil, nil + } + return links, err +} + +// sourceOwningLink maps --link-id to the source that owns it, so a tenant +// with links to both sources can sync by name without --source. +func sourceOwningLink(linkID string, githubLinks []githubLink, + bitbucketLinks []bitbucketlink.Link, +) (string, string, error) { + for _, link := range githubLinks { + if link.id == linkID { + return sourceGitHub, linkID, nil + } + } + for _, link := range bitbucketLinks { + if link.ID == linkID { + return sourceBitbucket, linkID, nil + } + } + return "", "", newProjectError( + usefulerror.ErrNotFound, + "Source link not found", + "List link IDs with `safedep integration bitbucket link list`, or drop --link-id to use the tenant's only link.", + fmt.Errorf("project sync: link %q is not a GitHub or Bitbucket link of the active tenant", linkID), + ) +} + // validateRepositoryNames enforces the owner/repository shape and rejects // duplicates case-insensitively, because GitHub treats owner and repository // names as case-insensitive. From 5b849f81c69ab3081f88ebae407b4f8f585bd4ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:34:13 +0000 Subject: [PATCH 06/11] fix(integration): normalize repository UUIDs before any RPC Bitbucket shows repository UUIDs braced and uppercase, and the control plane requires the canonical lowercase, unbraced form. The allowlist update and project sync now parse every UUID value locally with google/uuid and send the canonical form, so a pasted value works and a malformed one fails before auth and link resolution. Normalization runs before validation, so two spellings of one UUID fail the duplicate check. --repository-uuid, --enable, and --disable now split comma-separated values, matching --repository-id. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- .../integration-bitbucket-allowlist-update.md | 2 +- docs/cmd/project-sync.md | 2 +- go.mod | 2 +- internal/bitbucketlink/bitbucketlink.go | 14 ++++++ .../cmd/integration/bitbucket/allowlist.go | 26 +++++++++- .../integration/bitbucket/bitbucket_test.go | 47 +++++++++++++++---- internal/cmd/project/bitbucket_test.go | 28 +++++++++++ internal/cmd/project/sync.go | 23 ++++++++- 8 files changed, 129 insertions(+), 15 deletions(-) diff --git a/docs/cmd/integration-bitbucket-allowlist-update.md b/docs/cmd/integration-bitbucket-allowlist-update.md index 2f7113b..8dde4e4 100644 --- a/docs/cmd/integration-bitbucket-allowlist-update.md +++ b/docs/cmd/integration-bitbucket-allowlist-update.md @@ -26,7 +26,7 @@ opens the scope or fills the allowlist. |------|-------------| | `--link-id ` | Bitbucket workspace link to update. Resolved automatically when the tenant has exactly one link. | | `--scope ` | Scan scope after the update. Omit to keep the stored scope. Switching scope does not change the stored allowlist. | -| `--enable ` | Repository UUID to add to the allowlist. Repeat for multiple repositories. A UUID already on the allowlist is a no-op. | +| `--enable ` | Repository UUID to add to the allowlist. Braced and uppercase forms are accepted and normalized. Repeat for multiple repositories. A UUID already on the allowlist is a no-op. | | `--disable ` | Repository UUID to remove from the allowlist. Repeat for multiple repositories. A UUID not on the allowlist is a no-op. | Pass at least one of `--scope`, `--enable`, or `--disable`. Scope `all` diff --git a/docs/cmd/project-sync.md b/docs/cmd/project-sync.md index ade4c95..a673d66 100644 --- a/docs/cmd/project-sync.md +++ b/docs/cmd/project-sync.md @@ -46,7 +46,7 @@ One invocation serves one source, so `--repository-id` and |------|-------------| | `--source ` | Repository source. See Sources for the default. | | `--repository-id ` | Immutable GitHub repository ID to sync instead of a name. Repeat the flag, or pass a comma-separated list, to select multiple repositories. | -| `--repository-uuid ` | Immutable Bitbucket repository UUID to sync instead of a name: lowercase, hyphenated, no braces. Repeat for multiple repositories. Get UUIDs from `safedep integration bitbucket repository list`. | +| `--repository-uuid ` | Immutable Bitbucket repository UUID to sync instead of a name. Braced and uppercase forms are accepted and normalized. Repeat for multiple repositories. Get UUIDs from `safedep integration bitbucket repository list`. | | `--link-id ` | Source link to sync through. Resolved automatically when the tenant has exactly one link for the source. | Inherits root flags `--output` and `--profile`. diff --git a/go.mod b/go.mod index a747732..017e92f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/cli/browser v1.3.0 github.com/cli/oauth v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/oklog/ulid/v2 v2.1.2 github.com/safedep/dry v0.0.0-20260902191040-800bc36ff686 github.com/spf13/cobra v1.10.2 @@ -51,7 +52,6 @@ require ( github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/go-github/v74 v74.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/internal/bitbucketlink/bitbucketlink.go b/internal/bitbucketlink/bitbucketlink.go index 8e2ed27..4c6dea6 100644 --- a/internal/bitbucketlink/bitbucketlink.go +++ b/internal/bitbucketlink/bitbucketlink.go @@ -12,6 +12,7 @@ import ( "strings" controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/google/uuid" "github.com/safedep/dry/usefulerror" "google.golang.org/grpc" @@ -21,6 +22,19 @@ import ( // PageSize is the integration service's cap on a listing page. const PageSize = 100 +// NormalizeRepositoryUUID validates a repository UUID and returns the +// canonical lowercase, unbraced form the control plane requires. Bitbucket +// shows repository UUIDs braced and uppercase, so the CLI accepts those +// forms instead of letting the server reject them after auth and link +// resolution. +func NormalizeRepositoryUUID(value string) (string, error) { + parsed, err := uuid.Parse(value) + if err != nil { + return "", fmt.Errorf("invalid repository UUID %q: %w", value, err) + } + return parsed.String(), nil +} + // Lister is the one RPC this package reads. type Lister interface { ListBitbucketWorkspaceLinks( diff --git a/internal/cmd/integration/bitbucket/allowlist.go b/internal/cmd/integration/bitbucket/allowlist.go index f1d168c..0c250a1 100644 --- a/internal/cmd/integration/bitbucket/allowlist.go +++ b/internal/cmd/integration/bitbucket/allowlist.go @@ -69,9 +69,9 @@ func allowlistUpdateCmd(a *app.App) *cobra.Command { "Bitbucket workspace link to update; resolved automatically when the tenant has exactly one link") f.StringVar(&in.Scope, "scope", "", "scan scope after the update: all or selected (omit to keep the stored scope)") - f.StringArrayVar(&in.Enable, "enable", nil, + f.StringSliceVar(&in.Enable, "enable", nil, "repository UUID to add to the scan allowlist; repeat for multiple repositories") - f.StringArrayVar(&in.Disable, "disable", nil, + f.StringSliceVar(&in.Disable, "disable", nil, "repository UUID to remove from the scan allowlist; repeat for multiple repositories") return cmd } @@ -88,6 +88,12 @@ func runAllowlistUpdate( if err != nil { return nil, err } + if err := normalizeUUIDValues(in.Enable, "enable"); err != nil { + return nil, err + } + if err := normalizeUUIDValues(in.Disable, "disable"); err != nil { + return nil, err + } if err := validateAllowlistSelection(in); err != nil { return nil, err } @@ -142,6 +148,22 @@ func parseScanScope(in allowlistUpdateInput) (controltowerv1.BitbucketScanScope, return scope, nil } +// normalizeUUIDValues rewrites every value to the canonical lowercase, +// unbraced form the control plane requires, so a UUID pasted from the +// Bitbucket UI works and a malformed value fails before any RPC. +func normalizeUUIDValues(values []string, flag string) error { + for i, value := range values { + normalized, err := bitbucketlink.NormalizeRepositoryUUID(value) + if err != nil { + return invalidSelectionError(err, fmt.Sprintf( + "Provide --%s value %d as a repository UUID from `safedep integration bitbucket repository list`. Braces and uppercase are accepted.", + flag, i+1)) + } + values[i] = normalized + } + return nil +} + func validateAllowlistSelection(in allowlistUpdateInput) error { if in.Scope == "" && len(in.Enable) == 0 && len(in.Disable) == 0 { cause := fmt.Errorf("nothing to update") diff --git a/internal/cmd/integration/bitbucket/bitbucket_test.go b/internal/cmd/integration/bitbucket/bitbucket_test.go index c76e7f1..8b707d4 100644 --- a/internal/cmd/integration/bitbucket/bitbucket_test.go +++ b/internal/cmd/integration/bitbucket/bitbucket_test.go @@ -278,6 +278,12 @@ func TestRunRepositoryList(t *testing.T) { }) } +const ( + testRepoUUIDa = "d6c2f02b-7f78-49a8-b7e3-cf8b86efd115" + testRepoUUIDb = "9a2b3c4d-0000-4000-8000-000000000001" + testRepoUUIDc = "9a2b3c4d-0000-4000-8000-000000000002" +) + func TestRunAllowlistUpdate(t *testing.T) { newUpdaterResponse := func(scope controltowerv1.BitbucketScanScope, count uint32) *controltowerv1.UpdateBitbucketRepositoryAllowlistResponse { res := &controltowerv1.UpdateBitbucketRepositoryAllowlistResponse{} @@ -294,19 +300,32 @@ func TestRunAllowlistUpdate(t *testing.T) { result, err := runAllowlistUpdate(context.Background(), &fakeLinkLister{}, updater, allowlistUpdateInput{ LinkID: "link-1", Scope: "selected", - Enable: []string{"uuid-a", "uuid-b"}, - Disable: []string{"uuid-c"}, + Enable: []string{testRepoUUIDa, testRepoUUIDb}, + Disable: []string{testRepoUUIDc}, }) require.NoError(t, err) require.Equal(t, 1, updater.calls) assert.Equal(t, "link-1", updater.req.GetLinkId()) assert.Equal(t, controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, updater.req.GetScanScope()) - assert.Equal(t, []string{"uuid-a", "uuid-b"}, updater.req.GetEnable()) - assert.Equal(t, []string{"uuid-c"}, updater.req.GetDisable()) + assert.Equal(t, []string{testRepoUUIDa, testRepoUUIDb}, updater.req.GetEnable()) + assert.Equal(t, []string{testRepoUUIDc}, updater.req.GetDisable()) assert.Equal(t, "selected", result.scanScope) assert.Equal(t, uint32(2), result.allowlistCount) }) + t.Run("normalizes braced uppercase UUIDs before the request", func(t *testing.T) { + updater := &fakeAllowlistUpdater{ + res: newUpdaterResponse(controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, 1), + } + + _, err := runAllowlistUpdate(context.Background(), &fakeLinkLister{}, updater, allowlistUpdateInput{ + LinkID: "link-1", + Enable: []string{"{D6C2F02B-7F78-49A8-B7E3-CF8B86EFD115}"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{testRepoUUIDa}, updater.req.GetEnable()) + }) + t.Run("keeps the stored scope when the flag is omitted", func(t *testing.T) { updater := &fakeAllowlistUpdater{ res: newUpdaterResponse(controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_SELECTED, 1), @@ -314,7 +333,7 @@ func TestRunAllowlistUpdate(t *testing.T) { _, err := runAllowlistUpdate(context.Background(), &fakeLinkLister{}, updater, allowlistUpdateInput{ LinkID: "link-1", - Enable: []string{"uuid-a"}, + Enable: []string{testRepoUUIDa}, }) require.NoError(t, err) assert.Equal(t, controltowerv1.BitbucketScanScope_BITBUCKET_SCAN_SCOPE_UNSPECIFIED, updater.req.GetScanScope()) @@ -352,22 +371,32 @@ func TestRunAllowlistUpdate(t *testing.T) { }, { name: "scope all with a delta", - in: allowlistUpdateInput{LinkID: "link-1", Scope: "all", Enable: []string{"uuid-a"}}, + in: allowlistUpdateInput{LinkID: "link-1", Scope: "all", Enable: []string{testRepoUUIDa}}, want: "scope all takes no --enable or --disable values", }, { name: "duplicate enable", - in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{"uuid-a", "uuid-a"}}, + in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{testRepoUUIDa, testRepoUUIDa}}, want: "duplicate --enable value", }, { name: "empty disable", in: allowlistUpdateInput{LinkID: "link-1", Disable: []string{""}}, - want: "--disable value at position 1 must not be empty", + want: `invalid repository UUID ""`, + }, + { + name: "malformed enable", + in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{"not-a-uuid"}}, + want: `invalid repository UUID "not-a-uuid"`, + }, + { + name: "two spellings of one UUID are a duplicate", + in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{testRepoUUIDa, "{D6C2F02B-7F78-49A8-B7E3-CF8B86EFD115}"}}, + want: "duplicate --enable value", }, { name: "enable and disable overlap", - in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{"uuid-a"}, Disable: []string{"uuid-a"}}, + in: allowlistUpdateInput{LinkID: "link-1", Enable: []string{testRepoUUIDa}, Disable: []string{testRepoUUIDa}}, want: "in both --enable and --disable", }, } diff --git a/internal/cmd/project/bitbucket_test.go b/internal/cmd/project/bitbucket_test.go index 40cc1ed..4a3ed60 100644 --- a/internal/cmd/project/bitbucket_test.go +++ b/internal/cmd/project/bitbucket_test.go @@ -229,6 +229,34 @@ func TestRunBitbucketSync(t *testing.T) { }) } +func TestRunSync_NormalizesRepositoryUUIDs(t *testing.T) { + t.Parallel() + + t.Run("a braced uppercase UUID normalizes before the request", func(t *testing.T) { + syncer := &fakeBitbucketProjectSyncer{res: bitbucketSyncResponse( + bitbucketProjectMapping(testBitbucketRepoUUIDa, "project-1"), + )} + + _, err := runSync(context.Background(), syncDeps{bitbucketSyncer: syncer}, syncInput{ + LinkID: "link-1", + RepositoryUUIDs: []string{"{D6C2F02B-7F78-49A8-B7E3-CF8B86EFD115}"}, + }) + require.NoError(t, err) + require.Len(t, syncer.req.GetRepositories(), 1) + assert.Equal(t, testBitbucketRepoUUIDa, syncer.req.GetRepositories()[0].GetRepositoryUuid()) + }) + + t.Run("a malformed UUID fails before any RPC", func(t *testing.T) { + syncer := &fakeBitbucketProjectSyncer{} + + _, err := runSync(context.Background(), syncDeps{bitbucketSyncer: syncer}, syncInput{ + RepositoryUUIDs: []string{"not-a-uuid"}, + }) + require.ErrorContains(t, err, `invalid repository UUID "not-a-uuid"`) + assert.Equal(t, 0, syncer.calls) + }) +} + func TestRunSync_ListsEachSourceOnceForANamesOnlySync(t *testing.T) { t.Parallel() diff --git a/internal/cmd/project/sync.go b/internal/cmd/project/sync.go index 0f6c993..28a8f4a 100644 --- a/internal/cmd/project/sync.go +++ b/internal/cmd/project/sync.go @@ -107,7 +107,7 @@ func syncCmd(a *app.App) *cobra.Command { "source link to sync through; resolved automatically when the tenant has exactly one link") f.Int64SliceVar(&in.RepositoryIDs, "repository-id", nil, "GitHub repository ID to sync instead of a name; repeat for multiple repositories") - f.StringArrayVar(&in.RepositoryUUIDs, "repository-uuid", nil, + f.StringSliceVar(&in.RepositoryUUIDs, "repository-uuid", nil, "Bitbucket repository UUID to sync instead of a name; repeat for multiple repositories") // pflag renders an empty slice default as "(default [])", which reads as a // value the flag accepts. An empty DefValue suppresses the default in help @@ -162,6 +162,9 @@ type syncDeps struct { } func runSync(ctx context.Context, deps syncDeps, in syncInput) (tui.Renderable, error) { + if err := normalizeSyncRepositoryUUIDs(in.RepositoryUUIDs); err != nil { + return nil, err + } if err := validateSyncInput(in); err != nil { return nil, err } @@ -280,6 +283,24 @@ func sourceOwningLink(linkID string, githubLinks []githubLink, ) } +// normalizeSyncRepositoryUUIDs rewrites every --repository-uuid value to the +// canonical lowercase, unbraced form the control plane requires, so a UUID +// pasted from the Bitbucket UI works and a malformed value fails before any +// RPC. It runs before validation, so two spellings of one UUID fail the +// duplicate check. +func normalizeSyncRepositoryUUIDs(values []string) error { + for i, value := range values { + normalized, err := bitbucketlink.NormalizeRepositoryUUID(value) + if err != nil { + return invalidRepositorySelectionError(err, fmt.Sprintf( + "Provide --repository-uuid value %d as a repository UUID from `safedep integration bitbucket repository list`. Braces and uppercase are accepted.", + i+1)) + } + values[i] = normalized + } + return nil +} + // validateRepositoryNames enforces the owner/repository shape and rejects // duplicates case-insensitively, because GitHub treats owner and repository // names as case-insensitive. From 0e9c652e07bc8f912b7b59c57b77e17c1d2e40f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:35:10 +0000 Subject: [PATCH 07/11] fix(project): fail source inference on a denied bitbucket listing Unimplemented proves the control plane has no Bitbucket links, so the inference tolerates only that code. PermissionDenied now propagates: links the caller cannot inspect may exist, and picking GitHub then could sync a name against the wrong source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- internal/cmd/project/bitbucket_test.go | 5 ++--- internal/cmd/project/sync.go | 13 +++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/cmd/project/bitbucket_test.go b/internal/cmd/project/bitbucket_test.go index 4a3ed60..36043c3 100644 --- a/internal/cmd/project/bitbucket_test.go +++ b/internal/cmd/project/bitbucket_test.go @@ -367,11 +367,10 @@ func TestResolveSyncSource(t *testing.T) { wantLinkID: "gh-link-1", }, { - name: "a caller without the bitbucket permission keeps github working", + name: "a caller without the bitbucket permission cannot infer a source", github: []*controltowerv1.ListGitHubAppInstallationLinksResponse{githubLinksPage("gh-link-1")}, bitbucketErr: status.Error(codes.PermissionDenied, "not allowed"), - wantSource: sourceGitHub, - wantLinkID: "gh-link-1", + wantErr: "resolve workspace link", }, { name: "any other bitbucket listing failure stops the sync", diff --git a/internal/cmd/project/sync.go b/internal/cmd/project/sync.go index 28a8f4a..a970170 100644 --- a/internal/cmd/project/sync.go +++ b/internal/cmd/project/sync.go @@ -245,16 +245,17 @@ func resolveSyncSource(ctx context.Context, deps syncDeps, in syncInput) (string const syncLinkLabel = "project sync: resolve workspace link" // listBitbucketLinksForInference treats a control plane without the -// Bitbucket RPCs, or a caller without the permission for them, as a tenant -// with no Bitbucket links. Without this, a GitHub-only tenant against such a -// control plane loses every names-only sync to the failing listing. Any -// other failure leaves the source undecidable and stops the sync. +// Bitbucket RPCs as a tenant with no Bitbucket links, which Unimplemented +// proves. Without this, a GitHub-only tenant against such a control plane +// loses every names-only sync to the failing listing. Every other failure, +// PermissionDenied included, leaves the source undecidable and stops the +// sync: links the caller cannot inspect may exist, and picking GitHub then +// could sync a name against the wrong source. func listBitbucketLinksForInference(ctx context.Context, client bitbucketlink.Lister, ) ([]bitbucketlink.Link, error) { links, err := bitbucketlink.List(ctx, client, syncLinkLabel) - switch status.Code(err) { - case codes.Unimplemented, codes.PermissionDenied: + if status.Code(err) == codes.Unimplemented { return nil, nil } return links, err From c7342633a87eff0c493e246daf9f9bff043bab50 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:44:47 +0000 Subject: [PATCH 08/11] docs(cmd): cover --link-id disambiguation and --disable normalization Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- docs/cmd/integration-bitbucket-allowlist-update.md | 2 +- docs/cmd/project-sync.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/cmd/integration-bitbucket-allowlist-update.md b/docs/cmd/integration-bitbucket-allowlist-update.md index 8dde4e4..568d27f 100644 --- a/docs/cmd/integration-bitbucket-allowlist-update.md +++ b/docs/cmd/integration-bitbucket-allowlist-update.md @@ -27,7 +27,7 @@ opens the scope or fills the allowlist. | `--link-id ` | Bitbucket workspace link to update. Resolved automatically when the tenant has exactly one link. | | `--scope ` | Scan scope after the update. Omit to keep the stored scope. Switching scope does not change the stored allowlist. | | `--enable ` | Repository UUID to add to the allowlist. Braced and uppercase forms are accepted and normalized. Repeat for multiple repositories. A UUID already on the allowlist is a no-op. | -| `--disable ` | Repository UUID to remove from the allowlist. Repeat for multiple repositories. A UUID not on the allowlist is a no-op. | +| `--disable ` | Repository UUID to remove from the allowlist. Braced and uppercase forms are accepted and normalized. Repeat for multiple repositories. A UUID not on the allowlist is a no-op. | Pass at least one of `--scope`, `--enable`, or `--disable`. Scope `all` takes no `--enable` or `--disable` values. At most 500 UUIDs per flag per diff --git a/docs/cmd/project-sync.md b/docs/cmd/project-sync.md index a673d66..e3de5c0 100644 --- a/docs/cmd/project-sync.md +++ b/docs/cmd/project-sync.md @@ -34,7 +34,8 @@ it for the same repository returns the same project ID. 1. `--repository-id` implies `github` and `--repository-uuid` implies `bitbucket`. 2. Otherwise the CLI uses the one source the tenant has links for. A tenant - with links to both sources must pass `--source`. A tenant with no link + with links to both sources must pass `--source` or `--link-id`, and a + link ID resolves to the source that owns it. A tenant with no link fails with guidance. One invocation serves one source, so `--repository-id` and From 490fa5d81c7cac4a6b442317c89af6f584000edb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 09:06:26 +0000 Subject: [PATCH 09/11] feat(integration): show the remaining code lifetime in link create The table showed the raw RFC 3339 expiry. Show "in X min" instead. The plain and JSON output keep the RFC 3339 instant for scripts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- docs/cmd/integration-bitbucket-link-create.md | 2 +- .../integration/bitbucket/bitbucket_test.go | 23 +++++++++++ internal/cmd/integration/bitbucket/link.go | 41 +++++++++++++++---- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/cmd/integration-bitbucket-link-create.md b/docs/cmd/integration-bitbucket-link-create.md index 4476dd2..e8853b1 100644 --- a/docs/cmd/integration-bitbucket-link-create.md +++ b/docs/cmd/integration-bitbucket-link-create.md @@ -35,7 +35,7 @@ safedep integration bitbucket link create --output json | jq -r '.link_code' | Field | Description | |-------|-------------| | `link_code` | The code to paste into the workspace's SafeDep settings page in Bitbucket. | -| `expires_at` | The instant the code expires, RFC 3339 UTC. | +| `expires_at` | The instant the code expires, RFC 3339 UTC, in the plain and JSON output. The table shows the remaining lifetime instead, as `in 15 min`. | ## Authentication diff --git a/internal/cmd/integration/bitbucket/bitbucket_test.go b/internal/cmd/integration/bitbucket/bitbucket_test.go index 8b707d4..94de149 100644 --- a/internal/cmd/integration/bitbucket/bitbucket_test.go +++ b/internal/cmd/integration/bitbucket/bitbucket_test.go @@ -172,6 +172,29 @@ func TestRunLinkCreate(t *testing.T) { }) } +func TestFormatExpiresIn(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) + + cases := []struct { + name string + expiresAt time.Time + expected string + }{ + {"already expired", now.Add(-time.Second), "expired"}, + {"under a minute", now.Add(30 * time.Second), "in less than a minute"}, + {"minutes only", now.Add(15 * time.Minute), "in 15 min"}, + {"seconds round to the nearest minute", now.Add(14*time.Minute + 40*time.Second), "in 15 min"}, + {"whole hours", now.Add(2 * time.Hour), "in 2 h"}, + {"hours and minutes", now.Add(90 * time.Minute), "in 1 h 30 min"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, formatExpiresIn(now, tc.expiresAt)) + }) + } +} + func TestRunLinkList(t *testing.T) { t.Run("walks every page", func(t *testing.T) { lister := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ diff --git a/internal/cmd/integration/bitbucket/link.go b/internal/cmd/integration/bitbucket/link.go index 8270af5..86b5468 100644 --- a/internal/cmd/integration/bitbucket/link.go +++ b/internal/cmd/integration/bitbucket/link.go @@ -80,24 +80,49 @@ func (r *linkCreateResult) RenderJSON() ([]byte, error) { } func (r *linkCreateResult) RenderPlain() string { - return "link_code\texpires_at\n" + strings.Join(r.cells(), "\t") + expiresAt := "" + if r.expiresAt != nil { + expiresAt = r.expiresAt.Format(time.RFC3339) + } + return "link_code\texpires_at\n" + r.linkCode + "\t" + expiresAt } func (r *linkCreateResult) RenderTable() string { + expires := "" + if r.expiresAt != nil { + expires = formatExpiresIn(time.Now(), *r.expiresAt) + } return table.New(). Title("Bitbucket workspace link code"). - Headers("LINK CODE", "EXPIRES AT"). - Rows(r.cells()). + Headers("LINK CODE", "EXPIRES"). + Rows([]string{r.linkCode, expires}). Footer("Paste the code into the workspace's SafeDep settings page in Bitbucket before it expires."). Render() } -func (r *linkCreateResult) cells() []string { - expiresAt := "" - if r.expiresAt != nil { - expiresAt = r.expiresAt.Format(time.RFC3339) +// formatExpiresIn renders the remaining lifetime of the code instead of the +// raw timestamp. The code lives for minutes, so the reader wants "how long do +// I have", not an RFC 3339 instant to decode. +func formatExpiresIn(now, expiresAt time.Time) string { + remaining := expiresAt.Sub(now) + if remaining <= 0 { + return "expired" + } + if remaining < time.Minute { + return "in less than a minute" + } + + minutes := int(remaining.Round(time.Minute).Minutes()) + hours := minutes / 60 + minutes %= 60 + switch { + case hours == 0: + return fmt.Sprintf("in %d min", minutes) + case minutes == 0: + return fmt.Sprintf("in %d h", hours) + default: + return fmt.Sprintf("in %d h %d min", hours, minutes) } - return []string{r.linkCode, expiresAt} } type linkListResult struct { From 0be9c272d682cf4833ce69e84220e4fbe1704ccc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 09:43:52 +0000 Subject: [PATCH 10/11] refactor(integration): use the shared dry humanizer for the expiry cell The CLI already renders relative time with dry/tui/humanize in auth status and the list commands. Drop the local formatter and use it, so the expiry cell reads like the rest of the CLI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- docs/cmd/integration-bitbucket-link-create.md | 2 +- .../integration/bitbucket/bitbucket_test.go | 23 -------------- internal/cmd/integration/bitbucket/link.go | 31 +++---------------- 3 files changed, 6 insertions(+), 50 deletions(-) diff --git a/docs/cmd/integration-bitbucket-link-create.md b/docs/cmd/integration-bitbucket-link-create.md index e8853b1..2896b43 100644 --- a/docs/cmd/integration-bitbucket-link-create.md +++ b/docs/cmd/integration-bitbucket-link-create.md @@ -35,7 +35,7 @@ safedep integration bitbucket link create --output json | jq -r '.link_code' | Field | Description | |-------|-------------| | `link_code` | The code to paste into the workspace's SafeDep settings page in Bitbucket. | -| `expires_at` | The instant the code expires, RFC 3339 UTC, in the plain and JSON output. The table shows the remaining lifetime instead, as `in 15 min`. | +| `expires_at` | The instant the code expires, RFC 3339 UTC, in the plain and JSON output. The table shows the remaining lifetime instead, as `in 15m`. | ## Authentication diff --git a/internal/cmd/integration/bitbucket/bitbucket_test.go b/internal/cmd/integration/bitbucket/bitbucket_test.go index 94de149..8b707d4 100644 --- a/internal/cmd/integration/bitbucket/bitbucket_test.go +++ b/internal/cmd/integration/bitbucket/bitbucket_test.go @@ -172,29 +172,6 @@ func TestRunLinkCreate(t *testing.T) { }) } -func TestFormatExpiresIn(t *testing.T) { - now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) - - cases := []struct { - name string - expiresAt time.Time - expected string - }{ - {"already expired", now.Add(-time.Second), "expired"}, - {"under a minute", now.Add(30 * time.Second), "in less than a minute"}, - {"minutes only", now.Add(15 * time.Minute), "in 15 min"}, - {"seconds round to the nearest minute", now.Add(14*time.Minute + 40*time.Second), "in 15 min"}, - {"whole hours", now.Add(2 * time.Hour), "in 2 h"}, - {"hours and minutes", now.Add(90 * time.Minute), "in 1 h 30 min"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, formatExpiresIn(now, tc.expiresAt)) - }) - } -} - func TestRunLinkList(t *testing.T) { t.Run("walks every page", func(t *testing.T) { lister := &fakeLinkLister{pages: []*controltowerv1.ListBitbucketWorkspaceLinksResponse{ diff --git a/internal/cmd/integration/bitbucket/link.go b/internal/cmd/integration/bitbucket/link.go index 86b5468..194faaf 100644 --- a/internal/cmd/integration/bitbucket/link.go +++ b/internal/cmd/integration/bitbucket/link.go @@ -8,6 +8,7 @@ import ( "time" controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "github.com/safedep/dry/tui/humanize" "github.com/safedep/dry/tui/table" "github.com/spf13/cobra" @@ -88,9 +89,12 @@ func (r *linkCreateResult) RenderPlain() string { } func (r *linkCreateResult) RenderTable() string { + // The code lives for minutes, so the table shows the remaining lifetime + // via the shared humanizer, like auth status and the list commands. The + // plain and JSON output keep the exact instant. expires := "" if r.expiresAt != nil { - expires = formatExpiresIn(time.Now(), *r.expiresAt) + expires = humanize.Time(*r.expiresAt, time.Now()) } return table.New(). Title("Bitbucket workspace link code"). @@ -100,31 +104,6 @@ func (r *linkCreateResult) RenderTable() string { Render() } -// formatExpiresIn renders the remaining lifetime of the code instead of the -// raw timestamp. The code lives for minutes, so the reader wants "how long do -// I have", not an RFC 3339 instant to decode. -func formatExpiresIn(now, expiresAt time.Time) string { - remaining := expiresAt.Sub(now) - if remaining <= 0 { - return "expired" - } - if remaining < time.Minute { - return "in less than a minute" - } - - minutes := int(remaining.Round(time.Minute).Minutes()) - hours := minutes / 60 - minutes %= 60 - switch { - case hours == 0: - return fmt.Sprintf("in %d min", minutes) - case minutes == 0: - return fmt.Sprintf("in %d h", hours) - default: - return fmt.Sprintf("in %d h %d min", hours, minutes) - } -} - type linkListResult struct { links []bitbucketlink.Link } From 198e4a3d2ed0975aede78858a85a31d4e925d3b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 09:47:17 +0000 Subject: [PATCH 11/11] docs(agents): make dry/tui/humanize the rule for humanized table cells DEVGUIDE gets a Humanized values section under Output: table cells go through dry/tui/humanize, plain and JSON keep exact values, and a new value shape extends the dry package rather than adding a local formatter. AGENTS.md gets the distilled line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PZmWHi9y5aCpBaxxWXU6Fo --- AGENTS.md | 1 + docs/DEVGUIDE.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 00760ec..1e4db00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ make release-snapshot # Local release build via goreleaser - Generate mocks with mockery v3 (testify template) for non-trivial interfaces. Hand-rolled fakes for single-method interfaces or function-type parameters. See DEVGUIDE Mocks section - Re-use existing patterns. Prefer refactoring over copying - `dry/log` for internal logging (`log.Warnf` for soft failures) +- Humanize table cells with `dry/tui/humanize` (`Time`, `Duration`). Never a local formatter. New value shapes extend the dry package. See DEVGUIDE Output section For any changes in `.github/workflows/`: diff --git a/docs/DEVGUIDE.md b/docs/DEVGUIDE.md index fbd2a07..d535387 100644 --- a/docs/DEVGUIDE.md +++ b/docs/DEVGUIDE.md @@ -94,6 +94,20 @@ Operational commands (no structured result) call `dry/tui` directly and do not i - No direct use of `fmt.Println`, `fmt.Printf`, `os.Stdout`, or `os.Stderr` outside `internal/tui` and `dry/tui` call sites in commands. **(lint, depguard)** The single allowed exception is the top-level error path in `cmd/safedep/main.go`, which writes the fatal error to stderr before exit. - Reusable visual components (tables, banners, diffs, badges, spinners, progress) come from `dry/tui` sub-packages. Do not reimplement. +### Humanized values + +`RenderTable` humanizes machine values through [`dry/tui/humanize`](https://github.com/safedep/dry/tree/main/tui/humanize): + +- `humanize.Time(t, now)` for instants: `5m ago`, `in 3d`, an absolute UTC date beyond 30 days. +- `humanize.Duration(d)` for durations: `45s`, `1h30m`, `1d12h`. + +Rules: + +- Only `RenderTable` humanizes. `RenderPlain` and `RenderJSON` keep exact values (RFC 3339 instants, raw durations), so pipelines and agents never parse human text. +- Do not write a local time or duration formatter in a command package. Every relative-time cell in the CLI reads the same because they all go through the one package. +- Call sites pass `time.Now()` at render time. The humanize functions themselves are pure and take `now` as a parameter. +- Extension path: a value shape with no humanizer yet (bytes, counts, rates) gets a new pure function in `dry/tui/humanize` via a PR to [safedep/dry](https://github.com/safedep/dry), then a dry dependency bump here. Follow the existing style: compact output, at most two units of precision, table-driven tests next to the function. Only a formatter that is truly specific to one command (encodes its domain wording, not a general value shape) may live next to that command. + ## Authentication - API key (data plane): `a.DataPlane()`.