Skip to content

Commit bf115ea

Browse files
authored
Merge pull request #12 from LoganBarnett/git-repo-context
pull project and repo values from git context
2 parents 9e9ec7a + 9b70690 commit bf115ea

8 files changed

Lines changed: 495 additions & 26 deletions

File tree

‎README.md‎

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -63,52 +63,63 @@ drwxr-xr-x 3 dvitali dvitali 100 Jul 21 18:09 project-3
6363

6464
## Repo
6565

66-
This main subcommand requires two arguments:
66+
Most subcommands need to know which repository to operate on. You can supply this explicitly:
6767

68-
- `-k KEY`
69-
- `-n NAME`
68+
- `-k KEY` — project key (e.g. `TOOL`)
69+
- `-n NAME` — repository slug (e.g. `my-repo`)
7070

71-
These are basically the identifiers for your repository, not including one of the twos in all of the
72-
subcommands will result in an error.
71+
Or, if you run the command from inside a cloned Bitbucket repository, both are detected automatically
72+
from the `origin` remote URL and can be omitted.
7373

7474
### PR
7575

7676
This subcommand deals with PRs, please check its subcommands.
7777

7878
#### Create
7979

80-
This command, subcommand of (`repo pr`) allows you to create a Pull Request.
80+
Creates a Pull Request. When run from inside a cloned Bitbucket repository, the project key, slug,
81+
and source branch are all detected automatically. If the source branch has exactly one commit ahead
82+
of the target, the PR title and description are pre-populated from that commit's subject and body.
8183

82-
Use it as follows:
84+
Minimal invocation (from inside the repo, on a single-commit feature branch):
8385

8486
```
85-
bitbucket-cli repo -k "KEY" \
86-
-n "bitbucket-playground" \
87-
pr create \
88-
-t "Some Title" \
89-
-d "Some Description :thumbsup:" \
90-
-F "refs/heads/feature/2" -T "refs/heads/master"
87+
$ bitbucket-cli repo pr create -T "refs/heads/master"
9188
```
9289

90+
Explicit invocation:
9391

94-
##### Usage
92+
```
93+
$ bitbucket-cli repo -k "KEY" \
94+
-n "bitbucket-playground" \
95+
pr create \
96+
-t "Some Title" \
97+
-d "Some Description :thumbsup:" \
98+
-F "refs/heads/feature/2" -T "refs/heads/master"
99+
```
100+
101+
##### Usage
95102

96103
```
97-
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]
104+
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]
98105
99106
Options:
100107
--title TITLE, -t TITLE
101-
Title of this PR
108+
Title of this PR; defaults to the commit subject when the branch has
109+
exactly one commit ahead of the target
102110
--description DESCRIPTION, -d DESCRIPTION
103-
Description of the PR
111+
Description of the PR; defaults to the commit body when the branch has
112+
exactly one commit ahead of the target
104113
--from-ref FROM-REF, -F FROM-REF
105-
Reference of the incoming PR, e.g: refs/heads/feature-ABC-123
114+
Source branch, e.g: refs/heads/feature-ABC-123; defaults to the current branch
106115
--to-ref TO-REF, -T TO-REF
107-
Target reference, e.g: refs/heads/master
116+
Target branch, e.g: refs/heads/master
108117
--from-key FROM-KEY, -K FROM-KEY
109-
Project Key of the "from" repository
118+
Project key of the source repository (if different from target)
110119
--from-slug FROM-SLUG, -S FROM-SLUG
111-
Repository slug of the "from" repository
120+
Repository slug of the source repository (if different from target)
121+
--reviewers REVIEWERS, -r REVIEWERS
122+
Comma-separated list of reviewers
112123
--help, -h display this help and exit
113124
```
114125

‎internal/git_context.go‎

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"strings"
7+
8+
"github.com/go-git/go-git/v5"
9+
"github.com/go-git/go-git/v5/plumbing"
10+
"github.com/go-git/go-git/v5/plumbing/object"
11+
"github.com/go-git/go-git/v5/plumbing/storer"
12+
)
13+
14+
// RepoContext holds repository information inferred from the local git repo.
15+
type RepoContext struct {
16+
ProjectKey string
17+
Slug string
18+
Branch string // short branch name, e.g. "feature/my-thing"
19+
BaseURL string // Bitbucket REST base URL, e.g. "https://host/rest" (HTTPS remotes only)
20+
}
21+
22+
// GetRepoContext opens the git repo at dir (or the nearest parent with a .git),
23+
// reads the current branch and the "origin" remote URL, and parses them into
24+
// a RepoContext.
25+
func GetRepoContext(dir string) (*RepoContext, error) {
26+
repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{DetectDotGit: true})
27+
if err != nil {
28+
return nil, fmt.Errorf("not inside a git repository: %v", err)
29+
}
30+
31+
head, err := repo.Head()
32+
if err != nil {
33+
return nil, fmt.Errorf("could not read HEAD: %v", err)
34+
}
35+
36+
branch := ""
37+
if head.Name().IsBranch() {
38+
branch = head.Name().Short()
39+
}
40+
41+
remote, err := repo.Remote("origin")
42+
if err != nil {
43+
return nil, fmt.Errorf("no remote named \"origin\": %v", err)
44+
}
45+
46+
urls := remote.Config().URLs
47+
if len(urls) == 0 {
48+
return nil, fmt.Errorf("remote \"origin\" has no URLs")
49+
}
50+
51+
projectKey, slug, baseURL, err := ParseBitbucketRemote(urls[0])
52+
if err != nil {
53+
return nil, fmt.Errorf("could not parse Bitbucket project/repo from remote URL %q: %v", urls[0], err)
54+
}
55+
56+
return &RepoContext{
57+
ProjectKey: projectKey,
58+
Slug: slug,
59+
Branch: branch,
60+
BaseURL: baseURL,
61+
}, nil
62+
}
63+
64+
// ParseBitbucketRemote parses a Bitbucket Server remote URL and returns the
65+
// project key, repository slug, and (for HTTPS remotes) the REST API base URL.
66+
//
67+
// Supported formats:
68+
//
69+
// HTTPS (root deployment): https://host/scm/PROJECT/repo.git
70+
// HTTPS (context path): https://host/context/scm/PROJECT/repo.git
71+
// SSH URL with port: ssh://git@host:7999/PROJECT/repo.git
72+
// SSH URL without port: ssh://git@host/PROJECT/repo.git
73+
// git+ssh URL: git+ssh://git@host:7999/PROJECT/repo.git
74+
// SCP-style: git@host:PROJECT/repo.git
75+
func ParseBitbucketRemote(remoteURL string) (projectKey, slug, baseURL string, err error) {
76+
switch {
77+
case strings.HasPrefix(remoteURL, "https://"), strings.HasPrefix(remoteURL, "http://"):
78+
return parseHTTPSRemote(remoteURL)
79+
case strings.HasPrefix(remoteURL, "ssh://"), strings.HasPrefix(remoteURL, "git+ssh://"):
80+
return parseSSHURLRemote(remoteURL)
81+
default:
82+
// Fall through to SCP-style: [user@]host:path
83+
return parseSCPRemote(remoteURL)
84+
}
85+
}
86+
87+
// parseHTTPSRemote handles http(s):// URLs.
88+
// Bitbucket Server HTTPS clone URLs always contain "/scm/" as the separator
89+
// between an optional context path and the project/repo path.
90+
func parseHTTPSRemote(remoteURL string) (projectKey, slug, baseURL string, err error) {
91+
u, err := url.Parse(remoteURL)
92+
if err != nil {
93+
return "", "", "", fmt.Errorf("invalid URL: %v", err)
94+
}
95+
96+
// Split on "/scm/" to separate the optional context path from the project/repo.
97+
const scmSegment = "/scm/"
98+
idx := strings.Index(u.Path, scmSegment)
99+
if idx < 0 {
100+
return "", "", "", fmt.Errorf("URL does not contain /scm/ — is this a Bitbucket Server clone URL?")
101+
}
102+
103+
contextPath := u.Path[:idx] // e.g. "" or "/bitbucket"
104+
repoPath := u.Path[idx+len(scmSegment):] // e.g. "PROJECT/repo.git"
105+
106+
projectKey, slug, err = splitProjectSlug(repoPath)
107+
if err != nil {
108+
return "", "", "", err
109+
}
110+
111+
// Strip userinfo (username in clone URLs) from host for the base URL.
112+
baseURL = fmt.Sprintf("%s://%s%s/rest", u.Scheme, u.Hostname(), contextPath)
113+
if u.Port() != "" {
114+
baseURL = fmt.Sprintf("%s://%s:%s%s/rest", u.Scheme, u.Hostname(), u.Port(), contextPath)
115+
}
116+
117+
return projectKey, slug, baseURL, nil
118+
}
119+
120+
// parseSSHURLRemote handles ssh:// and git+ssh:// URLs.
121+
// Bitbucket Server SSH URLs carry the project/repo directly in the path with
122+
// no context path component.
123+
func parseSSHURLRemote(remoteURL string) (projectKey, slug, baseURL string, err error) {
124+
// Normalise git+ssh:// → ssh:// so url.Parse works cleanly.
125+
normalized := strings.TrimPrefix(remoteURL, "git+")
126+
127+
u, err := url.Parse(normalized)
128+
if err != nil {
129+
return "", "", "", fmt.Errorf("invalid SSH URL: %v", err)
130+
}
131+
132+
// Path is "/PROJECT/repo[.git]"; strip the leading slash.
133+
projectKey, slug, err = splitProjectSlug(strings.TrimPrefix(u.Path, "/"))
134+
if err != nil {
135+
return "", "", "", err
136+
}
137+
138+
return projectKey, slug, "", nil // base URL not determinable from SSH
139+
}
140+
141+
// parseSCPRemote handles SCP-style URLs: [user@]host:PROJECT/repo[.git]
142+
func parseSCPRemote(remoteURL string) (projectKey, slug, baseURL string, err error) {
143+
colonIdx := strings.Index(remoteURL, ":")
144+
if colonIdx < 0 {
145+
return "", "", "", fmt.Errorf("not a recognised git remote URL: %q", remoteURL)
146+
}
147+
148+
path := remoteURL[colonIdx+1:]
149+
projectKey, slug, err = splitProjectSlug(path)
150+
if err != nil {
151+
return "", "", "", err
152+
}
153+
154+
return projectKey, slug, "", nil // base URL not determinable from SCP
155+
}
156+
157+
// splitProjectSlug splits a "PROJECT/repo[.git]" string into its two parts.
158+
func splitProjectSlug(path string) (projectKey, slug string, err error) {
159+
path = strings.TrimSuffix(path, ".git")
160+
parts := strings.SplitN(path, "/", 2)
161+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
162+
return "", "", fmt.Errorf("expected PROJECT/repo in %q", path)
163+
}
164+
return parts[0], parts[1], nil
165+
}
166+
167+
// GetSingleCommitMessage looks at the commits in fromRef that are not
168+
// reachable from toRef. If there is exactly one such commit it returns its
169+
// subject line and body so callers can pre-populate PR title/description.
170+
// Returns found=false (without error) when the count is not exactly one.
171+
func GetSingleCommitMessage(repoPath, fromRef, toRef string) (subject, body string, found bool, err error) {
172+
repo, err := git.PlainOpenWithOptions(repoPath, &git.PlainOpenOptions{DetectDotGit: true})
173+
if err != nil {
174+
return "", "", false, fmt.Errorf("not inside a git repository: %v", err)
175+
}
176+
177+
fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef))
178+
if err != nil {
179+
return "", "", false, fmt.Errorf("could not resolve %q: %v", fromRef, err)
180+
}
181+
182+
toHash, err := repo.ResolveRevision(plumbing.Revision(toRef))
183+
if err != nil {
184+
return "", "", false, fmt.Errorf("could not resolve %q: %v", toRef, err)
185+
}
186+
187+
// Build the set of commits reachable from toRef so we can stop the walk.
188+
toAncestors := make(map[plumbing.Hash]bool)
189+
toIter, err := repo.Log(&git.LogOptions{From: *toHash})
190+
if err != nil {
191+
return "", "", false, err
192+
}
193+
_ = toIter.ForEach(func(c *object.Commit) error {
194+
toAncestors[c.Hash] = true
195+
return nil
196+
})
197+
198+
// Walk from fromRef, collecting commits not in toAncestors.
199+
// Stop as soon as we have seen more than one (no need to count further).
200+
var unique []*object.Commit
201+
fromIter, err := repo.Log(&git.LogOptions{From: *fromHash})
202+
if err != nil {
203+
return "", "", false, err
204+
}
205+
_ = fromIter.ForEach(func(c *object.Commit) error {
206+
if toAncestors[c.Hash] {
207+
return storer.ErrStop
208+
}
209+
unique = append(unique, c)
210+
if len(unique) > 1 {
211+
return storer.ErrStop
212+
}
213+
return nil
214+
})
215+
216+
if len(unique) != 1 {
217+
return "", "", false, nil
218+
}
219+
220+
subject, body = splitCommitMessage(unique[0].Message)
221+
return subject, body, true, nil
222+
}
223+
224+
// splitCommitMessage splits a raw commit message into subject and body,
225+
// following the conventional blank-line separator.
226+
func splitCommitMessage(msg string) (subject, body string) {
227+
msg = strings.TrimRight(msg, "\n")
228+
parts := strings.SplitN(msg, "\n", 2)
229+
subject = strings.TrimSpace(parts[0])
230+
if len(parts) > 1 {
231+
body = strings.TrimSpace(parts[1])
232+
}
233+
return subject, body
234+
}

0 commit comments

Comments
 (0)