Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions go/internal/forge/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,13 +309,15 @@ func (r ghComment) toComment() Comment {
// the fields forge.PullRequest needs at create time are decoded; the read-side
// roll-ups (Changed/Checks/Reviews/Threads) are GetPullRequest's (ghPullDetail).
type ghPull struct {
Number uint64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Head struct {
Number uint64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Head struct {
Ref string `json:"ref"`
} `json:"head"`
Base struct {
Expand All @@ -339,9 +341,20 @@ func (r ghPull) toPullRequest() PullRequest {
BaseRef: r.Base.Ref,
ForgeAccount: r.User.Login,
Draft: r.Draft,
CreatedAt: parseGHTime(r.CreatedAt),
UpdatedAt: parseGHTime(r.UpdatedAt),
}
}

// parseGHTime parses a GitHub RFC-3339 timestamp; empty or malformed is the zero time.
func parseGHTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}

// CreateIssue creates an issue on repo. in.Body is PRE-stamped by the Service
// (DL-050); the Provider sends it verbatim. Returns the created forge.Issue.
func (g *GitHub) CreateIssue(ctx context.Context, repo string, in CreateIssue) (Issue, error) {
Expand Down Expand Up @@ -564,6 +577,8 @@ type ghPullDetail struct {
Deletions uint32 `json:"deletions"`
ChangedFiles uint32 `json:"changed_files"`
Merged bool `json:"merged"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Head struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
Expand Down Expand Up @@ -600,6 +615,8 @@ func (r ghPullDetail) toPullRequest() PullRequest {
Additions: r.Additions,
Deletions: r.Deletions,
},
CreatedAt: parseGHTime(r.CreatedAt),
UpdatedAt: parseGHTime(r.UpdatedAt),
}
}

Expand Down Expand Up @@ -646,12 +663,13 @@ func (g *GitHub) GetPullRequest(ctx context.Context, repo string, number uint64)
})
}

checks, threads, err := g.checksForPull(ctx, coord, detail.Head.SHA, true)
checks, gql, err := g.checksForPull(ctx, coord, detail.Head.SHA, true)
if err != nil {
return PullRequest{}, fmt.Errorf("forge: github get pull request %q#%d: %w", repo, number, err)
}
pr.Checks = checks
pr.Threads = threads
pr.Threads = gql.threads
pr.ClosingRefs = gql.closingRefs

return pr, nil
}
Expand Down Expand Up @@ -1021,12 +1039,6 @@ func (r ghIssue) toIssue() Issue {
for _, l := range r.Labels {
labels = append(labels, l.Name)
}
var updated time.Time
if r.UpdatedAt != "" {
if t, err := time.Parse(time.RFC3339, r.UpdatedAt); err == nil {
updated = t
}
}
return Issue{
Number: r.Number,
Title: r.Title,
Expand All @@ -1035,7 +1047,7 @@ func (r ghIssue) toIssue() Issue {
URL: r.HTMLURL,
ForgeAccount: r.User.Login,
Labels: labels,
UpdatedAt: updated,
UpdatedAt: parseGHTime(r.UpdatedAt),
}
}

Expand Down
98 changes: 74 additions & 24 deletions go/internal/forge/github_graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"net/http"
"strings"
Expand Down Expand Up @@ -172,12 +173,29 @@ type ghGQLCommitNode struct {
} `json:"commit"`
}

// ghGQLClosingRef is one issue the PR closes.
type ghGQLClosingRef struct {
Number uint64 `json:"number"`
Repository struct {
NameWithOwner string `json:"nameWithOwner"`
} `json:"repository"`
}

// ghGQLClosingRefs is the issues the PR closes (closingIssuesReferences).
type ghGQLClosingRefs struct {
Nodes []ghGQLClosingRef `json:"nodes"`
}

// ghGQLPull is the pull request half; each connection is absent when excluded.
type ghGQLPull struct {
ReviewThreads *ghGQLThreads `json:"reviewThreads"`
Commits *ghGQLPullCommits `json:"commits"`
ClosingRefs *ghGQLClosingRefs `json:"closingIssuesReferences"`
}

// closingRefsCap is the closingIssuesReferences page size; a PR closing more is truncated.
const closingRefsCap = 25

// ghGQLRepo is the repository root of pullReadQuery.
type ghGQLRepo struct {
PullRequest *ghGQLPull `json:"pullRequest"`
Expand All @@ -199,9 +217,12 @@ type ghGQLThreadNode struct {
// @include flags let a later page fetch only the connection still paging. The
// contexts are read through the pull request (commits(last: 1)), so only Pull
// requests: read is needed; the commit oid pins each page to the REST head SHA.
const pullReadQuery = `query($owner: String!, $name: String!, $number: Int!, $threads: Boolean!, $threadsAfter: String, $contexts: Boolean!, $contextsAfter: String) {
const pullReadQuery = `query($owner: String!, $name: String!, $number: Int!, $threads: Boolean!, $threadsAfter: String, $contexts: Boolean!, $contextsAfter: String, $refs: Boolean!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
closingIssuesReferences(first: 25) @include(if: $refs) {
nodes { number repository { nameWithOwner } }
}
reviewThreads(first: 100, after: $threadsAfter) @include(if: $threads) {
pageInfo { hasNextPage endCursor }
nodes {
Expand Down Expand Up @@ -256,74 +277,103 @@ func (g *GitHub) graphQLURL() string {
return "https://" + g.host + "/api/graphql"
}

// pullReadExtras is what a full GraphQL read adds beyond checks.
type pullReadExtras struct {
threads []ReviewThread
closingRefs []IssueRef
}

// checksForPull is the one checks path for a pull request (GetPullRequest and
// Checks): the REST roll-up from checksForSHA, with Required set from the PR's
// required contexts. withThreads also returns the review threads from the same
// GraphQL leg, so GetPullRequest pays one leg, not two.
func (g *GitHub) checksForPull(ctx context.Context, c pullCoord, sha string, withThreads bool) (Checks, []ReviewThread, error) {
// required contexts. full also returns the review threads and closing references
// from the same GraphQL leg, so GetPullRequest pays one leg, not two.
func (g *GitHub) checksForPull(ctx context.Context, c pullCoord, sha string, full bool) (Checks, pullReadExtras, error) {
checks, err := g.checksForSHA(ctx, c.repo, sha)
if err != nil {
return Checks{}, nil, err
return Checks{}, pullReadExtras{}, err
}
threads, required, err := g.pullGraphQL(ctx, c, sha, withThreads)
w, err := g.pullGraphQL(ctx, c, sha, full)
if err != nil {
return Checks{}, nil, fmt.Errorf("forge: github graphql for %q#%d: %w", c.repo, c.number, err)
return Checks{}, pullReadExtras{}, fmt.Errorf("forge: github graphql for %q#%d: %w", c.repo, c.number, err)
}
for i := range checks.Checks {
if _, ok := required[checks.Checks[i].Name]; ok {
if _, ok := w.required[checks.Checks[i].Name]; ok {
checks.Checks[i].Required = true
}
}
return checks, threads, nil
return checks, pullReadExtras{threads: w.threads, closingRefs: w.closingRefs}, nil
}

// pullGraphQLWalk is the cursor state of one pullGraphQL walk. A connection
// that has finished drops out of later queries through its @include flag.
type pullGraphQLWalk struct {
sha string // REST head SHA every contexts page must match
threads []ReviewThread
closingRefs []IssueRef
required map[string]struct{}
threadsAfter, contextsAfter any // nil sends JSON null: the first page
moreThreads, moreContexts bool
refs bool // read closing references; first page only
}

// pullGraphQL walks pullReadQuery to completion: every review-thread page (when
// withThreads) and every context page of head commit sha. It returns the threads
// in forge order and the set of required context names (a CheckRun's name, a
// StatusContext's context), which match the REST check names.
func (g *GitHub) pullGraphQL(ctx context.Context, c pullCoord, sha string, withThreads bool) ([]ReviewThread, map[string]struct{}, error) {
w := pullGraphQLWalk{sha: sha, required: map[string]struct{}{}, moreThreads: withThreads, moreContexts: true}
// pullGraphQL walks pullReadQuery to completion: every review-thread page and
// the closing references (when full) and every context page of head commit sha.
// The walk holds the threads in forge order and the set of required context
// names (a CheckRun's name, a StatusContext's context), which match REST's.
func (g *GitHub) pullGraphQL(ctx context.Context, c pullCoord, sha string, full bool) (*pullGraphQLWalk, error) {
w := &pullGraphQLWalk{sha: sha, required: map[string]struct{}{}, moreThreads: full, moreContexts: true, refs: full}
for w.moreThreads || w.moreContexts {
if err := ctx.Err(); err != nil {
return nil, nil, err
return nil, err
}
data, err := graphQL[ghGQLPullData](ctx, g, pullReadQuery, map[string]any{
"owner": c.owner, "name": c.name, "number": c.number,
"threads": w.moreThreads, "threadsAfter": w.threadsAfter,
"contexts": w.moreContexts, "contextsAfter": w.contextsAfter,
"refs": w.refs,
})
if err != nil {
return nil, nil, err
return nil, err
}
if data.Repository == nil {
return nil, nil, fmt.Errorf("forge: github graphql: repository %q not found", c.repo)
return nil, fmt.Errorf("forge: github graphql: repository %q not found", c.repo)
}
pr := data.Repository.PullRequest
if pr == nil {
return nil, nil, fmt.Errorf("forge: github graphql: pull request %q#%d not found", c.repo, c.number)
return nil, fmt.Errorf("forge: github graphql: pull request %q#%d not found", c.repo, c.number)
}
if w.refs {
if err := foldClosingRefs(ctx, w, c, pr.ClosingRefs); err != nil {
return nil, err
}
}
if w.moreThreads {
if err := g.foldThreads(ctx, &w, c, pr.ReviewThreads); err != nil {
return nil, nil, err
if err := g.foldThreads(ctx, w, c, pr.ReviewThreads); err != nil {
return nil, err
}
}
if w.moreContexts {
if err := foldContexts(&w, c, pr.Commits); err != nil {
return nil, nil, err
if err := foldContexts(w, c, pr.Commits); err != nil {
return nil, err
}
}
}
return w.threads, w.required, nil
return w, nil
}

// foldClosingRefs records the PR's closing references and turns them off for later pages.
func foldClosingRefs(ctx context.Context, w *pullGraphQLWalk, c pullCoord, conn *ghGQLClosingRefs) error {
if conn == nil {
return fmt.Errorf("forge: github graphql: pull request %q#%d has no closing references connection", c.repo, c.number)
}
w.refs = false
for _, n := range conn.Nodes {
w.closingRefs = append(w.closingRefs, IssueRef{Repo: n.Repository.NameWithOwner, Number: n.Number})
}
if len(conn.Nodes) >= closingRefsCap {
slog.WarnContext(ctx, "forge: github closing references truncated", "repo", c.repo, "number", c.number, "cap", closingRefsCap)
}
return nil
}

// foldThreads appends one page of review threads to w and advances its cursor.
Expand Down
7 changes: 6 additions & 1 deletion go/internal/forge/github_graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func noRollupGraphQL(sha string) string {
// status-check rollup on the last commit, head sha.
func emptyPullGraphQL(sha string) string {
return `{"data":{"repository":{"pullRequest":{
"closingIssuesReferences":{"nodes":[]},
"reviewThreads":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[]},
"commits":{"nodes":[{"commit":{"oid":"` + sha + `","statusCheckRollup":null}}]}}}}}`
}
Expand All @@ -48,9 +49,10 @@ func gqlBody(t *testing.T, data any) string {

// pullPage builds one pullReadQuery page. A nil threads or contexts leaves that
// connection out, as @include(if: false) does; a nil rollup is a commit with no checks.
// Closing refs ride every page here; the walk reads them from the first only.
func pullPage(t *testing.T, threads *ghGQLThreads, commits *ghGQLPullCommits) string {
t.Helper()
return gqlBody(t, ghGQLPullData{Repository: &ghGQLRepo{PullRequest: &ghGQLPull{ReviewThreads: threads, Commits: commits}}})
return gqlBody(t, ghGQLPullData{Repository: &ghGQLRepo{PullRequest: &ghGQLPull{ReviewThreads: threads, Commits: commits, ClosingRefs: &ghGQLClosingRefs{}}}})
}

// threadsConn is one page of review threads.
Expand Down Expand Up @@ -293,6 +295,9 @@ func TestPullGraphQLErrorBranches(t *testing.T) {
{"null pull request", []scriptedResponse{ok200(`{"data":{"repository":{"pullRequest":null}}}`)}, "pull request"},
{"no threads connection", []scriptedResponse{ok200(pullPage(t, nil, rollup(headSHA, false, "c")))}, "review threads connection"},
{"no commits connection", []scriptedResponse{ok200(pullPage(t, threadsConn(false, "t"), nil))}, "commits connection"},
{"no closing references connection", []scriptedResponse{ok200(gqlBody(t, ghGQLPullData{Repository: &ghGQLRepo{PullRequest: &ghGQLPull{
ReviewThreads: threadsConn(false, "t"), Commits: rollup(headSHA, false, "c"),
}}}))}, "closing references connection"},
{"next page without a cursor", []scriptedResponse{ok200(pullPage(t, threadsConn(true, ""), rollup(headSHA, false, "c")))}, "without an endCursor"},
{"repeated cursor", []scriptedResponse{
ok200(pullPage(t, threadsConn(true, "SAME"), rollup(headSHA, false, "c"))),
Expand Down
2 changes: 1 addition & 1 deletion go/internal/forge/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1425,7 +1425,7 @@ func TestGetIssueBudgetGateFailFast(t *testing.T) {
// with a human and a bot comment, an unresolved PR-level thread, and one
// required check run beside a non-required legacy status.
const happyPullGraphQL = `{"data":{"repository":{
"pullRequest":{"reviewThreads":{
"pullRequest":{"closingIssuesReferences":{"nodes":[]},"reviewThreads":{
"pageInfo":{"hasNextPage":false,"endCursor":"t1"},
"nodes":[
{"id":"T1","isResolved":true,"path":"main.go","comments":{
Expand Down
9 changes: 8 additions & 1 deletion go/internal/forge/golden_capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
canonURL = "https://example.invalid/canonical" // html_url, url, target_url -> URL
canonIDString = "canonical-id" // a string/UUID id (Linear) -> ID / resolve coordinate
canonUpdatedAt = "2026-08-01T12:30:00Z" // updated_at, updatedAt -> UpdatedAt
canonCreatedAt = "2026-08-01T12:00:00Z" // created_at -> CreatedAt
canonSHA = "canonicalsha" // sha -> HeadSHA
canonRef = "canonical-ref" // ref -> HeadRef/BaseRef
canonTitle = "canonical title" // title -> Title
Expand Down Expand Up @@ -71,6 +72,7 @@ var volatileFields = map[string]struct{}{
"ID": {},
"URL": {},
"UpdatedAt": {},
"CreatedAt": {},
"HeadSHA": {},
"HeadRef": {},
"BaseRef": {},
Expand All @@ -94,6 +96,7 @@ var wireVolatile = map[string]func(node any) any{
"target_url": fixedSentinel(canonURL),
"updated_at": fixedSentinel(canonUpdatedAt),
"updatedAt": fixedSentinel(canonUpdatedAt),
"created_at": fixedSentinel(canonCreatedAt),
"login": fixedSentinel(canonAccount),
"displayName": fixedSentinel(canonAccount),
"sha": fixedSentinel(canonSHA),
Expand Down Expand Up @@ -134,6 +137,7 @@ func canonCursor(node any) any {
// - ID <- id (ghComment.ID numeric; Linear comment id is a UUID string)
// - URL <- html_url,url,target_url (GitHub HTMLURL + ghStatus.TargetURL -> Check.URL; Linear URL)
// - UpdatedAt<- updated_at,updatedAt (GitHub updated_at; Linear updatedAt)
// - CreatedAt<- created_at (ghPull/ghPullDetail.CreatedAt)
// - HeadSHA <- sha (ghPullDetail.Head.SHA)
// - HeadRef <- ref (ghPull/ghPullDetail.Head.Ref)
// - BaseRef <- ref (ghPull/ghPullDetail.Base.Ref) — same wire key as HeadRef
Expand All @@ -147,6 +151,7 @@ var domainToWire = map[string][]string{
"ID": {"id"},
"URL": {"html_url", "url", "target_url"},
"UpdatedAt": {"updated_at", "updatedAt"},
"CreatedAt": {"created_at"},
"HeadSHA": {"sha"},
"HeadRef": {"ref"},
"BaseRef": {"ref"},
Expand Down Expand Up @@ -426,7 +431,7 @@ func TestUpdateCanonicalizeStable(t *testing.T) {
// (1) Completeness across every wire-volatile key.
all := json.RawMessage(`{
"number": 1, "id": 2, "html_url": "h", "url": "u", "target_url": "t",
"updated_at": "a", "updatedAt": "b", "login": "l", "displayName": "d",
"updated_at": "a", "updatedAt": "b", "created_at": "c", "login": "l", "displayName": "d",
"sha": "s", "oid": "o", "ref": "r", "title": "ti", "body": "bo", "description": "de",
"endCursor": "Y3Vyc29y", "last": { "endCursor": null },
"state": "open", "keep": "kept"
Expand All @@ -435,6 +440,7 @@ func TestUpdateCanonicalizeStable(t *testing.T) {
"number": 42, "id": 42, "html_url": "https://example.invalid/canonical",
"url": "https://example.invalid/canonical", "target_url": "https://example.invalid/canonical",
"updated_at": "2026-08-01T12:30:00Z", "updatedAt": "2026-08-01T12:30:00Z",
"created_at": "2026-08-01T12:00:00Z",
"login": "octocat", "displayName": "octocat", "sha": "canonicalsha", "oid": "canonicalsha",
"ref": "canonical-ref", "title": "canonical title", "body": "canonical body",
"description": "canonical body", "endCursor": "canonical-cursor", "last": { "endCursor": null },
Expand Down Expand Up @@ -575,6 +581,7 @@ func TestUpdateCanonicalizeComposite(t *testing.T) {
// extra leg 4: GraphQL threads + required contexts — a bot comment login
// and body inside nested arrays, a thread node id, and paging cursors.
{status: 200, body: json.RawMessage(`{ "data": { "repository": { "pullRequest": {
"closingIssuesReferences": { "nodes": [] },
"reviewThreads": { "pageInfo": { "hasNextPage": false, "endCursor": "live-cursor" }, "nodes": [
{ "id": "PRRT_live", "isResolved": true, "path": "main.go", "comments": {
"pageInfo": { "hasNextPage": false, "endCursor": "live-c" },
Expand Down
Loading
Loading