Files
kandev/apps/backend/internal/github/errors.go
T
2026-06-15 18:54:16 +01:00

102 lines
4.9 KiB
Go

package github
import (
"errors"
"strings"
)
// ErrInvalidPRURL signals that a caller-supplied PR URL could not be parsed.
// Used by AssociateExistingPRByURL so HTTP callers can translate the failure
// into a 400 instead of a generic 500.
var ErrInvalidPRURL = errors.New("invalid PR URL")
// ErrTaskNotFound is the sentinel that cleanup paths check to distinguish
// "the task is already gone — fine, mop up the dedup row" from a real
// upstream failure. Adapter implementations of TaskDeleter wrap this when
// the task domain reports a missing row so the github layer can recognize
// the case without string-matching the underlying error message.
var ErrTaskNotFound = errors.New("github: task not found for cleanup")
// ErrSelfApprove is returned by SubmitReview when the authenticated user
// attempts to APPROVE their own PR. GitHub rejects this with a 422; we
// catch it server-side so the UI sees a clean, typed error rather than a
// generic upstream failure when the frontend's visibility guard is bypassed.
var ErrSelfApprove = errors.New("cannot approve your own pull request")
// ErrInvalidToken is returned by ConfigureToken when the supplied PAT fails
// validation against the GitHub API. Wrapped around the underlying client
// error so HTTP callers can distinguish a validation failure (HTTP 400)
// from a secret-store write failure (HTTP 500).
var ErrInvalidToken = errors.New("invalid token")
// ErrRepoNotResolvable signals that GitHub reported the repository as
// missing, deleted, or inaccessible to the authenticated principal. The PR
// watch flood storm this fixes (SyncWatchesBatched hammering the same dead
// repo on every 5s frontend poll) classifies upstream errors via
// isRepoNotResolvableErr and feeds the result into a 10-minute negative
// cache so subsequent watch syncs short-circuit before acquiring the gh
// throttle. The cache is also flushed on token swap/clear (see
// Service.ClearRepoErrorCache wired from ConfigureToken/ClearToken) so a
// re-auth doesn't have to wait out the TTL. Use errors.Is(err,
// ErrRepoNotResolvable) at boundaries (WS handler, poller) where the
// caller needs to differentiate a "stop retrying" failure from a
// transient upstream blip.
var ErrRepoNotResolvable = errors.New("github: repository not resolvable")
// errStoreUnavailable is returned from service methods when no Store is
// wired (Provide can return a Service with store == nil when the SQLite
// repos aren't configured). Returning a typed error instead of nil-
// dereferencing keeps the watch reset handlers from panicking under that
// degenerate config — the caller surfaces a 5xx, the process keeps running.
var errStoreUnavailable = errors.New("github: store not configured")
// repoNotResolvableSubstrings matches the wire-format substrings GitHub's
// GraphQL/REST APIs use for a deterministic "repository does not exist /
// not accessible" outcome:
// - "Could not resolve to a Repository" — GraphQL response for a missing
// or deleted repo (the dominant signal in the storm logs)
// - "Resource not accessible by integration" — GitHub Apps / fine-grained
// PAT scope mismatch. Treated as "dead end" here because retrying the
// same call with the same token will never succeed; a token swap
// evicts the cache via ClearRepoErrorCache.
//
// Raw "HTTP 404 / Not Found" is intentionally NOT matched here. A 404 on
// /repos/{owner}/{repo}/pulls/{N} can legitimately mean "the PR was
// deleted" while the repo itself is fine — a false-positive there would
// negative-cache the whole repo for 10 minutes off a single stale PR
// number. The GraphQL "Could not resolve to a Repository" string IS
// repo-scoped by construction and remains the canonical signal.
var repoNotResolvableSubstrings = []string{
"could not resolve to a repository",
"resource not accessible by integration",
}
// isRepoNotResolvableErr reports whether err carries a deterministic
// "repository missing / inaccessible" signal we should not keep retrying
// against in a tight loop. Errors flagged here are negative-cached for
// 10 minutes via Service.repoErrorCache; the cache is evicted on watch
// (re)creation and on token swap/clear so a freshly-linked repo or a
// re-auth is probed immediately. The match is case-insensitive so we
// don't depend on a stable error casing across gh CLI versions and
// GraphQL response variants.
//
// Bare HTTP 404 errors are NOT classified — too ambiguous (missing PR
// number vs missing repo) and a false positive poisons the whole repo
// for 10 minutes. Callers that know they're hitting a repo-root
// endpoint should wrap with ErrRepoNotResolvable explicitly.
func isRepoNotResolvableErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrRepoNotResolvable) {
return true
}
s := strings.ToLower(err.Error())
for _, sub := range repoNotResolvableSubstrings {
if strings.Contains(s, sub) {
return true
}
}
return false
}