0f1081d423
* feat(share): add public share links via GitHub Gists Adds a Share button to the chat panel header that publishes the current session as a secret GitHub Gist on the user's account. The share link routes through gistpreview.github.io to render share.html as a styled kandev-themed page; snapshot.json + README.md ride along for machine-readable + fallback views. Snapshots run a redaction pass (absolute paths, common credential shapes, .env contents, shell env vars) and strip <kandev-system> prompt blocks via the shared sysprompt helper. Message filtering whitelists user prose + tool_* types so internal noise never leaks into shares. Spec at docs/specs/public-share-links/spec.md. * fix(share): coerce pendingClarification to bool for ClarificationSection A late refactor extracted ClarificationSection with `pendingClarification: boolean` on its props, but the call site passes the actual Message|null value. CI's tsc build catches the mismatch; runtime was unaffected because the section only uses the value in a truthiness check. Coerce with Boolean() at the call site to keep the prop's intent narrow. * fix(share): address PR #995 review feedback Bugs (flagged by Greptile, Cubic, Claude): - abs-path redaction split on `root + "/"` so sibling dirs like /home/user/proj2 don't get corrupted when workspaceRoot is /home/user/proj; regression test added. - GHClient.DeleteGist now wraps "HTTP 404" stderr as *GitHubAPIError so share.IsAlreadyGone matches consistently with the PAT client; this restores the "gist already revoked upstream" soft-success path for CLI-authenticated users. - SnapshotSizeBytes is now measured from the same MarshalIndent payload the gist backend uploads, so the size shown in the UI matches the actual gist bytes (was ~20-40% underreported). Polish (flagged by CodeRabbit, Cubic): - Tool name + tool output now HTML-escaped; tool call args and output render via <pre><code> instead of triple-backtick fences so payloads containing ``` can't break out of the code block. - share-list.tsx: clear `busy` via finally and break error layout out of the justify-between row. - use-shares.ts: convert UseSharesResult to interface (repo TS rules); reset error+isLoading when ids are missing. - use-shares.test.tsx: wait on the actual async outcome (shares populated, listShares called) instead of isLoading flipping false. - provider.go: replace `repo.Close` cleanup with an explicit noop closure so the comment ("Cleanup is a no-op") and the impl match. - spec.md: aligned the shareability rule across What/Failure/Scenarios/ Out-of-scope — any non-prehistory state can be shared; added language tags to fenced code blocks for markdownlint. Skipped intentionally (reply on each thread): - HTML-escape every metadata badge: the values are controlled enum strings from kandev's own schema, not user input. Defense-in-depth noise. Tool name (which can come from agent runtime) IS escaped. - useShares request-token guard against stale responses: the dialog owns the hook lifecycle and opens against a single (taskId, sessionId) — the race the comment describes can't occur here. * fix(share): redact paths for every worktree, not just the first A single TaskSession can carry multiple worktrees when the task spans multiple repos. The redactor previously only saw the first worktree path, so absolute paths from secondary worktrees leaked into public shares instead of being rewritten to repo-relative form. NewRedactor is now variadic and applies the abs-path rule against every root it receives, sorted longest-first so a root that is a prefix of another (e.g. /workspace vs /workspace/repo1) gets matched by the more specific one first. workspaceRootFor in builder.go becomes workspaceRootsFor and emits every WorktreePath plus the WorkspacePath fallback. Flagged by Cubic on PR #995. * fix(share): address PR #995 review round 3 Bugs (flagged by Cubic + Claude on gh_client.go:714): - DeleteGist now calls the existing isNotFoundErr helper instead of an inline strings.Contains(err, "HTTP 404"). The helper also covers "404 Not Found" and "status: 404", which some gh versions emit, so the share service's "gist already revoked upstream" soft-success path now fires consistently across CLI auth users. Bugs (flagged by Claude on builder.go:220): - toolCallBlock used to read metadata["tool_name"] and metadata["args"] but production never writes those keys; the typed payload lives under metadata["normalized"] (a *streams.NormalizedPayload) and the tool kind is on Message.Type. Switched to deriving ToolName from strings.TrimPrefix(m.Type, "tool_") (gives "read", "execute", "edit", …) and reading args from metadata["normalized"], with backward-compat fallback to the older "tool_name"/"args" keys for the debug-fixture replay path. New TestBuildSnapshot_ToolCall_ProductionMetadataShape exercises the real session shape. Bugs (flagged by CodeRabbit on redaction.go:110): - Bare workspace-root occurrences (pwd output, end-of-sentence, quoted paths) used to leak because the matcher only handled root + "/". Switched to a precompiled regex per root — regexp.QuoteMeta(root) + `(?:/|\b)` — so both `root/...` and standalone `root` get stripped, while sibling dirs like /workspace2 still don't match. Test TestRedactor_AbsPath_StandaloneRootStripped covers the bare-root variants. Doc (flagged by Claude on spec.md): - Two malformed fence closers (`go and `text on closing fences) corrected to plain ``` so the file is markdownlint-clean. * fix(share): narrow abs-path boundary to avoid sibling-prefix corruption Round 4 review (Claude + CodeRabbit) caught a regression in the regex introduced by 436cb66: `(?:/|\b)` used `\b` (word boundary) to catch bare-root cases, but `\b` fires on any word↔non-word transition. So a sibling path like `/workspace.bak`, `/workspace-old`, or `/workspace-other` would false-match the `/workspace` root (since e→. and e→- are word boundaries), stripping it and leaving a corrupted `.bak` / `-old/file.ts` in the share. Replaced the matcher with `(/|\s|$)`: path separator, whitespace, or end-of-string only — never punctuation or quote chars. The boundary is captured and re-emitted via ReplaceAllStringFunc (or dropped, in the `/` case) so prose loses only the root, not the surrounding space. Added TestRedactor_AbsPath_SiblingsWithPunctuationNotCorrupted covering `.bak`, `-old`, `_alt` siblings; the digit-sibling test still passes. The quoted-bare-root case (`"/workspace"`) no longer matches and was dropped from the standalone-root test — contrived enough that a small leak there is preferable to corrupting realistic sibling paths. * refactor(share): move share button into chat status bar Replaces the dedicated PanelHeaderBarSplit above the message list with a slot in ChatStatusBar (the existing row holding TodoIndicator and PRStatusChip just above the chat input). The button anchors right via ml-auto; when the "proceed to next step" affordance is present it takes the right anchor instead and the share button stays inline. Also tightens the iconOnly variant in ShareButton to a ghost style that matches the muted-foreground chips in the status bar, and exports shareableSessionStateClient from share-button.tsx so the gating logic lives next to the entry point it controls. Net -23 lines in task-chat-panel.tsx: the extra header bar and the local gate helper are gone. * docs(share): broaden tooltip + add share/ to CLAUDE.md tree The share tooltip read "Share this completed task with a public link", but shareableSessionStateClient allows RUNNING / IDLE / WAITING_FOR_INPUT / FAILED / CANCELLED too. A user hovering mid-run saw misleading copy; reworded to "Share this task conversation as a public link" which matches the actual gate. Also added the missing internal/task/share/ entry to the task/ subtree in CLAUDE.md so the new package shows up in the repo map — flagged across multiple rounds. * docs: add internal/task/share/ to repo map Carryover from the share PR — AGENTS.md (which CLAUDE.md symlinks to) was missing the share/ subpackage under task/. Adding it now so the repo map matches what's checked in. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
976 lines
32 KiB
Go
976 lines
32 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// GHClient implements Client using the gh CLI.
|
|
type GHClient struct {
|
|
rateTracker *RateTracker
|
|
}
|
|
|
|
// NewGHClient creates a new gh CLI-based client.
|
|
func NewGHClient() *GHClient {
|
|
return &GHClient{}
|
|
}
|
|
|
|
// WithRateTracker attaches a rate tracker so stderr output is inspected for
|
|
// rate-limit signals after every gh invocation. Returns the client for
|
|
// chaining; safe to call before any commands run.
|
|
func (c *GHClient) WithRateTracker(t *RateTracker) *GHClient {
|
|
c.rateTracker = t
|
|
return c
|
|
}
|
|
|
|
// reGHRateLimit matches the prose gh prints when a request hit a primary or
|
|
// secondary rate limit. The exact text varies by gh version and locale, but
|
|
// "rate limit" / "API rate limit" appear consistently.
|
|
var ghRateLimitMarkers = []string{"rate limit", "abuse detection"}
|
|
|
|
func ghStderrIndicatesRateLimit(stderr string) bool {
|
|
if stderr == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(stderr)
|
|
for _, marker := range ghRateLimitMarkers {
|
|
if strings.Contains(lower, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// inspectRateStderr inspects the stderr from a failed `gh` invocation and
|
|
// flags the appropriate resource bucket as exhausted. `gh api <path>` is REST
|
|
// (Core) by default, with `api graphql` and `api search/*` as the documented
|
|
// exceptions; non-`api` subcommands like `pr`, `issue`, and `repo` are
|
|
// implemented against GraphQL by gh itself, so they map to the GraphQL bucket.
|
|
func (c *GHClient) inspectRateStderr(args []string, stderr string) {
|
|
if c.rateTracker == nil {
|
|
return
|
|
}
|
|
if !ghStderrIndicatesRateLimit(stderr) {
|
|
return
|
|
}
|
|
c.rateTracker.markRateExhausted(resourceForGHArgs(args), time.Time{})
|
|
}
|
|
|
|
// resourceForGHArgs maps a `gh` argv to the rate-limit bucket the call hits.
|
|
// Exposed at package scope so the table-driven test can pin every branch.
|
|
func resourceForGHArgs(args []string) Resource {
|
|
if len(args) == 0 {
|
|
return ResourceCore
|
|
}
|
|
if args[0] != "api" {
|
|
// `gh pr`, `gh issue`, `gh repo`, etc. are all GraphQL under the hood.
|
|
return ResourceGraphQL
|
|
}
|
|
if len(args) < 2 {
|
|
return ResourceCore
|
|
}
|
|
switch {
|
|
case args[1] == "graphql":
|
|
return ResourceGraphQL
|
|
case strings.HasPrefix(args[1], "search/"):
|
|
return ResourceSearch
|
|
default:
|
|
return ResourceCore
|
|
}
|
|
}
|
|
|
|
// 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 timeout/cancellation errors so callers can distinguish them
|
|
// from a genuine "not authenticated" result. Check the returned error
|
|
// (not ctx.Err()) because run() may apply its own child deadline.
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// RunAuthDiagnostics executes gh auth status and captures the raw output for troubleshooting.
|
|
func (c *GHClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics {
|
|
ctx, cancel := withDefaultGHTimeout(ctx)
|
|
defer cancel()
|
|
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"`
|
|
MergeStateStatus string `json:"mergeStateStatus"`
|
|
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,mergeStateStatus,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,mergeStateStatus,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,mergeStateStatus,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) SearchPRs(ctx context.Context, filter, customQuery string) ([]*PR, error) {
|
|
page, err := c.SearchPRsPaged(ctx, filter, customQuery, 1, 50)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return page.PRs, nil
|
|
}
|
|
|
|
func (c *GHClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) {
|
|
page, perPage = clampSearchPage(page, perPage)
|
|
query := buildPRSearchQuery(filter, customQuery)
|
|
out, err := c.run(ctx, "api", "search/issues",
|
|
"-X", "GET",
|
|
"-f", "q="+query,
|
|
"-f", fmt.Sprintf("per_page=%d", perPage),
|
|
"-f", fmt.Sprintf("page=%d", page),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search PRs: %w", err)
|
|
}
|
|
var result struct {
|
|
TotalCount int `json:"total_count"`
|
|
Items json.RawMessage `json:"items"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
|
return nil, fmt.Errorf("parse PR search response: %w", err)
|
|
}
|
|
prs, err := c.parseSearchResults(string(result.Items))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PRSearchPage{PRs: prs, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil
|
|
}
|
|
|
|
func (c *GHClient) ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) {
|
|
page, err := c.ListIssuesPaged(ctx, filter, customQuery, 1, 50)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return page.Issues, nil
|
|
}
|
|
|
|
func (c *GHClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
|
|
page, perPage = clampSearchPage(page, perPage)
|
|
query := buildIssueSearchQuery(filter, customQuery)
|
|
out, err := c.run(ctx, "api", "search/issues",
|
|
"-X", "GET",
|
|
"-f", "q="+query,
|
|
"-f", fmt.Sprintf("per_page=%d", perPage),
|
|
"-f", fmt.Sprintf("page=%d", page),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list issues: %w", err)
|
|
}
|
|
var result struct {
|
|
TotalCount int `json:"total_count"`
|
|
Items []issueSearchItem `json:"items"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
|
return nil, fmt.Errorf("parse issue search response: %w", err)
|
|
}
|
|
issues := parseIssueSearchResults(result.Items)
|
|
return &IssueSearchPage{Issues: issues, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil
|
|
}
|
|
|
|
func (c *GHClient) GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) {
|
|
out, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, number),
|
|
"--jq", ".state")
|
|
if err != nil {
|
|
return "", fmt.Errorf("get issue state: %w", err)
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
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) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) {
|
|
return getPRStatus(ctx, c, owner, repo, number)
|
|
}
|
|
|
|
// FetchBranchProtection looks up branch protection via `gh api`. A 404 from
|
|
// `gh` (no rule) and a 403 (token lacks Administration: Read scope) are both
|
|
// treated as "no rule we can see" so callers cache the negative result and
|
|
// don't burn rate-limit quota on every poll. The string match is permissive
|
|
// across formats so a future `gh` CLI release that tweaks the wording
|
|
// doesn't silently break the cache.
|
|
func (c *GHClient) FetchBranchProtection(ctx context.Context, owner, repo, branch string) (BranchProtection, error) {
|
|
out, err := c.run(ctx, "api",
|
|
fmt.Sprintf("repos/%s/%s/branches/%s/protection", owner, repo, branch))
|
|
if err != nil {
|
|
if isNotFoundErr(err) || isForbiddenErr(err) {
|
|
return BranchProtection{HasRule: false}, nil
|
|
}
|
|
return BranchProtection{}, err
|
|
}
|
|
var raw struct {
|
|
RequiredPullRequestReviews *struct {
|
|
RequiredApprovingReviewCount int `json:"required_approving_review_count"`
|
|
} `json:"required_pull_request_reviews"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &raw); err != nil {
|
|
return BranchProtection{}, fmt.Errorf("decode branch protection: %w", err)
|
|
}
|
|
if raw.RequiredPullRequestReviews == nil {
|
|
return BranchProtection{HasRule: true}, nil
|
|
}
|
|
return BranchProtection{
|
|
HasRule: true,
|
|
RequiredApprovingReviewCount: raw.RequiredPullRequestReviews.RequiredApprovingReviewCount,
|
|
}, nil
|
|
}
|
|
|
|
// isNotFoundErr matches the formats the `gh` CLI uses to report a 404 across
|
|
// recent versions: "HTTP 404", "HTTP 404: ...", and "404 Not Found".
|
|
// Restrictive enough that an unrelated 404 substring (e.g. inside a JSON
|
|
// payload accidentally rendered into stderr) is unlikely to false-match.
|
|
func isNotFoundErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
return strings.Contains(s, "HTTP 404") ||
|
|
strings.Contains(s, "404 Not Found") ||
|
|
strings.Contains(s, "status: 404")
|
|
}
|
|
|
|
// isForbiddenErr matches the formats the `gh` CLI uses to report a 403, used
|
|
// by the branch-protection lookup to silently fall back to "no rule" when
|
|
// the token lacks Administration: Read scope.
|
|
func isForbiddenErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
return strings.Contains(s, "HTTP 403") ||
|
|
strings.Contains(s, "403 Forbidden") ||
|
|
strings.Contains(s, "status: 403")
|
|
}
|
|
|
|
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) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
|
|
endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/merge", owner, repo, number)
|
|
args := []string{"api", endpoint, "-X", "PUT"}
|
|
if mergeMethod != "" {
|
|
args = append(args, "-f", "merge_method="+mergeMethod)
|
|
}
|
|
_, err := c.run(ctx, args...)
|
|
if err != nil {
|
|
// Surface status-based errors as GitHubAPIError so httpMergePR can
|
|
// translate 405 (not mergeable) / 409 (conflict) to HTTP 409 for
|
|
// gh CLI users too, matching the PAT path.
|
|
if code, ok := ghMergeStatusCode(err); ok {
|
|
return &GitHubAPIError{StatusCode: code, Endpoint: endpoint, Body: err.Error()}
|
|
}
|
|
return fmt.Errorf("merge PR #%d: %w", number, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ghMergeStatusCode extracts the HTTP status code from a gh CLI merge error.
|
|
// Returns the code and true when the stderr matches one of GitHub's
|
|
// merge-API failure shapes (404/403/405/409); false otherwise so the caller
|
|
// falls back to a generic wrap.
|
|
func ghMergeStatusCode(err error) (int, bool) {
|
|
if err == nil {
|
|
return 0, false
|
|
}
|
|
if isNotFoundErr(err) {
|
|
return http.StatusNotFound, true
|
|
}
|
|
if isForbiddenErr(err) {
|
|
return http.StatusForbidden, true
|
|
}
|
|
s := err.Error()
|
|
if strings.Contains(s, "HTTP 405") || strings.Contains(s, "status: 405") ||
|
|
strings.Contains(s, "405 Method Not Allowed") {
|
|
return http.StatusMethodNotAllowed, true
|
|
}
|
|
if strings.Contains(s, "HTTP 409") || strings.Contains(s, "status: 409") ||
|
|
strings.Contains(s, "409 Conflict") {
|
|
return http.StatusConflict, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (c *GHClient) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) {
|
|
out, err := c.run(ctx, "api", fmt.Sprintf("repos/%s/%s", owner, repo))
|
|
if err != nil {
|
|
return RepoMergeMethods{}, fmt.Errorf("get repo merge methods: %w", err)
|
|
}
|
|
var raw struct {
|
|
AllowMergeCommit *bool `json:"allow_merge_commit"`
|
|
AllowSquashMerge *bool `json:"allow_squash_merge"`
|
|
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &raw); err != nil {
|
|
return RepoMergeMethods{}, fmt.Errorf("parse repo: %w", err)
|
|
}
|
|
// Conservative read: missing field → false. A permission-gated response
|
|
// that omits allow_* would otherwise let us pick a disallowed method
|
|
// (e.g. "merge" on a rebase-only repo), reproducing the 405 this fix is
|
|
// designed to prevent. Callers that fall back to GitHub's default on an
|
|
// empty pick get a meaningful error instead of a wrong-method 405.
|
|
allowed := func(p *bool) bool { return p != nil && *p }
|
|
return RepoMergeMethods{
|
|
Merge: allowed(raw.AllowMergeCommit),
|
|
Squash: allowed(raw.AllowSquashMerge),
|
|
Rebase: allowed(raw.AllowRebaseMerge),
|
|
}, 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
|
|
}
|
|
|
|
func (c *GHClient) CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) {
|
|
payload := struct {
|
|
Description string `json:"description,omitempty"`
|
|
Public bool `json:"public"`
|
|
Files map[string]gistFileDTO `json:"files"`
|
|
}{
|
|
Description: in.Description,
|
|
Public: in.Public,
|
|
Files: make(map[string]gistFileDTO, len(in.Files)),
|
|
}
|
|
for name, f := range in.Files {
|
|
payload.Files[name] = gistFileDTO(f)
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal gist payload: %w", err)
|
|
}
|
|
args := []string{"api", "gists",
|
|
"-X", "POST",
|
|
"-H", "Accept: " + githubAccept,
|
|
"--input", "-",
|
|
}
|
|
out, err := c.runWithStdin(ctx, body, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create gist: %w", err)
|
|
}
|
|
var resp GistResponse
|
|
if err := json.Unmarshal([]byte(out), &resp); err != nil {
|
|
return nil, fmt.Errorf("decode gist response: %w", err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
func (c *GHClient) DeleteGist(ctx context.Context, gistID string) error {
|
|
if gistID == "" {
|
|
return fmt.Errorf("delete gist: empty id")
|
|
}
|
|
_, err := c.run(ctx, "api", "gists/"+gistID, "-X", "DELETE")
|
|
if err != nil {
|
|
// gh exits non-zero for HTTP errors; stderr contains the status
|
|
// line (e.g. "HTTP 404: Not Found"). Promote 404 to *GitHubAPIError
|
|
// so share.IsAlreadyGone matches consistently — PATClient.delete()
|
|
// already returns a typed error for the same case, and the share
|
|
// service uses errors.As to detect "gist already revoked upstream"
|
|
// and treat it as a soft success rather than a 502. Use isNotFoundErr
|
|
// so every gh-CLI 404 format ("HTTP 404", "404 Not Found", "status: 404")
|
|
// is recognised, not just the most common one.
|
|
if isNotFoundErr(err) {
|
|
return &GitHubAPIError{
|
|
StatusCode: http.StatusNotFound,
|
|
Endpoint: "/gists/" + gistID,
|
|
Body: err.Error(),
|
|
}
|
|
}
|
|
return fmt.Errorf("delete gist %s: %w", gistID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const ghCLITimeout = 30 * time.Second
|
|
|
|
// withDefaultGHTimeout applies a 30s timeout if the context has no deadline.
|
|
func withDefaultGHTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
if _, ok := ctx.Deadline(); ok {
|
|
return ctx, func() {}
|
|
}
|
|
return context.WithTimeout(ctx, ghCLITimeout)
|
|
}
|
|
|
|
// run executes a gh CLI command and returns its stdout output.
|
|
// Stderr is captured separately to avoid contaminating JSON output.
|
|
// A default 30s timeout is applied if the context has no deadline.
|
|
func (c *GHClient) run(ctx context.Context, args ...string) (string, error) {
|
|
ctx, cancel := withDefaultGHTimeout(ctx)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
c.inspectRateStderr(args, stderr.String())
|
|
return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String())
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// runWithStdin is like run but pipes stdin into the gh CLI process.
|
|
// Useful for `gh api --input -` calls where the body comes from JSON.
|
|
func (c *GHClient) runWithStdin(ctx context.Context, stdin []byte, args ...string) (string, error) {
|
|
ctx, cancel := withDefaultGHTimeout(ctx)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
cmd.Stdin = bytes.NewReader(stdin)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
c.inspectRateStderr(args, stderr.String())
|
|
return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String())
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
// ghRateLimitResponse mirrors the GET /rate_limit JSON shape so we can seed
|
|
// the tracker on startup and after CLI failures without parsing prose.
|
|
type ghRateLimitResponse struct {
|
|
Resources struct {
|
|
Core ghRateLimitBucket `json:"core"`
|
|
GraphQL ghRateLimitBucket `json:"graphql"`
|
|
Search ghRateLimitBucket `json:"search"`
|
|
} `json:"resources"`
|
|
}
|
|
|
|
type ghRateLimitBucket struct {
|
|
Limit int `json:"limit"`
|
|
Remaining int `json:"remaining"`
|
|
Reset int64 `json:"reset"`
|
|
}
|
|
|
|
// FetchRateLimit calls `gh api rate_limit` and seeds the tracker with the
|
|
// returned snapshots. Best-effort: a failure (e.g. CLI absent, network) is
|
|
// logged and ignored.
|
|
func (c *GHClient) FetchRateLimit(ctx context.Context) error {
|
|
if c.rateTracker == nil {
|
|
return nil
|
|
}
|
|
out, err := c.run(ctx, "api", "rate_limit")
|
|
if err != nil {
|
|
return fmt.Errorf("fetch rate limit: %w", err)
|
|
}
|
|
var raw ghRateLimitResponse
|
|
if err := json.Unmarshal([]byte(out), &raw); err != nil {
|
|
return fmt.Errorf("parse rate_limit response: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
record := func(resource Resource, b ghRateLimitBucket) {
|
|
if b.Limit == 0 && b.Remaining == 0 && b.Reset == 0 {
|
|
return
|
|
}
|
|
c.rateTracker.Record(RateSnapshot{
|
|
Resource: resource,
|
|
Limit: b.Limit,
|
|
Remaining: b.Remaining,
|
|
ResetAt: time.Unix(b.Reset, 0).UTC(),
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
record(ResourceCore, raw.Resources.Core)
|
|
record(ResourceGraphQL, raw.Resources.GraphQL)
|
|
record(ResourceSearch, raw.Resources.Search)
|
|
return 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",
|
|
MergeableState: strings.ToLower(raw.MergeStateStatus),
|
|
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))
|
|
}
|