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{