|
| 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