123b2f6eed
* feat(backend): accept github_url in task creation and clone on launch Add github_url field to task creation DTO and propagate through HTTP/WS handlers and service layer. When a task is created with a GitHub URL instead of a local repository, the service resolves (or creates) the repository record via FindOrCreateRepository and triggers a clone/fetch via repoclone.Cloner during executor pre-flight. Wire RepoCloner into the orchestrator executor so remote repositories are fetched before the agent process starts. On resume, RepoUpdater performs a fetch to ensure the worktree is up-to-date. * feat(backend): add GitHub branches endpoint for remote repos Add ListRepoBranches to the GitHub client interface (GHClient, PATClient, NoopClient, MockClient) and expose it via GET /api/v1/github/repos/:owner/:repo/branches. The mock controller gains POST /api/v1/github/mock/branches for seeding branch data in E2E tests. * feat(web): add GitHub URL input to task create dialog Add a toggle in the task create dialog that switches between the existing repository selector and a free-text GitHub URL input. When a valid GitHub URL is entered (owner/repo parsed), branches are fetched from the new backend endpoint and auto-selected. The dialog state, effects, validation, and submit logic are extended to support the github_url flow alongside the existing repository flow. * test(e2e): add tests for task creation from GitHub URL Add E2E tests verifying: - Full flow: toggle to GitHub URL mode, enter URL, create task, verify agent runs and completes in session view - Toggle behavior: switching between GitHub URL and repository selector modes updates UI correctly * fix(web): dynamic URL input sizing and extract RepoSourceInput Replace fixed-width GitHub URL input with size-based auto-sizing (matching InlineTaskName pattern). Extract RepoSourceInput component from DialogHeaderContent to stay within cyclomatic complexity limits. Add testId prop to Combobox and wire it to ExecutorProfileSelector for E2E test targeting. * test(e2e): add worktree executor test and fix GitHub URL repo seeding Update GitHub URL E2E tests to pre-seed a repository with GitHub provider info pointing to the local git repo, avoiding actual clone attempts during task creation. Add test for GitHub URL task creation with worktree executor type, verifying the full flow including executor profile selection. Extend ApiClient with listExecutors and provider fields on createRepository for test seeding. * fix(backend): use protocol-aware clone URL for provider-backed repos The ensureRepoCloned function was hardcoding HTTPS clone URLs via repositoryCloneURL(), but the user's git setup may require SSH. Add BuildCloneURL to the RepoCloner interface so the executor uses the Cloner's configured protocol (SSH/HTTPS) instead of always generating HTTPS URLs. Falls back to HTTPS if BuildCloneURL fails. This fixes workspace loading issues (terminal, file tree) for cloned repos when the user has SSH-only authentication configured. * test(e2e): enhance GitHub URL test with terminal and changes panel verification * feat(web): add inline validation for GitHub URL input - Show inline error for invalid URL format (not matching github.com/owner/repo) - Show inline error when repository is not found or not accessible - Mock client returns error for unknown repos (matches real GitHub API) - E2E tests for invalid URL, nonexistent repo, and error-clearing on valid input * fix(web): fix GitHub URL branch auto-select and E2E test stability - Pick branch by name (main/master) for GitHub URL mode instead of using selectPreferredBranch which returns origin-prefixed names - Add test timeout for worktree executor test - Use shorter filename in terminal test to avoid xterm line-wrap issues * fix(web): fix GitHub URL dialog polish and wrong-repo bug UI fixes: - Align toggle button text with dropdown text content (pl-3) - Match GitHub URL input height to dropdown trigger (min-h-9) - Position error text absolutely to prevent layout shift - Remove redundant toast on repo-not-found (inline error suffices) Bug fix: - Prevent repo auto-select from overriding GitHub URL mode by guarding useRepositoryAutoSelectEffect with useGitHubUrl - Reorder getRepositoriesPayload to check useGitHubUrl first, preventing stale repositoryId from winning over the GitHub URL Add E2E test verifying correct repo usage across consecutive tasks. * fix(web): fix dialog height mismatch and error text overlap - Use exact h-9 on GitHub URL input to match dropdown trigger height - Move error text from absolute-positioned overlay to inline next to the toggle button, preventing overlap while keeping layout stable * fix(web): make GitHub URL input auto-size to content Reduce minimum size from 30 to 20 and shorten placeholder so the "/ task-name" sits closer to the URL text, matching the dropdown's compact w-auto behavior. * fix(web): polish GitHub URL input sizing, floating error, and placeholder color - Match input height (h-7) and padding (px-2) to Combobox trigger button - Size input width to exactly fit placeholder text - Show validation error as floating popover below input instead of inline - Keep placeholder text grey even when input has error state - Extract useGitHubUrlState hook to fix max-lines-per-function lint * fix(backend): return proper HTTP status codes from GitHub branches endpoint Add GitHubAPIError type to distinguish upstream GitHub API status codes. The branches endpoint now returns 404 (not 500) for non-existent repos, which the logging middleware correctly logs as DEBUG instead of ERROR. * feat(web): show error toast when a task session fails to start Add session failure notification plumbing: WS handler detects FAILED state with error_message, pushes to Zustand store, and a global bridge component shows an error toast via the ToastProvider. Also adds data-testid attributes to toast elements and types error_message on the session state changed payload. * style: fix prettier formatting in task-create-dialog and related files * fix(web): extract ToastList component to fix max-lines-per-function lint * fix: address code review feedback for GitHub URL task creation - Preserve 403 status instead of collapsing to 401 in branches endpoint - Use clone-generic error instead of remote-docker-specific in ensureRepoCloned - Trim whitespace in GitHub URL validation (backend + frontend) - Add GitHubURL field to wsUpdateTask path - Fix DefaultBranch mapping to prefer repoInput.DefaultBranch over BaseBranch - Clear githubBranchesLoading on early-return paths to prevent stuck state - Gate GitHub branch loading by mode to avoid false loading states - Encode owner/repo in API path for special characters - Add aria-label, aria-invalid, aria-errormessage for accessibility * fix(e2e): improve diff-update test stability with fresh locator and longer timeout * fix(backend): add --paginate flag to gh api branches call for repos with >100 branches * fix: address second round of code review feedback - Add pagination support to PATClient.ListRepoBranches for repos with >100 branches - Validate owner/repo in mock controller addBranches endpoint - Use aria-describedby instead of aria-errormessage for broader AT support - Add aria-live region to toast container for screen reader announcements - Assert absence of FIRST_MODIFICATION in diff-update E2E test * fix(backend): downgrade HTTP request logger from error to warn for 4xx/5xx responses * fix: address third round of code review feedback - Validate repositoryID/localPath in UpdateRepositoryLocalPath - Copy slices in mock client to prevent external mutation - Extract GitHub header constants and shared setter in PATClient - Redact clone URL from logs to prevent credential leaks - Guard against persisting empty localPath after clone - Show fallback toast message when FAILED state has no error_message
554 lines
17 KiB
Go
554 lines
17 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// GHClient implements Client using the gh CLI.
|
|
type GHClient struct{}
|
|
|
|
// NewGHClient creates a new gh CLI-based client.
|
|
func NewGHClient() *GHClient {
|
|
return &GHClient{}
|
|
}
|
|
|
|
// GHAvailable checks if the gh CLI is installed and accessible.
|
|
func GHAvailable() bool {
|
|
_, err := exec.LookPath("gh")
|
|
return err == nil
|
|
}
|
|
|
|
func (c *GHClient) IsAuthenticated(ctx context.Context) (bool, error) {
|
|
// Treat any non-zero exit as "not authenticated". This avoids parsing
|
|
// locale-dependent error messages and handles multi-account scenarios
|
|
// where a secondary account has an invalid token. GHAvailable() already
|
|
// guards binary existence before this method is called.
|
|
_, err := c.run(ctx, "auth", "status", "--hostname", "github.com")
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
// Propagate context errors so callers can distinguish cancellation/timeout
|
|
// from a genuine "not authenticated" result.
|
|
if ctx.Err() != nil {
|
|
return false, ctx.Err()
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// RunAuthDiagnostics executes gh auth status and captures the raw output for troubleshooting.
|
|
func (c *GHClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics {
|
|
cmd := exec.CommandContext(ctx, "gh", "auth", "status", "--hostname", "github.com")
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
exitCode := 0
|
|
if err := cmd.Run(); err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
exitCode = -1
|
|
}
|
|
}
|
|
output := stderr.String()
|
|
if output == "" {
|
|
output = stdout.String()
|
|
}
|
|
return &AuthDiagnostics{
|
|
Command: "gh auth status --hostname github.com",
|
|
Output: output,
|
|
ExitCode: exitCode,
|
|
}
|
|
}
|
|
|
|
func (c *GHClient) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
out, err := c.run(ctx, "api", "user", "-q", ".login")
|
|
if err != nil {
|
|
return "", fmt.Errorf("get authenticated user: %w", err)
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// ghRequestedReviewer is a user/team requested reviewer returned by gh pr view.
|
|
type ghRequestedReviewer struct {
|
|
TypeName string `json:"__typename"`
|
|
Login string `json:"login"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// ghPR is the JSON shape returned by gh pr list/view.
|
|
type ghPR struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
State string `json:"state"`
|
|
Body string `json:"body"`
|
|
HeadRefName string `json:"headRefName"`
|
|
HeadRefOid string `json:"headRefOid"`
|
|
BaseRefName string `json:"baseRefName"`
|
|
IsDraft bool `json:"isDraft"`
|
|
Mergeable string `json:"mergeable"`
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
MergedAt string `json:"mergedAt"`
|
|
ClosedAt string `json:"closedAt"`
|
|
ReviewRequests []ghRequestedReviewer `json:"reviewRequests"`
|
|
Author struct {
|
|
Login string `json:"login"`
|
|
} `json:"author"`
|
|
}
|
|
|
|
func (c *GHClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) {
|
|
out, err := c.run(ctx, "pr", "view", fmt.Sprintf("%d", number),
|
|
"--repo", fmt.Sprintf("%s/%s", owner, repo),
|
|
"--json", "number,title,url,state,body,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,closedAt,reviewRequests")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get PR #%d: %w", number, err)
|
|
}
|
|
var raw ghPR
|
|
if err := json.Unmarshal([]byte(out), &raw); err != nil {
|
|
return nil, fmt.Errorf("parse PR response: %w", err)
|
|
}
|
|
return convertGHPR(&raw, owner, repo), nil
|
|
}
|
|
|
|
func (c *GHClient) FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) {
|
|
out, err := c.run(ctx, "pr", "list",
|
|
"--repo", fmt.Sprintf("%s/%s", owner, repo),
|
|
"--head", branch,
|
|
"--state", "open",
|
|
"--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt",
|
|
"--limit", "1")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find PR by branch %q: %w", branch, err)
|
|
}
|
|
var prs []ghPR
|
|
if err := json.Unmarshal([]byte(out), &prs); err != nil {
|
|
return nil, fmt.Errorf("parse PR list: %w", err)
|
|
}
|
|
if len(prs) == 0 {
|
|
return nil, nil
|
|
}
|
|
return convertGHPR(&prs[0], owner, repo), nil
|
|
}
|
|
|
|
func (c *GHClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) {
|
|
out, err := c.run(ctx, "pr", "list",
|
|
"--repo", fmt.Sprintf("%s/%s", owner, repo),
|
|
"--author", "@me",
|
|
"--state", "open",
|
|
"--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,additions,deletions,createdAt,updatedAt")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list authored PRs: %w", err)
|
|
}
|
|
return c.parsePRList(out, owner, repo)
|
|
}
|
|
|
|
func (c *GHClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, customQuery string) ([]*PR, error) {
|
|
query := buildReviewSearchQuery(scope, filter, customQuery)
|
|
out, err := c.run(ctx, "api", "search/issues",
|
|
"-X", "GET",
|
|
"-f", "q="+query,
|
|
"-f", "per_page=50",
|
|
"--jq", ".items")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list review-requested PRs: %w", err)
|
|
}
|
|
return c.parseSearchResults(out)
|
|
}
|
|
|
|
func (c *GHClient) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) {
|
|
out, err := c.run(ctx, "api", "user/orgs", "--paginate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list user orgs: %w", err)
|
|
}
|
|
var raw []struct {
|
|
Login string `json:"login"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &raw); err != nil {
|
|
return nil, fmt.Errorf("parse orgs: %w", err)
|
|
}
|
|
orgs := make([]GitHubOrg, len(raw))
|
|
for i, r := range raw {
|
|
orgs[i] = GitHubOrg{Login: r.Login, AvatarURL: r.AvatarURL}
|
|
}
|
|
return orgs, nil
|
|
}
|
|
|
|
func (c *GHClient) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) {
|
|
q := "org:" + org
|
|
if query != "" {
|
|
q += " " + query
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
out, err := c.run(ctx, "api", "search/repositories",
|
|
"-X", "GET",
|
|
"-f", "q="+q,
|
|
"-f", fmt.Sprintf("per_page=%d", limit),
|
|
"--jq", ".items")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search org repos: %w", err)
|
|
}
|
|
return parseGHSearchRepos(out)
|
|
}
|
|
|
|
func parseGHSearchRepos(data string) ([]GitHubRepo, error) {
|
|
var items []struct {
|
|
FullName string `json:"full_name"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
Name string `json:"name"`
|
|
Private bool `json:"private"`
|
|
}
|
|
if err := json.Unmarshal([]byte(data), &items); err != nil {
|
|
return nil, fmt.Errorf("parse search repos: %w", err)
|
|
}
|
|
repos := make([]GitHubRepo, len(items))
|
|
for i, item := range items {
|
|
repos[i] = GitHubRepo{
|
|
FullName: item.FullName,
|
|
Owner: item.Owner.Login,
|
|
Name: item.Name,
|
|
Private: item.Private,
|
|
}
|
|
}
|
|
return repos, nil
|
|
}
|
|
|
|
// ghReview is the JSON shape for reviews from gh pr view.
|
|
type ghReview struct {
|
|
ID int64 `json:"id"`
|
|
Author struct {
|
|
Login string `json:"login"`
|
|
} `json:"author"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
} `json:"user"`
|
|
State string `json:"state"`
|
|
Body string `json:"body"`
|
|
SubmittedAt time.Time `json:"submitted_at"`
|
|
}
|
|
|
|
func (c *GHClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) {
|
|
out, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/pulls/%d/reviews", owner, repo, number),
|
|
"--paginate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list PR reviews: %w", err)
|
|
}
|
|
var raw []ghReview
|
|
if err := json.Unmarshal([]byte(out), &raw); err != nil {
|
|
return nil, fmt.Errorf("parse reviews: %w", err)
|
|
}
|
|
reviews := make([]PRReview, len(raw))
|
|
for i, r := range raw {
|
|
author := r.Author.Login
|
|
avatar := ""
|
|
if r.User.Login != "" {
|
|
author = r.User.Login
|
|
avatar = r.User.AvatarURL
|
|
}
|
|
reviews[i] = PRReview{
|
|
ID: r.ID,
|
|
Author: author,
|
|
AuthorAvatar: avatar,
|
|
State: r.State,
|
|
Body: r.Body,
|
|
CreatedAt: r.SubmittedAt,
|
|
}
|
|
}
|
|
return reviews, nil
|
|
}
|
|
|
|
// ghComment is the JSON shape for review comments from the GitHub API.
|
|
type ghComment struct {
|
|
ID int64 `json:"id"`
|
|
Path string `json:"path"`
|
|
Line int `json:"line"`
|
|
Side string `json:"side"`
|
|
Body string `json:"body"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
InReplyTo *int64 `json:"in_reply_to_id"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
Type string `json:"type"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
func (c *GHClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) {
|
|
reviewEndpoint := appendSinceQuery(fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number), since)
|
|
reviewOut, err := c.run(ctx, "api", reviewEndpoint, "--paginate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list PR comments: %w", err)
|
|
}
|
|
var reviewRaw []ghComment
|
|
if err := json.Unmarshal([]byte(reviewOut), &reviewRaw); err != nil {
|
|
return nil, fmt.Errorf("parse comments: %w", err)
|
|
}
|
|
issueEndpoint := appendSinceQuery(fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number), since)
|
|
issueOut, err := c.run(ctx, "api", issueEndpoint, "--paginate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list issue comments: %w", err)
|
|
}
|
|
var issueRaw []ghIssueComment
|
|
if err := json.Unmarshal([]byte(issueOut), &issueRaw); err != nil {
|
|
return nil, fmt.Errorf("parse issue comments: %w", err)
|
|
}
|
|
return mergeAndSortPRComments(convertRawComments(reviewRaw), convertRawIssueComments(issueRaw)), nil
|
|
}
|
|
|
|
// ghCheckRun is the JSON shape from the check-runs API.
|
|
type ghCheckRun struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Conclusion *string `json:"conclusion"`
|
|
HTMLURL string `json:"html_url"`
|
|
StartedAt string `json:"started_at"`
|
|
CompletedAt string `json:"completed_at"`
|
|
Output struct {
|
|
Title *string `json:"title"`
|
|
Summary *string `json:"summary"`
|
|
} `json:"output"`
|
|
}
|
|
|
|
func (c *GHClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) {
|
|
checkRunsOut, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/commits/%s/check-runs", owner, repo, ref),
|
|
"--jq", ".check_runs")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list check runs: %w", err)
|
|
}
|
|
var checkRunsRaw []ghCheckRun
|
|
if err := json.Unmarshal([]byte(checkRunsOut), &checkRunsRaw); err != nil {
|
|
return nil, fmt.Errorf("parse check runs: %w", err)
|
|
}
|
|
statusOut, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/commits/%s/status", owner, repo, ref),
|
|
"--jq", ".statuses")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list status contexts: %w", err)
|
|
}
|
|
var statusRaw []ghStatusContext
|
|
if err := json.Unmarshal([]byte(statusOut), &statusRaw); err != nil {
|
|
return nil, fmt.Errorf("parse status contexts: %w", err)
|
|
}
|
|
return mergeChecks(convertRawCheckRuns(checkRunsRaw), convertRawStatusContexts(statusRaw)), nil
|
|
}
|
|
|
|
func (c *GHClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) {
|
|
return getPRFeedback(ctx, c, owner, repo, number)
|
|
}
|
|
|
|
func (c *GHClient) ListPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) {
|
|
out, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/pulls/%d/files", owner, repo, number),
|
|
"--paginate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list PR files: %w", err)
|
|
}
|
|
return parsePRFilesJSON(out)
|
|
}
|
|
|
|
func (c *GHClient) ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) {
|
|
out, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/pulls/%d/commits", owner, repo, number),
|
|
"--paginate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list PR commits: %w", err)
|
|
}
|
|
return parsePRCommitsJSON(out)
|
|
}
|
|
|
|
func (c *GHClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error {
|
|
args := []string{"api",
|
|
fmt.Sprintf("repos/%s/%s/pulls/%d/reviews", owner, repo, number),
|
|
"-X", "POST",
|
|
"-f", "event=" + event,
|
|
}
|
|
if body != "" {
|
|
args = append(args, "-f", "body="+body)
|
|
}
|
|
_, err := c.run(ctx, args...)
|
|
if err != nil {
|
|
return fmt.Errorf("submit review on PR #%d: %w", number, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *GHClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) {
|
|
out, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/branches", owner, repo),
|
|
"-X", "GET",
|
|
"-f", "per_page=100",
|
|
"--paginate",
|
|
"--jq", ".[].name")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list repo branches: %w", err)
|
|
}
|
|
var branches []RepoBranch
|
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
|
name := strings.TrimSpace(line)
|
|
if name != "" {
|
|
branches = append(branches, RepoBranch{Name: name})
|
|
}
|
|
}
|
|
return branches, nil
|
|
}
|
|
|
|
// run executes a gh CLI command and returns its stdout output.
|
|
// Stderr is captured separately to avoid contaminating JSON output.
|
|
func (c *GHClient) run(ctx context.Context, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String())
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
func (c *GHClient) parsePRList(data string, owner, repo string) ([]*PR, error) {
|
|
var raw []ghPR
|
|
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
|
return nil, fmt.Errorf("parse PR list: %w", err)
|
|
}
|
|
prs := make([]*PR, len(raw))
|
|
for i := range raw {
|
|
prs[i] = convertGHPR(&raw[i], owner, repo)
|
|
}
|
|
return prs, nil
|
|
}
|
|
|
|
// ghSearchItem is a PR item from the GitHub search API.
|
|
type ghSearchItem struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
HTMLURL string `json:"html_url"`
|
|
State string `json:"state"`
|
|
Draft bool `json:"draft"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
PullRequest struct {
|
|
URL string `json:"url"`
|
|
} `json:"pull_request"`
|
|
RepositoryURL string `json:"repository_url"`
|
|
}
|
|
|
|
func (c *GHClient) parseSearchResults(data string) ([]*PR, error) {
|
|
var items []ghSearchItem
|
|
if err := json.Unmarshal([]byte(data), &items); err != nil {
|
|
return nil, fmt.Errorf("parse search results: %w", err)
|
|
}
|
|
prs := make([]*PR, len(items))
|
|
for i, item := range items {
|
|
prs[i] = convertSearchItemToPR(
|
|
item.Number, item.Title, item.HTMLURL, item.State,
|
|
item.User.Login, item.RepositoryURL, item.Draft,
|
|
item.CreatedAt, item.UpdatedAt,
|
|
)
|
|
}
|
|
return prs, nil
|
|
}
|
|
|
|
func convertGHPR(raw *ghPR, owner, repo string) *PR {
|
|
state := strings.ToLower(raw.State)
|
|
if raw.MergedAt != "" {
|
|
state = prStateMerged
|
|
}
|
|
pr := &PR{
|
|
Number: raw.Number,
|
|
Title: raw.Title,
|
|
URL: raw.URL,
|
|
HTMLURL: raw.URL,
|
|
State: state,
|
|
Body: raw.Body,
|
|
HeadBranch: raw.HeadRefName,
|
|
HeadSHA: raw.HeadRefOid,
|
|
BaseBranch: raw.BaseRefName,
|
|
AuthorLogin: raw.Author.Login,
|
|
RepoOwner: owner,
|
|
RepoName: repo,
|
|
Draft: raw.IsDraft,
|
|
Mergeable: raw.Mergeable == "MERGEABLE",
|
|
Additions: raw.Additions,
|
|
Deletions: raw.Deletions,
|
|
RequestedReviewers: convertGHRequestedReviewers(raw.ReviewRequests),
|
|
CreatedAt: raw.CreatedAt,
|
|
UpdatedAt: raw.UpdatedAt,
|
|
MergedAt: parseTimePtr(raw.MergedAt),
|
|
ClosedAt: parseTimePtr(raw.ClosedAt),
|
|
}
|
|
return pr
|
|
}
|
|
|
|
func convertGHRequestedReviewers(raw []ghRequestedReviewer) []RequestedReviewer {
|
|
reviewers := make([]RequestedReviewer, 0, len(raw))
|
|
for _, req := range raw {
|
|
switch req.TypeName {
|
|
case "Team":
|
|
login := req.Slug
|
|
if login == "" {
|
|
login = req.Name
|
|
}
|
|
if login == "" {
|
|
continue
|
|
}
|
|
reviewers = append(reviewers, RequestedReviewer{Login: login, Type: reviewerTypeTeam})
|
|
default:
|
|
if req.Login == "" {
|
|
continue
|
|
}
|
|
reviewers = append(reviewers, RequestedReviewer{Login: req.Login, Type: reviewerTypeUser})
|
|
}
|
|
}
|
|
return reviewers
|
|
}
|
|
|
|
func parseTimePtr(s string) *time.Time {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|
|
|
|
// parseRepoURL extracts owner and repo from a GitHub API URL like
|
|
// "https://api.github.com/repos/owner/repo".
|
|
func parseRepoURL(url string) (string, string) {
|
|
parts := strings.Split(url, "/")
|
|
if len(parts) < 2 {
|
|
return "", ""
|
|
}
|
|
return parts[len(parts)-2], parts[len(parts)-1]
|
|
}
|
|
|
|
func appendSinceQuery(endpoint string, since *time.Time) string {
|
|
if since == nil {
|
|
return endpoint
|
|
}
|
|
return endpoint + "?since=" + url.QueryEscape(since.Format(time.RFC3339))
|
|
}
|