agent-orchestrator/backend/internal/adapters/scm/github/provider.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)
}