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>
847 lines
28 KiB
Go
847 lines
28 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
githubAPIBase = "https://api.github.com"
|
|
githubAccept = "application/vnd.github+json"
|
|
githubAPIVersion = "2022-11-28"
|
|
)
|
|
|
|
// GitHubAPIError represents an error response from the GitHub API with a status code.
|
|
type GitHubAPIError struct {
|
|
StatusCode int
|
|
Endpoint string
|
|
Body string
|
|
}
|
|
|
|
func (e *GitHubAPIError) Error() string {
|
|
return fmt.Sprintf("GitHub API %s returned %d: %s", e.Endpoint, e.StatusCode, e.Body)
|
|
}
|
|
|
|
// PATClient implements Client using a GitHub Personal Access Token.
|
|
type PATClient struct {
|
|
token string
|
|
httpClient *http.Client
|
|
username string // cached after first GetAuthenticatedUser call
|
|
rateTracker *RateTracker
|
|
}
|
|
|
|
// NewPATClient creates a new PAT-based GitHub client.
|
|
func NewPATClient(token string) *PATClient {
|
|
return &PATClient{
|
|
token: token,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// WithRateTracker attaches a rate tracker so response headers are recorded.
|
|
// Returns the client for chaining; safe to call before any requests are made.
|
|
func (c *PATClient) WithRateTracker(t *RateTracker) *PATClient {
|
|
c.rateTracker = t
|
|
return c
|
|
}
|
|
|
|
// recordRateHeaders feeds rate-limit data from a response into the tracker.
|
|
// endpoint is used to pick the default resource bucket when the response
|
|
// omits the X-RateLimit-Resource header.
|
|
func (c *PATClient) recordRateHeaders(resp *http.Response, endpoint string) {
|
|
if c.rateTracker == nil || resp == nil {
|
|
return
|
|
}
|
|
defaultResource := ResourceCore
|
|
if strings.HasPrefix(endpoint, "/search/") {
|
|
defaultResource = ResourceSearch
|
|
} else if strings.HasPrefix(endpoint, "/graphql") {
|
|
defaultResource = ResourceGraphQL
|
|
}
|
|
snap, headersOK := parseRateHeaders(resp, defaultResource)
|
|
if headersOK {
|
|
c.rateTracker.Record(snap)
|
|
}
|
|
if !isRateLimitStatus(resp.StatusCode) {
|
|
return
|
|
}
|
|
// On a 429, prefer the real X-RateLimit-Reset over the synthetic 1h
|
|
// fallback. parseRateHeaders may return ok=true with Remaining>0 if the
|
|
// secondary-limit response carried stale headers — only skip the
|
|
// fallback when the headers themselves report exhaustion (Remaining<=0
|
|
// + future ResetAt). Otherwise fall through to the conservative pause.
|
|
if headersOK && snap.Exhausted() {
|
|
return
|
|
}
|
|
c.rateTracker.markRateExhausted(defaultResource, time.Time{})
|
|
}
|
|
|
|
// isRateLimitStatus returns true for status codes GitHub uses to signal
|
|
// primary or secondary rate-limit exhaustion. 403 is documented for both
|
|
// abuse-detection and primary limits when the body indicates so; 429 is
|
|
// secondary limits.
|
|
func isRateLimitStatus(status int) bool {
|
|
return status == http.StatusTooManyRequests
|
|
}
|
|
|
|
// maybeMarkRateExhaustedFromBody flags a rate-limit hit when GitHub returned
|
|
// a 403/429 whose body contains the rate-limit prose. The headers may be
|
|
// missing on these responses (esp. secondary limits), so the body is the
|
|
// authoritative signal.
|
|
func (c *PATClient) maybeMarkRateExhaustedFromBody(endpoint string, status int, body []byte) {
|
|
if c.rateTracker == nil {
|
|
return
|
|
}
|
|
if status != http.StatusForbidden && status != http.StatusTooManyRequests {
|
|
return
|
|
}
|
|
lower := strings.ToLower(string(body))
|
|
if !strings.Contains(lower, "rate limit") && !strings.Contains(lower, "abuse detection") {
|
|
return
|
|
}
|
|
resource := ResourceCore
|
|
if strings.HasPrefix(endpoint, "/search/") {
|
|
resource = ResourceSearch
|
|
} else if strings.HasPrefix(endpoint, "/graphql") {
|
|
resource = ResourceGraphQL
|
|
}
|
|
// If recordRateHeaders already captured a real reset for this bucket on
|
|
// the same response, don't clobber it with the synthetic 1h fallback.
|
|
if existing, ok := c.rateTracker.Snapshot(resource); ok && existing.Exhausted() {
|
|
return
|
|
}
|
|
c.rateTracker.markRateExhausted(resource, time.Time{})
|
|
}
|
|
|
|
// setGitHubHeaders sets the common Authorization, Accept, and API version headers.
|
|
func (c *PATClient) setGitHubHeaders(req *http.Request) {
|
|
req.Header.Set("Authorization", "token "+c.token)
|
|
req.Header.Set("Accept", githubAccept)
|
|
req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
|
|
}
|
|
|
|
func (c *PATClient) IsAuthenticated(ctx context.Context) (bool, error) {
|
|
_, err := c.GetAuthenticatedUser(ctx)
|
|
return err == nil, nil
|
|
}
|
|
|
|
func (c *PATClient) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|
if c.username != "" {
|
|
return c.username, nil
|
|
}
|
|
var user struct {
|
|
Login string `json:"login"`
|
|
}
|
|
if err := c.get(ctx, "/user", &user); err != nil {
|
|
return "", err
|
|
}
|
|
c.username = user.Login
|
|
return c.username, nil
|
|
}
|
|
|
|
func (c *PATClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) {
|
|
var raw patPR
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, number)
|
|
if err := c.get(ctx, endpoint, &raw); err != nil {
|
|
return nil, fmt.Errorf("get PR #%d: %w", number, err)
|
|
}
|
|
return convertPatPR(&raw, owner, repo), nil
|
|
}
|
|
|
|
func (c *PATClient) FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) {
|
|
var raw []patPR
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls?head=%s:%s&state=open&per_page=1",
|
|
owner, repo, owner, branch)
|
|
if err := c.get(ctx, endpoint, &raw); err != nil {
|
|
return nil, fmt.Errorf("find PR by branch: %w", err)
|
|
}
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
return convertPatPR(&raw[0], owner, repo), nil
|
|
}
|
|
|
|
func (c *PATClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) {
|
|
user, err := c.GetAuthenticatedUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw []patPR
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls?state=open&per_page=100", owner, repo)
|
|
if err := c.get(ctx, endpoint, &raw); err != nil {
|
|
return nil, fmt.Errorf("list PRs: %w", err)
|
|
}
|
|
var result []*PR
|
|
for i := range raw {
|
|
if raw[i].User.Login == user {
|
|
result = append(result, convertPatPR(&raw[i], owner, repo))
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, customQuery string) ([]*PR, error) {
|
|
query := buildReviewSearchQuery(scope, filter, customQuery)
|
|
var result struct {
|
|
Items []patSearchItem `json:"items"`
|
|
}
|
|
endpoint := "/search/issues?q=" + url.QueryEscape(query) + "&per_page=50"
|
|
if err := c.get(ctx, endpoint, &result); err != nil {
|
|
return nil, fmt.Errorf("search review-requested: %w", err)
|
|
}
|
|
prs := make([]*PR, len(result.Items))
|
|
for i, item := range result.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 (c *PATClient) 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 *PATClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) {
|
|
page, perPage = clampSearchPage(page, perPage)
|
|
query := buildPRSearchQuery(filter, customQuery)
|
|
var result struct {
|
|
TotalCount int `json:"total_count"`
|
|
Items []patSearchItem `json:"items"`
|
|
}
|
|
endpoint := fmt.Sprintf("/search/issues?q=%s&per_page=%d&page=%d",
|
|
url.QueryEscape(query), perPage, page)
|
|
if err := c.get(ctx, endpoint, &result); err != nil {
|
|
return nil, fmt.Errorf("search PRs: %w", err)
|
|
}
|
|
prs := make([]*PR, len(result.Items))
|
|
for i, item := range result.Items {
|
|
prs[i] = convertSearchItemToPR(
|
|
item.Number, item.Title, item.HTMLURL, item.State,
|
|
item.User.Login, item.RepositoryURL, item.Draft,
|
|
item.CreatedAt, item.UpdatedAt,
|
|
)
|
|
}
|
|
return &PRSearchPage{PRs: prs, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil
|
|
}
|
|
|
|
func (c *PATClient) 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 *PATClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
|
|
page, perPage = clampSearchPage(page, perPage)
|
|
query := buildIssueSearchQuery(filter, customQuery)
|
|
var result struct {
|
|
TotalCount int `json:"total_count"`
|
|
Items []issueSearchItem `json:"items"`
|
|
}
|
|
endpoint := fmt.Sprintf("/search/issues?q=%s&per_page=%d&page=%d",
|
|
url.QueryEscape(query), perPage, page)
|
|
if err := c.get(ctx, endpoint, &result); err != nil {
|
|
return nil, fmt.Errorf("search issues: %w", err)
|
|
}
|
|
issues := parseIssueSearchResults(result.Items)
|
|
return &IssueSearchPage{Issues: issues, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil
|
|
}
|
|
|
|
func (c *PATClient) GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) {
|
|
var result struct {
|
|
State string `json:"state"`
|
|
}
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number)
|
|
if err := c.get(ctx, endpoint, &result); err != nil {
|
|
return "", fmt.Errorf("get issue state: %w", err)
|
|
}
|
|
return result.State, nil
|
|
}
|
|
|
|
func (c *PATClient) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) {
|
|
var raw []struct {
|
|
Login string `json:"login"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
if err := c.get(ctx, "/user/orgs?per_page=100", &raw); err != nil {
|
|
return nil, fmt.Errorf("list user 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 *PATClient) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) {
|
|
q := "org:" + org
|
|
if query != "" {
|
|
q += " " + query
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
var result struct {
|
|
Items []struct {
|
|
FullName string `json:"full_name"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
Name string `json:"name"`
|
|
Private bool `json:"private"`
|
|
} `json:"items"`
|
|
}
|
|
endpoint := fmt.Sprintf("/search/repositories?q=%s&per_page=%d", url.QueryEscape(q), limit)
|
|
if err := c.get(ctx, endpoint, &result); err != nil {
|
|
return nil, fmt.Errorf("search org repos: %w", err)
|
|
}
|
|
repos := make([]GitHubRepo, len(result.Items))
|
|
for i, item := range result.Items {
|
|
repos[i] = GitHubRepo{
|
|
FullName: item.FullName,
|
|
Owner: item.Owner.Login,
|
|
Name: item.Name,
|
|
Private: item.Private,
|
|
}
|
|
}
|
|
return repos, nil
|
|
}
|
|
|
|
func (c *PATClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) {
|
|
var raw []struct {
|
|
ID int64 `json:"id"`
|
|
State string `json:"state"`
|
|
Body string `json:"body"`
|
|
SubmittedAt time.Time `json:"submitted_at"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
} `json:"user"`
|
|
}
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number)
|
|
if err := c.get(ctx, endpoint, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
reviews := make([]PRReview, len(raw))
|
|
for i, r := range raw {
|
|
reviews[i] = PRReview{
|
|
ID: r.ID,
|
|
Author: r.User.Login,
|
|
AuthorAvatar: r.User.AvatarURL,
|
|
State: r.State,
|
|
Body: r.Body,
|
|
CreatedAt: r.SubmittedAt,
|
|
}
|
|
}
|
|
return reviews, nil
|
|
}
|
|
|
|
func (c *PATClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) {
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", owner, repo, number)
|
|
if since != nil {
|
|
endpoint += "&since=" + url.QueryEscape(since.Format(time.RFC3339))
|
|
}
|
|
var reviewRaw []ghComment
|
|
if err := c.get(ctx, endpoint, &reviewRaw); err != nil {
|
|
return nil, err
|
|
}
|
|
issueEndpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?per_page=100", owner, repo, number)
|
|
if since != nil {
|
|
issueEndpoint += "&since=" + url.QueryEscape(since.Format(time.RFC3339))
|
|
}
|
|
var issueRaw []ghIssueComment
|
|
if err := c.get(ctx, issueEndpoint, &issueRaw); err != nil {
|
|
return nil, err
|
|
}
|
|
return mergeAndSortPRComments(convertRawComments(reviewRaw), convertRawIssueComments(issueRaw)), nil
|
|
}
|
|
|
|
func (c *PATClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) {
|
|
var checkRunsResult struct {
|
|
CheckRuns []ghCheckRun `json:"check_runs"`
|
|
}
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/check-runs", owner, repo, ref)
|
|
if err := c.get(ctx, endpoint, &checkRunsResult); err != nil {
|
|
return nil, err
|
|
}
|
|
var statusResult struct {
|
|
Statuses []ghStatusContext `json:"statuses"`
|
|
}
|
|
statusEndpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/status", owner, repo, ref)
|
|
if err := c.get(ctx, statusEndpoint, &statusResult); err != nil {
|
|
return nil, err
|
|
}
|
|
return mergeChecks(
|
|
convertRawCheckRuns(checkRunsResult.CheckRuns),
|
|
convertRawStatusContexts(statusResult.Statuses),
|
|
), nil
|
|
}
|
|
|
|
func (c *PATClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) {
|
|
return getPRFeedback(ctx, c, owner, repo, number)
|
|
}
|
|
|
|
func (c *PATClient) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) {
|
|
return getPRStatus(ctx, c, owner, repo, number)
|
|
}
|
|
|
|
func (c *PATClient) ListPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) {
|
|
var raw []ghPRFile
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/files?per_page=100", owner, repo, number)
|
|
if err := c.get(ctx, endpoint, &raw); err != nil {
|
|
return nil, fmt.Errorf("list PR files: %w", err)
|
|
}
|
|
return convertRawPRFiles(raw), nil
|
|
}
|
|
|
|
func (c *PATClient) ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) {
|
|
var raw []ghPRCommit
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/commits?per_page=100", owner, repo, number)
|
|
if err := c.get(ctx, endpoint, &raw); err != nil {
|
|
return nil, fmt.Errorf("list PR commits: %w", err)
|
|
}
|
|
return convertRawPRCommits(raw), nil
|
|
}
|
|
|
|
func (c *PATClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) {
|
|
var branches []RepoBranch
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/branches?per_page=100", owner, repo)
|
|
for endpoint != "" {
|
|
var page []struct {
|
|
Name string `json:"name"`
|
|
}
|
|
nextLink, err := c.getPaginated(ctx, endpoint, &page)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list repo branches: %w", err)
|
|
}
|
|
for _, b := range page {
|
|
branches = append(branches, RepoBranch{Name: b.Name})
|
|
}
|
|
endpoint = nextLink
|
|
}
|
|
return branches, nil
|
|
}
|
|
|
|
func (c *PATClient) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) {
|
|
var raw struct {
|
|
AllowMergeCommit *bool `json:"allow_merge_commit"`
|
|
AllowSquashMerge *bool `json:"allow_squash_merge"`
|
|
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
|
|
}
|
|
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s", owner, repo), &raw); err != nil {
|
|
return RepoMergeMethods{}, fmt.Errorf("get repo merge methods: %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.
|
|
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 *PATClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error {
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number)
|
|
payload := map[string]string{"event": event}
|
|
if body != "" {
|
|
payload["body"] = body
|
|
}
|
|
jsonBody, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal review payload: %w", err)
|
|
}
|
|
return c.post(ctx, endpoint, jsonBody)
|
|
}
|
|
|
|
func (c *PATClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, number)
|
|
payload := map[string]string{}
|
|
if mergeMethod != "" {
|
|
payload["merge_method"] = mergeMethod
|
|
}
|
|
jsonBody, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal merge payload: %w", err)
|
|
}
|
|
return c.put(ctx, endpoint, jsonBody)
|
|
}
|
|
|
|
func (c *PATClient) 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)
|
|
}
|
|
var resp GistResponse
|
|
if err := c.postJSON(ctx, "/gists", body, &resp); err != nil {
|
|
return nil, fmt.Errorf("create gist: %w", err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
func (c *PATClient) DeleteGist(ctx context.Context, gistID string) error {
|
|
if gistID == "" {
|
|
return fmt.Errorf("delete gist: empty id")
|
|
}
|
|
return c.delete(ctx, "/gists/"+gistID)
|
|
}
|
|
|
|
// gistFileDTO mirrors the GitHub API's per-file body shape.
|
|
type gistFileDTO struct {
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// post makes an authenticated HTTP POST to the GitHub API.
|
|
// Errors on non-2xx are returned as plain `fmt.Errorf` (not `*GitHubAPIError`),
|
|
// so callers cannot recover the HTTP status via `errors.As`. Use `put` (or
|
|
// switch to wrapping in `GitHubAPIError`) if the caller needs per-status
|
|
// mapping like the merge endpoint does.
|
|
func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) error {
|
|
u := githubAPIBase + endpoint
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setGitHubHeaders(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request POST %s: %w", endpoint, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
c.recordRateHeaders(resp, endpoint)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody)
|
|
return fmt.Errorf("GitHub API POST %s returned %d: %s", endpoint, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// postJSON sends a POST and decodes the response body into result.
|
|
// 2xx with no body returns nil; 4xx/5xx returns a *GitHubAPIError.
|
|
func (c *PATClient) postJSON(ctx context.Context, endpoint string, body []byte, result interface{}) error {
|
|
u := githubAPIBase + endpoint
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setGitHubHeaders(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request POST %s: %w", endpoint, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
c.recordRateHeaders(resp, endpoint)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody)
|
|
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
|
|
}
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(result)
|
|
}
|
|
|
|
// delete sends a DELETE request. 2xx and 404 both return nil-or-typed-error per caller intent.
|
|
// Here we return a typed error on any non-2xx so callers can inspect for 404.
|
|
func (c *PATClient) delete(ctx context.Context, endpoint string) error {
|
|
u := githubAPIBase + endpoint
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setGitHubHeaders(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request DELETE %s: %w", endpoint, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
c.recordRateHeaders(resp, endpoint)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody)
|
|
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error {
|
|
u := githubAPIBase + endpoint
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setGitHubHeaders(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request PUT %s: %w", endpoint, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
c.recordRateHeaders(resp, endpoint)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody)
|
|
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *PATClient) get(ctx context.Context, endpoint string, result interface{}) error {
|
|
url := githubAPIBase + endpoint
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setGitHubHeaders(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request %s: %w", endpoint, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
c.recordRateHeaders(resp, endpoint)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body)
|
|
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)}
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(result)
|
|
}
|
|
|
|
// getPaginated is like get but also returns the "next" link from the Link header, if any.
|
|
func (c *PATClient) getPaginated(ctx context.Context, endpoint string, result interface{}) (string, error) {
|
|
u := githubAPIBase + endpoint
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c.setGitHubHeaders(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("request %s: %w", endpoint, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
c.recordRateHeaders(resp, endpoint)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body)
|
|
return "", &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)}
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
return "", err
|
|
}
|
|
return parseNextLink(resp.Header.Get("Link")), nil
|
|
}
|
|
|
|
// parseNextLink extracts the "next" URL path+query from a GitHub Link header.
|
|
func parseNextLink(header string) string {
|
|
if header == "" {
|
|
return ""
|
|
}
|
|
for _, part := range strings.Split(header, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if !strings.Contains(part, `rel="next"`) {
|
|
continue
|
|
}
|
|
start := strings.Index(part, "<")
|
|
end := strings.Index(part, ">")
|
|
if start < 0 || end < 0 || end <= start {
|
|
continue
|
|
}
|
|
link := part[start+1 : end]
|
|
// Strip the base URL to return just the path+query for our get method
|
|
if strings.HasPrefix(link, githubAPIBase) {
|
|
return link[len(githubAPIBase):]
|
|
}
|
|
return link
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// FetchBranchProtection looks up the branch protection rule on a repo's base
|
|
// branch via the REST API. A 404 is treated as "no rule configured" and a 403
|
|
// is treated as "no rule we can see" (token lacks Administration: Read scope).
|
|
// Both return HasRule=false with no error so the cache stores the negative
|
|
// result and we don't burn rate-limit quota retrying every poll cycle.
|
|
// Other errors propagate so callers can decide whether to retry.
|
|
func (c *PATClient) FetchBranchProtection(ctx context.Context, owner, repo, branch string) (BranchProtection, error) {
|
|
endpoint := fmt.Sprintf("/repos/%s/%s/branches/%s/protection", owner, repo, branch)
|
|
var raw struct {
|
|
RequiredPullRequestReviews *struct {
|
|
RequiredApprovingReviewCount int `json:"required_approving_review_count"`
|
|
} `json:"required_pull_request_reviews"`
|
|
}
|
|
if err := c.get(ctx, endpoint, &raw); err != nil {
|
|
var apiErr *GitHubAPIError
|
|
if errors.As(err, &apiErr) {
|
|
if apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusForbidden {
|
|
return BranchProtection{HasRule: false}, nil
|
|
}
|
|
}
|
|
return BranchProtection{}, err
|
|
}
|
|
if raw.RequiredPullRequestReviews == nil {
|
|
return BranchProtection{HasRule: true}, nil
|
|
}
|
|
return BranchProtection{
|
|
HasRule: true,
|
|
RequiredApprovingReviewCount: raw.RequiredPullRequestReviews.RequiredApprovingReviewCount,
|
|
}, nil
|
|
}
|
|
|
|
// patPR is the JSON shape from the GitHub REST API for PRs.
|
|
type patPR struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
HTMLURL string `json:"html_url"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
Draft bool `json:"draft"`
|
|
Mergeable *bool `json:"mergeable"`
|
|
MergeableState string `json:"mergeable_state"`
|
|
Additions int `json:"additions"`
|
|
Deletions int `json:"deletions"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
MergedAt *string `json:"merged_at"`
|
|
ClosedAt *string `json:"closed_at"`
|
|
RequestedReviewers []struct {
|
|
Login string `json:"login"`
|
|
} `json:"requested_reviewers"`
|
|
RequestedTeams []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
} `json:"requested_teams"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
Head struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
} `json:"head"`
|
|
Base struct {
|
|
Ref string `json:"ref"`
|
|
} `json:"base"`
|
|
}
|
|
|
|
type patSearchItem 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"`
|
|
RepositoryURL string `json:"repository_url"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
func convertPatPR(raw *patPR, owner, repo string) *PR {
|
|
state := strings.ToLower(raw.State)
|
|
if raw.MergedAt != nil && *raw.MergedAt != "" {
|
|
state = prStateMerged
|
|
}
|
|
mergeable := false
|
|
if raw.Mergeable != nil {
|
|
mergeable = *raw.Mergeable
|
|
}
|
|
pr := &PR{
|
|
Number: raw.Number,
|
|
Title: raw.Title,
|
|
HTMLURL: raw.HTMLURL,
|
|
Body: raw.Body,
|
|
State: state,
|
|
HeadBranch: raw.Head.Ref,
|
|
HeadSHA: raw.Head.SHA,
|
|
BaseBranch: raw.Base.Ref,
|
|
AuthorLogin: raw.User.Login,
|
|
RepoOwner: owner,
|
|
RepoName: repo,
|
|
Draft: raw.Draft,
|
|
Mergeable: mergeable,
|
|
MergeableState: strings.ToLower(raw.MergeableState),
|
|
Additions: raw.Additions,
|
|
Deletions: raw.Deletions,
|
|
RequestedReviewers: convertPatRequestedReviewers(raw),
|
|
CreatedAt: raw.CreatedAt,
|
|
UpdatedAt: raw.UpdatedAt,
|
|
}
|
|
if raw.MergedAt != nil {
|
|
pr.MergedAt = parseTimePtr(*raw.MergedAt)
|
|
}
|
|
if raw.ClosedAt != nil {
|
|
pr.ClosedAt = parseTimePtr(*raw.ClosedAt)
|
|
}
|
|
return pr
|
|
}
|
|
|
|
func convertPatRequestedReviewers(raw *patPR) []RequestedReviewer {
|
|
reviewers := make([]RequestedReviewer, 0, len(raw.RequestedReviewers)+len(raw.RequestedTeams))
|
|
for _, reviewer := range raw.RequestedReviewers {
|
|
if reviewer.Login == "" {
|
|
continue
|
|
}
|
|
reviewers = append(reviewers, RequestedReviewer{Login: reviewer.Login, Type: reviewerTypeUser})
|
|
}
|
|
for _, team := range raw.RequestedTeams {
|
|
login := team.Slug
|
|
if login == "" {
|
|
login = team.Name
|
|
}
|
|
if login == "" {
|
|
continue
|
|
}
|
|
reviewers = append(reviewers, RequestedReviewer{Login: login, Type: reviewerTypeTeam})
|
|
}
|
|
return reviewers
|
|
}
|