737 lines
23 KiB
Go
737 lines
23 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
// ciFailureLogTailLines is the number of trailing lines of a failed job's
|
|
// log we splice into the observation. 20 lines is enough to catch the
|
|
// usual "X tests failed" tail without bloating the per-PR row.
|
|
const ciFailureLogTailLines = 20
|
|
|
|
// ProviderOptions configures a Provider. Production code typically sets
|
|
// Token; tests inject a pre-built Client pointed at httptest.
|
|
type ProviderOptions struct {
|
|
Client *Client
|
|
HTTPClient *http.Client
|
|
Token TokenSource
|
|
// SkipTokenPreflight defers token validation until the first provider call.
|
|
// Daemon wiring uses this so gh-token shell-out never blocks readiness.
|
|
SkipTokenPreflight bool
|
|
RESTBase string
|
|
GraphQLURL string
|
|
UserAgent string
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// Provider observes one GitHub pull request and returns a normalized
|
|
// ports.PRObservation for the PR Manager to persist. There is no polling
|
|
// loop in v1 — the loop is a follow-up PR (#35); this adapter is the
|
|
// observation primitive that loop will call.
|
|
type Provider struct {
|
|
client *Client
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewProvider returns a Provider. If opts.Client is supplied it is used
|
|
// verbatim; otherwise a Client is built from the other options. When a
|
|
// Token source is supplied it is exercised once so missing credentials
|
|
// surface at daemon startup rather than at first observation, unless
|
|
// SkipTokenPreflight is set. Tests that want an unauthenticated fake pass
|
|
// opts.Client directly.
|
|
func NewProvider(opts ProviderOptions) (*Provider, error) {
|
|
if opts.Client == nil && opts.Token != nil && !opts.SkipTokenPreflight {
|
|
if _, err := opts.Token.Token(context.Background()); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
c := opts.Client
|
|
if c == nil {
|
|
c = NewClient(ClientOptions{
|
|
HTTPClient: opts.HTTPClient,
|
|
Token: opts.Token,
|
|
RESTBase: opts.RESTBase,
|
|
GraphQLURL: opts.GraphQLURL,
|
|
UserAgent: opts.UserAgent,
|
|
})
|
|
}
|
|
logger := opts.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Provider{client: c, logger: logger}, nil
|
|
}
|
|
|
|
// SCMCredentialsAvailable checks whether this provider can obtain a token. The
|
|
// SCM observer calls it lazily during the first poll that has SCM subjects, so
|
|
// daemon readiness is not blocked by shelling out to gh auth token and idle
|
|
// daemons do not warn about missing credentials.
|
|
func (p *Provider) SCMCredentialsAvailable(ctx context.Context) (bool, error) {
|
|
if p.client == nil || p.client.tokens == nil {
|
|
return true, nil
|
|
}
|
|
if _, err := p.client.tokens.Token(ctx); err != nil {
|
|
if errors.Is(err, ErrNoToken) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Observe fetches the current state of one PR by its github.com URL and
|
|
// returns a normalized ports.PRObservation. Any required network call
|
|
// failing yields Fetched=false (caller must not infer "PR closed" from a
|
|
// failed observation).
|
|
func (p *Provider) Observe(ctx context.Context, prURL string) (ports.PRObservation, error) {
|
|
out := ports.PRObservation{URL: prURL}
|
|
owner, repo, number, err := parsePRURL(prURL)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.Number = number
|
|
|
|
rest, err := p.fetchRESTPull(ctx, owner, repo, number)
|
|
if err != nil {
|
|
// Network/auth/rate-limit failures must surface as Fetched:false.
|
|
// Stable terminal states like 404 also surface that way — the PR
|
|
// Manager keeps the prior row rather than fabricating closed/merged.
|
|
return out, scmObserveError(err)
|
|
}
|
|
|
|
out.Draft = rest.Draft
|
|
out.Merged = rest.Merged || (rest.MergedAt != "")
|
|
out.Closed = strings.EqualFold(rest.State, "closed") && !out.Merged
|
|
|
|
gq, err := p.fetchGraphQL(ctx, owner, repo, number)
|
|
if err != nil {
|
|
return out, scmObserveError(err)
|
|
}
|
|
|
|
out.CI = ciSummaryFromGraphQL(gq)
|
|
out.Review = reviewDecisionFromGraphQL(gq)
|
|
out.Mergeability = mergeabilityFromGraphQL(gq, rest, out.CI, out.Review)
|
|
out.Checks = checksFromGraphQL(gq, rest.Head.SHA)
|
|
out.Comments = commentsFromGraphQL(gq)
|
|
|
|
// Log-tail enrichment is best-effort: a job-log fetch failure must not
|
|
// flip the observation to Fetched:false, because we already have the
|
|
// authoritative CI summary from GraphQL. Stamp a one-liner instead.
|
|
for i := range out.Checks {
|
|
if !isFailingCheckStatus(out.Checks[i].Status) {
|
|
continue
|
|
}
|
|
jobID := jobIDForCheck(gq, out.Checks[i].Name)
|
|
if jobID == 0 {
|
|
continue
|
|
}
|
|
log, fetchErr := p.fetchJobLogTail(ctx, owner, repo, jobID)
|
|
if fetchErr != nil {
|
|
out.Checks[i].LogTail = fmt.Sprintf("<log fetch failed: %s>", scrubError(fetchErr))
|
|
continue
|
|
}
|
|
out.Checks[i].LogTail = tailLines(log, ciFailureLogTailLines)
|
|
}
|
|
|
|
out.Fetched = true
|
|
return out, nil
|
|
}
|
|
|
|
func scmObserveError(err error) error {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return fmt.Errorf("%w: %w", ports.ErrSCMPRNotFound, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REST: pull payload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type restPull struct {
|
|
State string `json:"state"`
|
|
Draft bool `json:"draft"`
|
|
Merged bool `json:"merged"`
|
|
MergedAt string `json:"merged_at"`
|
|
Head struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"head"`
|
|
Mergeable *bool `json:"mergeable"`
|
|
MergeableState string `json:"mergeable_state"`
|
|
}
|
|
|
|
func (p *Provider) fetchRESTPull(ctx context.Context, owner, repo string, number int) (restPull, error) {
|
|
resp, err := p.client.doREST(ctx, http.MethodGet, repoPath(owner, repo, "pulls", strconv.Itoa(number)), nil, nil)
|
|
if err != nil {
|
|
return restPull{}, err
|
|
}
|
|
if len(resp.Body) == 0 {
|
|
return restPull{}, errors.New("github scm: empty pull response")
|
|
}
|
|
var pull restPull
|
|
if err := json.Unmarshal(resp.Body, &pull); err != nil {
|
|
return restPull{}, fmt.Errorf("github scm: decode pull: %w", err)
|
|
}
|
|
return pull, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GraphQL: the heavy lift
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// graphQLCheckContextLimit caps how many statusCheckRollup contexts we
|
|
// request in one GraphQL hop. 100 is GitHub's documented per-page max
|
|
// for the contexts connection. When the rollup has MORE than this many
|
|
// contexts the response surfaces pageInfo.hasNextPage=true and
|
|
// ciSummaryFromGraphQL is conservative (see the "CIUnknown on
|
|
// hasNextPage when not already CIFailing" branch — a partial visible
|
|
// set could hide a failure, so we degrade the verdict rather than
|
|
// risk reporting a broken PR as passing).
|
|
const graphQLCheckContextLimit = 100
|
|
|
|
// prObservationQuery is the GraphQL query (derived from PR #28, credited
|
|
// to @whoisasx) that pulls everything we need in one round trip:
|
|
// - reviewDecision (APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / null)
|
|
// - mergeable + mergeStateStatus (DIRTY / BLOCKED / UNSTABLE / CLEAN / ...)
|
|
// - latest commit's statusCheckRollup (CheckRuns + StatusContexts) so we
|
|
// can derive a CIState without an extra REST hop, and so that bot vs
|
|
// human is detected via __typename on review comments.
|
|
const prObservationQuery = `query($owner:String!,$repo:String!,$number:Int!){
|
|
repository(owner:$owner,name:$repo){
|
|
pullRequest(number:$number){
|
|
number
|
|
url
|
|
state
|
|
isDraft
|
|
merged
|
|
closed
|
|
mergeable
|
|
mergeStateStatus
|
|
reviewDecision
|
|
headRefOid
|
|
commits(last:1){ nodes{ commit{
|
|
oid
|
|
statusCheckRollup{
|
|
state
|
|
contexts(first:CONTEXT_LIMIT){
|
|
nodes{
|
|
__typename
|
|
... on CheckRun { name status conclusion detailsUrl url databaseId }
|
|
... on StatusContext { context state targetUrl }
|
|
}
|
|
pageInfo{ hasNextPage }
|
|
}
|
|
}
|
|
} } }
|
|
reviewThreads(last:100){ nodes{
|
|
id
|
|
isResolved
|
|
comments(first:100){ nodes{
|
|
id
|
|
body
|
|
path
|
|
line
|
|
url
|
|
author{ login __typename }
|
|
} }
|
|
} }
|
|
}
|
|
}
|
|
}`
|
|
|
|
func (p *Provider) fetchGraphQL(ctx context.Context, owner, repo string, number int) (map[string]any, error) {
|
|
q := strings.Replace(prObservationQuery, "CONTEXT_LIMIT", strconv.Itoa(graphQLCheckContextLimit), 1)
|
|
data, err := p.client.doGraphQL(ctx, q, map[string]any{"owner": owner, "repo": repo, "number": number})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repoData, _ := data["repository"].(map[string]any)
|
|
pr, _ := repoData["pullRequest"].(map[string]any)
|
|
if pr == nil {
|
|
return nil, fmt.Errorf("%w: pull request not found in graphql response", ErrNotFound)
|
|
}
|
|
return pr, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REST: per-job log tail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *Provider) fetchJobLogTail(ctx context.Context, owner, repo string, jobID int64) (string, error) {
|
|
logPath := repoPath(owner, repo, "actions", "jobs", strconv.FormatInt(jobID, 10), "logs")
|
|
body, err := p.client.fetchPlainText(ctx, logPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(body), nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Projection helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ciSummaryFromGraphQL maps the per-PR status rollup onto domain.CIState.
|
|
// If ANY visible context concluded failure-class we return CIFailing.
|
|
// Otherwise any pending context wins over passing. An empty rollup is
|
|
// CIUnknown. When the rollup is paginated (pageInfo.hasNextPage=true)
|
|
// the verdict is conservative: a known failure is still safe — failures
|
|
// don't get un-failed by more pages — but passing/pending/unknown
|
|
// verdicts could hide a failing context on the next page, so we degrade
|
|
// them all to CIUnknown rather than risk reporting a broken PR as ready.
|
|
func ciSummaryFromGraphQL(pr map[string]any) domain.CIState {
|
|
roll := statusRollup(pr)
|
|
if roll == nil {
|
|
return domain.CIUnknown
|
|
}
|
|
contexts, _ := roll["contexts"].(map[string]any)
|
|
rawNodes := nodes(contexts["nodes"])
|
|
if len(rawNodes) == 0 {
|
|
// GitHub returns a top-level "state" on the rollup even when the
|
|
// nodes list is empty (e.g. SUCCESS / FAILURE / PENDING). Honor it
|
|
// rather than returning CIUnknown for an otherwise-decided PR.
|
|
return mapRollupState(str(roll["state"]))
|
|
}
|
|
pending, passing := false, false
|
|
for _, n := range rawNodes {
|
|
st := checkStatusFromGraphQL(n)
|
|
switch st {
|
|
case domain.PRCheckFailed, domain.PRCheckCancelled:
|
|
return domain.CIFailing
|
|
case domain.PRCheckQueued, domain.PRCheckInProgress:
|
|
pending = true
|
|
case domain.PRCheckPassed:
|
|
passing = true
|
|
}
|
|
}
|
|
if pageInfoHasMore(contexts) {
|
|
return domain.CIUnknown
|
|
}
|
|
switch {
|
|
case pending:
|
|
return domain.CIPending
|
|
case passing:
|
|
return domain.CIPassing
|
|
default:
|
|
return domain.CIUnknown
|
|
}
|
|
}
|
|
|
|
// pageInfoHasMore reports whether the rollup contexts have a next page
|
|
// the current request didn't fetch. We treat a missing pageInfo block
|
|
// as "no more" (older API shapes that don't expose pagination simply
|
|
// return everything in one page).
|
|
func pageInfoHasMore(contexts map[string]any) bool {
|
|
pi, ok := contexts["pageInfo"].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return boolv(pi["hasNextPage"])
|
|
}
|
|
|
|
func mapRollupState(s string) domain.CIState {
|
|
switch strings.ToUpper(strings.TrimSpace(s)) {
|
|
case "SUCCESS":
|
|
return domain.CIPassing
|
|
case "FAILURE", "ERROR":
|
|
return domain.CIFailing
|
|
case "PENDING", "EXPECTED":
|
|
return domain.CIPending
|
|
default:
|
|
return domain.CIUnknown
|
|
}
|
|
}
|
|
|
|
// reviewDecisionFromGraphQL normalizes the GraphQL reviewDecision enum
|
|
// onto the domain vocabulary. Re-implemented inline because the helper
|
|
// referenced in the task brief lived against types that no longer exist.
|
|
func reviewDecisionFromGraphQL(pr map[string]any) domain.ReviewDecision {
|
|
switch strings.ToUpper(strings.TrimSpace(str(pr["reviewDecision"]))) {
|
|
case "APPROVED":
|
|
return domain.ReviewApproved
|
|
case "CHANGES_REQUESTED":
|
|
return domain.ReviewChangesRequest
|
|
case "REVIEW_REQUIRED":
|
|
return domain.ReviewRequired
|
|
default:
|
|
return domain.ReviewNone
|
|
}
|
|
}
|
|
|
|
// mergeabilityFromGraphQL composes the merge verdict from three signals:
|
|
// the REST mergeable/rebaseable booleans, the GraphQL mergeStateStatus,
|
|
// and the already-derived CIState + ReviewDecision. The rules follow the
|
|
// spec table in doc.go.
|
|
func mergeabilityFromGraphQL(pr map[string]any, rest restPull, ci domain.CIState, review domain.ReviewDecision) domain.Mergeability {
|
|
// REST's mergeable_state is the tiebreaker: GraphQL's
|
|
// mergeStateStatus enum (DIRTY / BLOCKED / UNSTABLE / CLEAN /
|
|
// UNKNOWN) is the primary; if it is empty we fall back to the
|
|
// REST string (lowercase: "dirty" / "blocked" / "unstable" /
|
|
// "clean" / "behind" / "unknown") uppercased so the same switch
|
|
// covers both shapes. The REST API does NOT expose a
|
|
// `merge_state_status` field — earlier revs of this code chased
|
|
// that ghost; we use mergeable_state instead.
|
|
state := strings.ToUpper(strings.TrimSpace(firstNonEmpty(str(pr["mergeStateStatus"]), rest.MergeableState)))
|
|
rawMergeable := strings.ToUpper(strings.TrimSpace(str(pr["mergeable"])))
|
|
|
|
switch state {
|
|
case "DIRTY":
|
|
return domain.MergeConflicting
|
|
case "BLOCKED":
|
|
return domain.MergeBlocked
|
|
case "UNSTABLE":
|
|
return domain.MergeUnstable
|
|
}
|
|
if rawMergeable == "CONFLICTING" {
|
|
return domain.MergeConflicting
|
|
}
|
|
|
|
if rest.Draft || boolv(pr["isDraft"]) {
|
|
return domain.MergeBlocked
|
|
}
|
|
if review == domain.ReviewChangesRequest || review == domain.ReviewRequired {
|
|
return domain.MergeBlocked
|
|
}
|
|
if ci == domain.CIFailing {
|
|
return domain.MergeBlocked
|
|
}
|
|
|
|
// REST's mergeable_state ("clean" / "blocked" / "behind" / "dirty" / "unstable"
|
|
// / "draft" / "unknown") backs up the GraphQL view when GitHub hasn't
|
|
// computed the rollup yet.
|
|
switch strings.ToLower(strings.TrimSpace(rest.MergeableState)) {
|
|
case "clean":
|
|
if rawMergeable == "MERGEABLE" || (rest.Mergeable != nil && *rest.Mergeable) {
|
|
return domain.MergeMergeable
|
|
}
|
|
case "dirty":
|
|
return domain.MergeConflicting
|
|
case "blocked":
|
|
return domain.MergeBlocked
|
|
case "unstable":
|
|
return domain.MergeUnstable
|
|
}
|
|
|
|
if rawMergeable == "MERGEABLE" && state == "CLEAN" {
|
|
return domain.MergeMergeable
|
|
}
|
|
return domain.MergeUnknown
|
|
}
|
|
|
|
// checksFromGraphQL projects each context node into a PRCheckObservation.
|
|
// StatusContext (commit-status) and CheckRun (Actions) are both flattened
|
|
// into the same slice because downstream consumers don't distinguish.
|
|
func checksFromGraphQL(pr map[string]any, headSHA string) []ports.PRCheckObservation {
|
|
roll := statusRollup(pr)
|
|
contexts, _ := roll["contexts"].(map[string]any)
|
|
rawNodes := nodes(contexts["nodes"])
|
|
if len(rawNodes) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]ports.PRCheckObservation, 0, len(rawNodes))
|
|
for _, n := range rawNodes {
|
|
typ := str(n["__typename"])
|
|
var name, urlOut string
|
|
switch typ {
|
|
case "CheckRun":
|
|
name = str(n["name"])
|
|
urlOut = firstNonEmpty(str(n["detailsUrl"]), str(n["url"]))
|
|
case "StatusContext":
|
|
name = str(n["context"])
|
|
urlOut = str(n["targetUrl"])
|
|
default:
|
|
continue
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
out = append(out, ports.PRCheckObservation{
|
|
Name: name,
|
|
CommitHash: headSHA,
|
|
Status: checkStatusFromGraphQL(n),
|
|
URL: urlOut,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// commentsFromGraphQL flattens unresolved review threads into one comment
|
|
// per node, dropping bot authors entirely (the spec keeps Resolved=false
|
|
// always since we filter resolved threads out client-side).
|
|
func commentsFromGraphQL(pr map[string]any) []ports.PRCommentObservation {
|
|
threads, _ := pr["reviewThreads"].(map[string]any)
|
|
rawNodes := nodes(threads["nodes"])
|
|
if len(rawNodes) == 0 {
|
|
return nil
|
|
}
|
|
var out []ports.PRCommentObservation
|
|
for _, th := range rawNodes {
|
|
if boolv(th["isResolved"]) {
|
|
continue
|
|
}
|
|
comments, _ := th["comments"].(map[string]any)
|
|
for _, cn := range nodes(comments["nodes"]) {
|
|
author, _ := cn["author"].(map[string]any)
|
|
if isBotAuthor(author) {
|
|
continue
|
|
}
|
|
out = append(out, ports.PRCommentObservation{
|
|
ID: str(cn["id"]),
|
|
Author: str(author["login"]),
|
|
File: str(cn["path"]),
|
|
Line: int(num(cn["line"])),
|
|
Body: str(cn["body"]),
|
|
Resolved: false,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isBotAuthor uses ONLY GitHub's typed signal (__typename or User.Type
|
|
// === "Bot"). The strings.Contains(login, "bot") fallback from PR #28
|
|
// was deliberately dropped — aa-18 flagged it as a false-positive
|
|
// magnet (logins like "robothon", "lambot123" tripped it).
|
|
func isBotAuthor(author map[string]any) bool {
|
|
if strings.EqualFold(str(author["__typename"]), "Bot") {
|
|
return true
|
|
}
|
|
if strings.EqualFold(str(author["type"]), "Bot") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// jobIDForCheck looks up the Actions job ID for a check by name, so we
|
|
// can call /actions/jobs/{job_id}/logs. StatusContext rows have no job
|
|
// ID (they're commit statuses, not Actions runs); those return 0 and
|
|
// the log fetch is skipped for them.
|
|
func jobIDForCheck(pr map[string]any, name string) int64 {
|
|
roll := statusRollup(pr)
|
|
contexts, _ := roll["contexts"].(map[string]any)
|
|
for _, n := range nodes(contexts["nodes"]) {
|
|
if str(n["__typename"]) != "CheckRun" {
|
|
continue
|
|
}
|
|
if str(n["name"]) != name {
|
|
continue
|
|
}
|
|
return int64(num(n["databaseId"]))
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// statusRollup extracts the commits[0].commit.statusCheckRollup blob
|
|
// from the GraphQL pullRequest payload. Nil when the PR has no commits
|
|
// or GitHub hasn't computed the rollup yet.
|
|
func statusRollup(pr map[string]any) map[string]any {
|
|
commits, _ := pr["commits"].(map[string]any)
|
|
for _, n := range nodes(commits["nodes"]) {
|
|
commit, _ := n["commit"].(map[string]any)
|
|
roll, _ := commit["statusCheckRollup"].(map[string]any)
|
|
if roll != nil {
|
|
return roll
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkStatusFromGraphQL maps the (status, conclusion) tuple of one node
|
|
// onto the domain enum. Failure-class conclusions always win — pending
|
|
// status with a final conclusion of "failure" is still a failed check.
|
|
func checkStatusFromGraphQL(n map[string]any) domain.PRCheckStatus {
|
|
typ := str(n["__typename"])
|
|
if typ == "StatusContext" {
|
|
switch strings.ToUpper(strings.TrimSpace(str(n["state"]))) {
|
|
case "SUCCESS":
|
|
return domain.PRCheckPassed
|
|
case "FAILURE", "ERROR":
|
|
return domain.PRCheckFailed
|
|
case "PENDING", "EXPECTED":
|
|
return domain.PRCheckInProgress
|
|
default:
|
|
return domain.PRCheckUnknown
|
|
}
|
|
}
|
|
conclusion := strings.ToUpper(strings.TrimSpace(str(n["conclusion"])))
|
|
status := strings.ToUpper(strings.TrimSpace(str(n["status"])))
|
|
switch conclusion {
|
|
case "SUCCESS", "NEUTRAL":
|
|
return domain.PRCheckPassed
|
|
case "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE":
|
|
return domain.PRCheckFailed
|
|
case "CANCELLED":
|
|
return domain.PRCheckCancelled
|
|
case "SKIPPED", "STALE":
|
|
return domain.PRCheckSkipped
|
|
}
|
|
switch status {
|
|
case "QUEUED", "PENDING", "REQUESTED", "WAITING":
|
|
return domain.PRCheckQueued
|
|
case "IN_PROGRESS":
|
|
return domain.PRCheckInProgress
|
|
case "COMPLETED":
|
|
// Completed without a conclusion is unusual but reachable — treat
|
|
// it as unknown so the caller does not over-trust an absent state.
|
|
return domain.PRCheckUnknown
|
|
}
|
|
return domain.PRCheckUnknown
|
|
}
|
|
|
|
func isFailingCheckStatus(s domain.PRCheckStatus) bool {
|
|
return s == domain.PRCheckFailed || s == domain.PRCheckCancelled
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL + path helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// parsePRURL accepts both the canonical github.com web URL and the API
|
|
// pulls URL. Returns owner, repo, number or an error wrapping ErrNotFound
|
|
// for shapes we don't recognise (so the caller surfaces them like any
|
|
// other "PR isn't on GitHub" outcome).
|
|
func parsePRURL(prURL string) (string, string, int, error) {
|
|
if prURL == "" {
|
|
return "", "", 0, fmt.Errorf("%w: empty PR url", ErrNotFound)
|
|
}
|
|
u, err := url.Parse(prURL)
|
|
if err != nil {
|
|
return "", "", 0, fmt.Errorf("%w: parse url: %w", ErrNotFound, err)
|
|
}
|
|
host := strings.ToLower(u.Host)
|
|
// Accept github.com (web) and api.github.com (REST/GraphQL). GitHub
|
|
// Enterprise hosts must end in .github.com or .ghe.io (GitHub's own
|
|
// dedicated TLDs); anything else looks like a bad URL or a different
|
|
// SCM and is rejected.
|
|
switch {
|
|
case host == "":
|
|
// Allow path-only URLs (parsePRURL is also exercised via API
|
|
// paths without a host in some tests).
|
|
case host == "github.com", host == "www.github.com", host == "api.github.com":
|
|
// canonical
|
|
case strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io"):
|
|
// enterprise
|
|
default:
|
|
return "", "", 0, fmt.Errorf("%w: host %q is not a github host", ErrNotFound, host)
|
|
}
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
// Web form: /owner/repo/pull/123
|
|
if len(parts) >= 4 && (parts[2] == "pull" || parts[2] == "pulls") {
|
|
owner, repo := parts[0], parts[1]
|
|
n, err := strconv.Atoi(parts[3])
|
|
if err != nil || n <= 0 {
|
|
return "", "", 0, fmt.Errorf("%w: bad PR number %q", ErrNotFound, parts[3])
|
|
}
|
|
return owner, repo, n, nil
|
|
}
|
|
// API form: /repos/owner/repo/pulls/123
|
|
if len(parts) >= 5 && parts[0] == "repos" && parts[3] == "pulls" {
|
|
owner, repo := parts[1], parts[2]
|
|
n, err := strconv.Atoi(parts[4])
|
|
if err != nil || n <= 0 {
|
|
return "", "", 0, fmt.Errorf("%w: bad PR number %q", ErrNotFound, parts[4])
|
|
}
|
|
return owner, repo, n, nil
|
|
}
|
|
return "", "", 0, fmt.Errorf("%w: not a github PR url: %s", ErrNotFound, prURL)
|
|
}
|
|
|
|
func repoPath(owner, repo string, elems ...string) string {
|
|
all := append([]string{"repos", owner, repo}, elems...)
|
|
for i := range all {
|
|
all[i] = url.PathEscape(all[i])
|
|
}
|
|
return "/" + path.Join(all...)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Small JSON-ish accessors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func nodes(v any) []map[string]any {
|
|
a, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]map[string]any, 0, len(a))
|
|
for _, item := range a {
|
|
if m, ok := item.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func str(v any) string {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func boolv(v any) bool {
|
|
if b, ok := v.(bool); ok {
|
|
return b
|
|
}
|
|
return false
|
|
}
|
|
|
|
func num(v any) float64 {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return t
|
|
case int:
|
|
return float64(t)
|
|
case int64:
|
|
return float64(t)
|
|
case json.Number:
|
|
f, _ := t.Float64()
|
|
return f
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(a, b string) string {
|
|
if strings.TrimSpace(a) != "" {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func tailLines(s string, n int) string {
|
|
s = strings.ReplaceAll(strings.TrimSpace(s), "\r\n", "\n")
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) > n {
|
|
lines = lines[len(lines)-n:]
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// scrubError keeps the error message single-line so the LogTail field
|
|
// stays a tidy one-liner instead of leaking multi-line API payloads
|
|
// into the PR row.
|
|
func scrubError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
msg := err.Error()
|
|
msg = strings.ReplaceAll(msg, "\n", " ")
|
|
msg = strings.ReplaceAll(msg, "\r", " ")
|
|
return strings.TrimSpace(msg)
|
|
}
|