// Package forge is the provider-abstracted boundary to a git hosting service // (AGENT.md §8.4). Gitea/Forgejo is the first provider; GitHub/GitLab can drop in // behind the same interface. It is read + write: the write path powers // "Merge & clean up" (merge a PR + delete its branch), which callers must confirm // per §1.4. With no provider configured the features degrade gracefully. package forge import ( "context" "errors" "net/url" "strings" ) // Sentinel errors so callers (and the UI) can degrade gracefully. var ( ErrNotConfigured = errors.New("forge integration not configured") ErrNotSupported = errors.New("repository is not on a supported forge host") ) // PullRequest is the provider-neutral view of an open PR/MR. type PullRequest struct { Number int64 `json:"number"` Title string `json:"title"` Author string `json:"author"` Head string `json:"head"` // head branch Base string `json:"base"` // base branch Draft bool `json:"draft"` Mergeable bool `json:"mergeable"` URL string `json:"url"` SameRepo bool `json:"sameRepo"` // head & base in the same repo (branch is deletable) } // MergeMethod selects how a PR is merged. type MergeMethod string const ( MergeSquash MergeMethod = "squash" MergeMerge MergeMethod = "merge" MergeRebase MergeMethod = "rebase" ) // MergeResult reports the outcome of a merge-and-cleanup. type MergeResult struct { Number int64 `json:"number"` Merged bool `json:"merged"` Branch string `json:"branch"` BranchDeleted bool `json:"branchDeleted"` } // Provider talks to one hosting provider. type Provider interface { // Handles reports whether this provider serves the given remote host. Handles(host string) bool ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error) MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error) } // ParseRemote extracts (host, owner, repo) from a git remote URL, handling both // https ("https://host/owner/repo.git") and scp-like ssh ("git@host:owner/repo.git"). func ParseRemote(remote string) (host, owner, repo string, ok bool) { remote = strings.TrimSpace(remote) if remote == "" { return "", "", "", false } // scp-like ssh form has no scheme: user@host:path if !strings.Contains(remote, "://") && strings.Contains(remote, "@") && strings.Contains(remote, ":") { rest := remote[strings.Index(remote, "@")+1:] colon := strings.Index(rest, ":") host = rest[:colon] owner, repo, ok = splitOwnerRepo(rest[colon+1:]) return host, owner, repo, ok } u, err := url.Parse(remote) if err != nil || u.Hostname() == "" { return "", "", "", false } owner, repo, ok = splitOwnerRepo(u.Path) return u.Hostname(), owner, repo, ok } // splitOwnerRepo turns "/owner/repo.git" (or subgroups) into (owner, repo). It // takes the last two path segments, which covers the common single-owner case. func splitOwnerRepo(path string) (owner, repo string, ok bool) { path = strings.Trim(path, "/") path = strings.TrimSuffix(path, ".git") parts := strings.Split(path, "/") if len(parts) < 2 { return "", "", false } owner = parts[len(parts)-2] repo = parts[len(parts)-1] if owner == "" || repo == "" { return "", "", false } return owner, repo, true }