From 9b70690fb02a443e5869e8864e0e1d3ab53242d7 Mon Sep 17 00:00:00 2001 From: Logan Barnett Date: Mon, 23 Feb 2026 11:20:38 -0800 Subject: [PATCH] pull project and repo values from git context This makes it such that `--key` and `--name` are no longer required if the repository has enough information to be derived (namely from the git remote). This should make CLI usage more ergonomic. The arguments are not gone, so any automation that enjoys these will be unaffected. Earmarked is the exclusion of integration tests if the Bitbucket server is not configured. I am not fluent in Go, but I am told this is a common pattern for integration tests in a Go repo. --- README.md | 53 +++++--- internal/git_context.go | 234 +++++++++++++++++++++++++++++++++ internal/git_context_test.go | 171 ++++++++++++++++++++++++ internal/project_clone_test.go | 1 + internal/project_list_test.go | 1 + internal/repo.go | 22 +++- internal/repo_pr_create.go | 31 ++++- test/util.go | 8 ++ 8 files changed, 495 insertions(+), 26 deletions(-) create mode 100644 internal/git_context.go create mode 100644 internal/git_context_test.go diff --git a/README.md b/README.md index 7143ed5..56d0512 100644 --- a/README.md +++ b/README.md @@ -63,13 +63,13 @@ drwxr-xr-x 3 dvitali dvitali 100 Jul 21 18:09 project-3 ## Repo -This main subcommand requires two arguments: +Most subcommands need to know which repository to operate on. You can supply this explicitly: -- `-k KEY` -- `-n NAME` +- `-k KEY` — project key (e.g. `TOOL`) +- `-n NAME` — repository slug (e.g. `my-repo`) -These are basically the identifiers for your repository, not including one of the twos in all of the -subcommands will result in an error. +Or, if you run the command from inside a cloned Bitbucket repository, both are detected automatically +from the `origin` remote URL and can be omitted. ### PR @@ -77,38 +77,49 @@ This subcommand deals with PRs, please check its subcommands. #### Create -This command, subcommand of (`repo pr`) allows you to create a Pull Request. +Creates a Pull Request. When run from inside a cloned Bitbucket repository, the project key, slug, +and source branch are all detected automatically. If the source branch has exactly one commit ahead +of the target, the PR title and description are pre-populated from that commit's subject and body. -Use it as follows: +Minimal invocation (from inside the repo, on a single-commit feature branch): ``` -bitbucket-cli repo -k "KEY" \ - -n "bitbucket-playground" \ - pr create \ - -t "Some Title" \ - -d "Some Description :thumbsup:" \ - -F "refs/heads/feature/2" -T "refs/heads/master" +$ bitbucket-cli repo pr create -T "refs/heads/master" ``` +Explicit invocation: -##### Usage +``` +$ bitbucket-cli repo -k "KEY" \ + -n "bitbucket-playground" \ + pr create \ + -t "Some Title" \ + -d "Some Description :thumbsup:" \ + -F "refs/heads/feature/2" -T "refs/heads/master" +``` + +##### Usage ``` -Usage: bitbucket-cli repo pr create --title TITLE [--description DESCRIPTION] --from-ref FROM-REF --to-ref TO-REF [--from-key FROM-KEY] [--from-slug FROM-SLUG] +Usage: bitbucket-cli repo [-k KEY] [-n NAME] pr create [-t TITLE] [-d DESCRIPTION] [-F FROM-REF] --to-ref TO-REF [--from-key FROM-KEY] [--from-slug FROM-SLUG] [--reviewers REVIEWERS] Options: --title TITLE, -t TITLE - Title of this PR + Title of this PR; defaults to the commit subject when the branch has + exactly one commit ahead of the target --description DESCRIPTION, -d DESCRIPTION - Description of the PR + Description of the PR; defaults to the commit body when the branch has + exactly one commit ahead of the target --from-ref FROM-REF, -F FROM-REF - Reference of the incoming PR, e.g: refs/heads/feature-ABC-123 + Source branch, e.g: refs/heads/feature-ABC-123; defaults to the current branch --to-ref TO-REF, -T TO-REF - Target reference, e.g: refs/heads/master + Target branch, e.g: refs/heads/master --from-key FROM-KEY, -K FROM-KEY - Project Key of the "from" repository + Project key of the source repository (if different from target) --from-slug FROM-SLUG, -S FROM-SLUG - Repository slug of the "from" repository + Repository slug of the source repository (if different from target) + --reviewers REVIEWERS, -r REVIEWERS + Comma-separated list of reviewers --help, -h display this help and exit ``` diff --git a/internal/git_context.go b/internal/git_context.go new file mode 100644 index 0000000..27372ce --- /dev/null +++ b/internal/git_context.go @@ -0,0 +1,234 @@ +package cli + +import ( + "fmt" + "net/url" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/storer" +) + +// RepoContext holds repository information inferred from the local git repo. +type RepoContext struct { + ProjectKey string + Slug string + Branch string // short branch name, e.g. "feature/my-thing" + BaseURL string // Bitbucket REST base URL, e.g. "https://host/rest" (HTTPS remotes only) +} + +// GetRepoContext opens the git repo at dir (or the nearest parent with a .git), +// reads the current branch and the "origin" remote URL, and parses them into +// a RepoContext. +func GetRepoContext(dir string) (*RepoContext, error) { + repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return nil, fmt.Errorf("not inside a git repository: %v", err) + } + + head, err := repo.Head() + if err != nil { + return nil, fmt.Errorf("could not read HEAD: %v", err) + } + + branch := "" + if head.Name().IsBranch() { + branch = head.Name().Short() + } + + remote, err := repo.Remote("origin") + if err != nil { + return nil, fmt.Errorf("no remote named \"origin\": %v", err) + } + + urls := remote.Config().URLs + if len(urls) == 0 { + return nil, fmt.Errorf("remote \"origin\" has no URLs") + } + + projectKey, slug, baseURL, err := ParseBitbucketRemote(urls[0]) + if err != nil { + return nil, fmt.Errorf("could not parse Bitbucket project/repo from remote URL %q: %v", urls[0], err) + } + + return &RepoContext{ + ProjectKey: projectKey, + Slug: slug, + Branch: branch, + BaseURL: baseURL, + }, nil +} + +// ParseBitbucketRemote parses a Bitbucket Server remote URL and returns the +// project key, repository slug, and (for HTTPS remotes) the REST API base URL. +// +// Supported formats: +// +// HTTPS (root deployment): https://host/scm/PROJECT/repo.git +// HTTPS (context path): https://host/context/scm/PROJECT/repo.git +// SSH URL with port: ssh://git@host:7999/PROJECT/repo.git +// SSH URL without port: ssh://git@host/PROJECT/repo.git +// git+ssh URL: git+ssh://git@host:7999/PROJECT/repo.git +// SCP-style: git@host:PROJECT/repo.git +func ParseBitbucketRemote(remoteURL string) (projectKey, slug, baseURL string, err error) { + switch { + case strings.HasPrefix(remoteURL, "https://"), strings.HasPrefix(remoteURL, "http://"): + return parseHTTPSRemote(remoteURL) + case strings.HasPrefix(remoteURL, "ssh://"), strings.HasPrefix(remoteURL, "git+ssh://"): + return parseSSHURLRemote(remoteURL) + default: + // Fall through to SCP-style: [user@]host:path + return parseSCPRemote(remoteURL) + } +} + +// parseHTTPSRemote handles http(s):// URLs. +// Bitbucket Server HTTPS clone URLs always contain "/scm/" as the separator +// between an optional context path and the project/repo path. +func parseHTTPSRemote(remoteURL string) (projectKey, slug, baseURL string, err error) { + u, err := url.Parse(remoteURL) + if err != nil { + return "", "", "", fmt.Errorf("invalid URL: %v", err) + } + + // Split on "/scm/" to separate the optional context path from the project/repo. + const scmSegment = "/scm/" + idx := strings.Index(u.Path, scmSegment) + if idx < 0 { + return "", "", "", fmt.Errorf("URL does not contain /scm/ — is this a Bitbucket Server clone URL?") + } + + contextPath := u.Path[:idx] // e.g. "" or "/bitbucket" + repoPath := u.Path[idx+len(scmSegment):] // e.g. "PROJECT/repo.git" + + projectKey, slug, err = splitProjectSlug(repoPath) + if err != nil { + return "", "", "", err + } + + // Strip userinfo (username in clone URLs) from host for the base URL. + baseURL = fmt.Sprintf("%s://%s%s/rest", u.Scheme, u.Hostname(), contextPath) + if u.Port() != "" { + baseURL = fmt.Sprintf("%s://%s:%s%s/rest", u.Scheme, u.Hostname(), u.Port(), contextPath) + } + + return projectKey, slug, baseURL, nil +} + +// parseSSHURLRemote handles ssh:// and git+ssh:// URLs. +// Bitbucket Server SSH URLs carry the project/repo directly in the path with +// no context path component. +func parseSSHURLRemote(remoteURL string) (projectKey, slug, baseURL string, err error) { + // Normalise git+ssh:// → ssh:// so url.Parse works cleanly. + normalized := strings.TrimPrefix(remoteURL, "git+") + + u, err := url.Parse(normalized) + if err != nil { + return "", "", "", fmt.Errorf("invalid SSH URL: %v", err) + } + + // Path is "/PROJECT/repo[.git]"; strip the leading slash. + projectKey, slug, err = splitProjectSlug(strings.TrimPrefix(u.Path, "/")) + if err != nil { + return "", "", "", err + } + + return projectKey, slug, "", nil // base URL not determinable from SSH +} + +// parseSCPRemote handles SCP-style URLs: [user@]host:PROJECT/repo[.git] +func parseSCPRemote(remoteURL string) (projectKey, slug, baseURL string, err error) { + colonIdx := strings.Index(remoteURL, ":") + if colonIdx < 0 { + return "", "", "", fmt.Errorf("not a recognised git remote URL: %q", remoteURL) + } + + path := remoteURL[colonIdx+1:] + projectKey, slug, err = splitProjectSlug(path) + if err != nil { + return "", "", "", err + } + + return projectKey, slug, "", nil // base URL not determinable from SCP +} + +// splitProjectSlug splits a "PROJECT/repo[.git]" string into its two parts. +func splitProjectSlug(path string) (projectKey, slug string, err error) { + path = strings.TrimSuffix(path, ".git") + parts := strings.SplitN(path, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("expected PROJECT/repo in %q", path) + } + return parts[0], parts[1], nil +} + +// GetSingleCommitMessage looks at the commits in fromRef that are not +// reachable from toRef. If there is exactly one such commit it returns its +// subject line and body so callers can pre-populate PR title/description. +// Returns found=false (without error) when the count is not exactly one. +func GetSingleCommitMessage(repoPath, fromRef, toRef string) (subject, body string, found bool, err error) { + repo, err := git.PlainOpenWithOptions(repoPath, &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return "", "", false, fmt.Errorf("not inside a git repository: %v", err) + } + + fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef)) + if err != nil { + return "", "", false, fmt.Errorf("could not resolve %q: %v", fromRef, err) + } + + toHash, err := repo.ResolveRevision(plumbing.Revision(toRef)) + if err != nil { + return "", "", false, fmt.Errorf("could not resolve %q: %v", toRef, err) + } + + // Build the set of commits reachable from toRef so we can stop the walk. + toAncestors := make(map[plumbing.Hash]bool) + toIter, err := repo.Log(&git.LogOptions{From: *toHash}) + if err != nil { + return "", "", false, err + } + _ = toIter.ForEach(func(c *object.Commit) error { + toAncestors[c.Hash] = true + return nil + }) + + // Walk from fromRef, collecting commits not in toAncestors. + // Stop as soon as we have seen more than one (no need to count further). + var unique []*object.Commit + fromIter, err := repo.Log(&git.LogOptions{From: *fromHash}) + if err != nil { + return "", "", false, err + } + _ = fromIter.ForEach(func(c *object.Commit) error { + if toAncestors[c.Hash] { + return storer.ErrStop + } + unique = append(unique, c) + if len(unique) > 1 { + return storer.ErrStop + } + return nil + }) + + if len(unique) != 1 { + return "", "", false, nil + } + + subject, body = splitCommitMessage(unique[0].Message) + return subject, body, true, nil +} + +// splitCommitMessage splits a raw commit message into subject and body, +// following the conventional blank-line separator. +func splitCommitMessage(msg string) (subject, body string) { + msg = strings.TrimRight(msg, "\n") + parts := strings.SplitN(msg, "\n", 2) + subject = strings.TrimSpace(parts[0]) + if len(parts) > 1 { + body = strings.TrimSpace(parts[1]) + } + return subject, body +} diff --git a/internal/git_context_test.go b/internal/git_context_test.go new file mode 100644 index 0000000..1570854 --- /dev/null +++ b/internal/git_context_test.go @@ -0,0 +1,171 @@ +package cli_test + +import ( + cli "github.com/swisscom/bitbucket-cli/internal" + "testing" +) + +func TestParseBitbucketRemote(t *testing.T) { + tests := []struct { + name string + remoteURL string + wantProjectKey string + wantSlug string + wantBaseURL string + wantErr bool + }{ + // ── HTTPS, root deployment ──────────────────────────────────────────── + { + name: "https root with .git", + remoteURL: "https://bitbucket.example.com/scm/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "https://bitbucket.example.com/rest", + }, + { + name: "https root without .git", + remoteURL: "https://bitbucket.example.com/scm/MYPROJ/my-repo", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "https://bitbucket.example.com/rest", + }, + { + name: "https root with embedded username", + remoteURL: "https://jsmith@bitbucket.example.com/scm/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "https://bitbucket.example.com/rest", + }, + { + name: "https root with non-standard port", + remoteURL: "https://bitbucket.example.com:8443/scm/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "https://bitbucket.example.com:8443/rest", + }, + + // ── HTTPS, non-root (context path) deployment ──────────────────────── + { + name: "https with single context path segment", + remoteURL: "https://bitbucket.example.com/bitbucket/scm/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "https://bitbucket.example.com/bitbucket/rest", + }, + { + name: "https with nested context path", + remoteURL: "https://example.com/tools/bitbucket/scm/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "https://example.com/tools/bitbucket/rest", + }, + { + name: "https context path with username and port", + remoteURL: "https://jsmith@bitbucket.example.com:8443/stash/scm/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "https://bitbucket.example.com:8443/stash/rest", + }, + + // ── SSH URL format ──────────────────────────────────────────────────── + { + name: "ssh with port", + remoteURL: "ssh://git@bitbucket.example.com:7999/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "", + }, + { + name: "ssh without port", + remoteURL: "ssh://git@bitbucket.example.com/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "", + }, + { + name: "ssh without .git suffix", + remoteURL: "ssh://git@bitbucket.example.com:7999/MYPROJ/my-repo", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "", + }, + + // ── git+ssh URL format ──────────────────────────────────────────────── + { + name: "git+ssh with port", + remoteURL: "git+ssh://git@bitbucket.example.com:7999/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "", + }, + { + name: "git+ssh without port", + remoteURL: "git+ssh://git@bitbucket.example.com/MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "", + }, + + // ── SCP-style ───────────────────────────────────────────────────────── + { + name: "scp with user", + remoteURL: "git@bitbucket.example.com:MYPROJ/my-repo.git", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "", + }, + { + name: "scp without .git suffix", + remoteURL: "git@bitbucket.example.com:MYPROJ/my-repo", + wantProjectKey: "MYPROJ", + wantSlug: "my-repo", + wantBaseURL: "", + }, + + // ── Error cases ─────────────────────────────────────────────────────── + { + name: "https missing /scm/ segment", + remoteURL: "https://bitbucket.example.com/MYPROJ/my-repo.git", + wantErr: true, + }, + { + name: "ssh missing repo segment", + remoteURL: "ssh://git@bitbucket.example.com:7999/MYPROJ", + wantErr: true, + }, + { + name: "scp missing repo segment", + remoteURL: "git@bitbucket.example.com:MYPROJ", + wantErr: true, + }, + { + name: "unrecognised URL with no colon", + remoteURL: "not-a-url", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + projectKey, slug, baseURL, err := cli.ParseBitbucketRemote(tt.remoteURL) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got projectKey=%q slug=%q baseURL=%q", projectKey, slug, baseURL) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if projectKey != tt.wantProjectKey { + t.Errorf("projectKey = %q, want %q", projectKey, tt.wantProjectKey) + } + if slug != tt.wantSlug { + t.Errorf("slug = %q, want %q", slug, tt.wantSlug) + } + if baseURL != tt.wantBaseURL { + t.Errorf("baseURL = %q, want %q", baseURL, tt.wantBaseURL) + } + }) + } +} diff --git a/internal/project_clone_test.go b/internal/project_clone_test.go index 2539268..fb90ed1 100644 --- a/internal/project_clone_test.go +++ b/internal/project_clone_test.go @@ -7,6 +7,7 @@ import ( ) func TestProjectClone(t *testing.T) { + test.SkipIfNoServer(t) c := test.MustGetCLI() c.RunProjectCmd(&cli.ProjectCmd{ Key: "TOOL", diff --git a/internal/project_list_test.go b/internal/project_list_test.go index 0d23d4f..e91bd7d 100644 --- a/internal/project_list_test.go +++ b/internal/project_list_test.go @@ -7,6 +7,7 @@ import ( ) func TestProjectList(t *testing.T) { + test.SkipIfNoServer(t) c := test.MustGetCLI() c.RunProjectCmd(&cli.ProjectCmd{ Key: "TOOL", diff --git a/internal/repo.go b/internal/repo.go index d77e0f6..081b172 100644 --- a/internal/repo.go +++ b/internal/repo.go @@ -1,8 +1,8 @@ package cli type RepoCmd struct { - ProjectKey string `arg:"-k,--key,required,env:BITBUCKET_PROJECT" help:"Project AccessToken (e.g: TOOL)"` - Slug string `arg:"-n,--name,required,env:BITBUCKET_REPO" help:"Slug of the repository"` + ProjectKey string `arg:"-k,--key,env:BITBUCKET_PROJECT" help:"Project key (e.g: TOOL); detected from git remote if not specified"` + Slug string `arg:"-n,--name,env:BITBUCKET_REPO" help:"Slug of the repository; detected from git remote if not specified"` PrCmd *RepoPrCmd `arg:"subcommand:pr"` BranchCmd *BranchCmd `arg:"subcommand:branch"` @@ -14,6 +14,24 @@ func (b *BitbucketCLI) RunRepoCmd(cmd *RepoCmd) { return } + if cmd.ProjectKey == "" || cmd.Slug == "" { + if ctx, err := GetRepoContext("."); err == nil { + if cmd.ProjectKey == "" { + cmd.ProjectKey = ctx.ProjectKey + } + if cmd.Slug == "" { + cmd.Slug = ctx.Slug + } + } + } + + if cmd.ProjectKey == "" { + b.logger.Fatal("project key is required: use -k or run from inside a Bitbucket git repository.") + } + if cmd.Slug == "" { + b.logger.Fatal("repository slug is required: use -n or run from inside a Bitbucket git repository.") + } + if cmd.PrCmd != nil { b.repoPrCmd(cmd) return diff --git a/internal/repo_pr_create.go b/internal/repo_pr_create.go index 249c3b2..5c936f5 100644 --- a/internal/repo_pr_create.go +++ b/internal/repo_pr_create.go @@ -7,10 +7,10 @@ import ( ) type RepoPrCreateCmd struct { - Title string `arg:"-t,--title,required" help:"Title of this PR"` - Description string `arg:"-d,--description" help:"Description of the PR"` + Title string `arg:"-t,--title" help:"Title of this PR; defaults to the commit subject when the branch has exactly one commit"` + Description string `arg:"-d,--description" help:"Description of the PR; defaults to the commit body when the branch has exactly one commit"` - FromRef string `arg:"-F,--from-ref,required" help:"Reference of the incoming PR, e.g: refs/heads/feature-ABC-123"` // e.g: refs/heads/feature-ABC-123 + FromRef string `arg:"-F,--from-ref" help:"Reference of the incoming PR, e.g: refs/heads/feature-ABC-123; defaults to the current branch"` // e.g: refs/heads/feature-ABC-123 ToRef string `arg:"-T,--to-ref,required" help:"Target reference, e.g: refs/heads/master"` // From which repo? Defaults to self @@ -58,6 +58,31 @@ func (b *BitbucketCLI) repoPrCreate(cmd *RepoCmd) { } create := cmd.PrCmd.Create + if create.FromRef == "" { + ctx, err := GetRepoContext(".") + if err != nil { + b.logger.Fatalf("--from-ref not specified and could not detect current branch: %v", err) + } + if ctx.Branch == "" { + b.logger.Fatal("--from-ref not specified and HEAD is not on a branch (detached HEAD).") + } + create.FromRef = "refs/heads/" + ctx.Branch + } + + if create.Title == "" { + subject, body, found, err := GetSingleCommitMessage(".", create.FromRef, create.ToRef) + if err != nil { + b.logger.Fatalf("--title not specified and could not read commit history: %v", err) + } + if !found { + b.logger.Fatal("--title not specified and branch does not have exactly one commit ahead of the target.") + } + create.Title = subject + if create.Description == "" { + create.Description = body + } + } + if create.FromRepoKey == "" && create.FromRepoSlug == "" { // From = To create.FromRepoKey = cmd.ProjectKey diff --git a/test/util.go b/test/util.go index 0623224..d8c5d42 100644 --- a/test/util.go +++ b/test/util.go @@ -3,8 +3,16 @@ package test import ( cli "github.com/swisscom/bitbucket-cli/internal" "os" + "testing" ) +func SkipIfNoServer(t *testing.T) { + t.Helper() + if os.Getenv("BITBUCKET_URL") == "" { + t.Skip("BITBUCKET_URL not set, skipping integration test") + } +} + func MustGetCLI() *cli.BitbucketCLI { c, err := cli.NewCLI( &cli.BasicAuth{