The subIssues, blockedBy, and blocking JSON output is currently shaped
as a flat array, which silently truncates when there are more entries
than the GraphQL fragment fetches. That's misleading once a user
crosses the page size, so this switches each connection to a
{nodes, totalCount} object so consumers can see when there's more.
While confirming page sizes, the GitHub limits turn out to be:
- sub-issues: up to 100 per parent
https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/adding-sub-issues
- blocked-by / blocking: up to 50 per relationship type
https://github.blog/changelog/2025-08-21-dependencies-on-issues/
So subIssues moves to first:100 to fetch the full set; blockedBy and
blocking stay at first:50, which already covers their cap.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
607 lines
13 KiB
Go
607 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/cli/cli/v2/pkg/set"
|
|
)
|
|
|
|
func squeeze(r rune) rune {
|
|
switch r {
|
|
case '\n', '\t':
|
|
return -1
|
|
default:
|
|
return r
|
|
}
|
|
}
|
|
|
|
func shortenQuery(q string) string {
|
|
return strings.Map(squeeze, q)
|
|
}
|
|
|
|
var assignedActors = shortenQuery(`
|
|
assignedActors(first: 10) {
|
|
nodes {
|
|
...on User {
|
|
id,
|
|
login,
|
|
name,
|
|
__typename
|
|
}
|
|
...on Bot {
|
|
id,
|
|
login,
|
|
__typename
|
|
}
|
|
},
|
|
totalCount
|
|
}
|
|
`)
|
|
|
|
var issueComments = shortenQuery(`
|
|
comments(first: 100) {
|
|
nodes {
|
|
id,
|
|
author{login,...on User{id,name}},
|
|
authorAssociation,
|
|
body,
|
|
createdAt,
|
|
includesCreatedEdit,
|
|
isMinimized,
|
|
minimizedReason,
|
|
reactionGroups{content,users{totalCount}},
|
|
url,
|
|
viewerDidAuthor
|
|
},
|
|
pageInfo{hasNextPage,endCursor},
|
|
totalCount
|
|
}
|
|
`)
|
|
|
|
var issueCommentLast = shortenQuery(`
|
|
comments(last: 1) {
|
|
nodes {
|
|
author{login,...on User{id,name}},
|
|
authorAssociation,
|
|
body,
|
|
createdAt,
|
|
includesCreatedEdit,
|
|
isMinimized,
|
|
minimizedReason,
|
|
reactionGroups{content,users{totalCount}}
|
|
},
|
|
totalCount
|
|
}
|
|
`)
|
|
|
|
var issueClosedByPullRequestsReferences = shortenQuery(`
|
|
closedByPullRequestsReferences(first: 100) {
|
|
nodes {
|
|
id,
|
|
number,
|
|
url,
|
|
repository {
|
|
id,
|
|
name,
|
|
owner {
|
|
id,
|
|
login
|
|
}
|
|
}
|
|
}
|
|
pageInfo{hasNextPage,endCursor}
|
|
}
|
|
`)
|
|
|
|
// prReviewRequests includes ...on Bot to support Copilot as a reviewer on github.com.
|
|
// On GHES, Bot is not part of the RequestedReviewer union, but the fragment is
|
|
// silently ignored (verified on GHES 3.19).
|
|
var prReviewRequests = shortenQuery(`
|
|
reviewRequests(first: 100) {
|
|
nodes {
|
|
requestedReviewer {
|
|
__typename,
|
|
...on User{login,name},
|
|
...on Bot{login},
|
|
...on Team{
|
|
organization{login}
|
|
name,
|
|
slug
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`)
|
|
|
|
var prReviews = shortenQuery(`
|
|
reviews(first: 100) {
|
|
nodes {
|
|
id,
|
|
author{login},
|
|
authorAssociation,
|
|
submittedAt,
|
|
body,
|
|
state,
|
|
commit{oid},
|
|
reactionGroups{content,users{totalCount}}
|
|
}
|
|
pageInfo{hasNextPage,endCursor}
|
|
totalCount
|
|
}
|
|
`)
|
|
|
|
var prLatestReviews = shortenQuery(`
|
|
latestReviews(first: 100) {
|
|
nodes {
|
|
author{login},
|
|
authorAssociation,
|
|
submittedAt,
|
|
body,
|
|
state
|
|
}
|
|
}
|
|
`)
|
|
|
|
var prFiles = shortenQuery(`
|
|
files(first: 100) {
|
|
nodes {
|
|
additions,
|
|
deletions,
|
|
path,
|
|
changeType
|
|
}
|
|
}
|
|
`)
|
|
|
|
var prCommits = shortenQuery(`
|
|
commits(first: 100) {
|
|
nodes {
|
|
commit {
|
|
authors(first:100) {
|
|
nodes {
|
|
name,
|
|
email,
|
|
user{id,login}
|
|
}
|
|
},
|
|
messageHeadline,
|
|
messageBody,
|
|
oid,
|
|
committedDate,
|
|
authoredDate
|
|
}
|
|
}
|
|
}
|
|
`)
|
|
|
|
var prClosingIssuesReferences = shortenQuery(`
|
|
closingIssuesReferences(first: 100) {
|
|
nodes {
|
|
id,
|
|
number,
|
|
url,
|
|
repository {
|
|
id,
|
|
name,
|
|
owner {
|
|
id,
|
|
login
|
|
}
|
|
}
|
|
}
|
|
pageInfo{hasNextPage,endCursor}
|
|
}
|
|
`)
|
|
|
|
var autoMergeRequest = shortenQuery(`
|
|
autoMergeRequest {
|
|
authorEmail,
|
|
commitBody,
|
|
commitHeadline,
|
|
mergeMethod,
|
|
enabledAt,
|
|
enabledBy{login,...on User{id,name}}
|
|
}
|
|
`)
|
|
|
|
func StatusCheckRollupGraphQLWithCountByState() string {
|
|
return shortenQuery(`
|
|
statusCheckRollup: commits(last: 1) {
|
|
nodes {
|
|
commit {
|
|
statusCheckRollup {
|
|
contexts {
|
|
checkRunCount,
|
|
checkRunCountsByState {
|
|
state,
|
|
count
|
|
},
|
|
statusContextCount,
|
|
statusContextCountsByState {
|
|
state,
|
|
count
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
}
|
|
|
|
func StatusCheckRollupGraphQLWithoutCountByState(after string) string {
|
|
var afterClause string
|
|
if after != "" {
|
|
afterClause = ",after:" + after
|
|
}
|
|
return fmt.Sprintf(shortenQuery(`
|
|
statusCheckRollup: commits(last: 1) {
|
|
nodes {
|
|
commit {
|
|
statusCheckRollup {
|
|
contexts(first:100%s) {
|
|
nodes {
|
|
__typename
|
|
...on StatusContext {
|
|
context,
|
|
state,
|
|
targetUrl,
|
|
createdAt,
|
|
description
|
|
},
|
|
...on CheckRun {
|
|
name,
|
|
checkSuite{workflowRun{workflow{name}}},
|
|
status,
|
|
conclusion,
|
|
startedAt,
|
|
completedAt,
|
|
detailsUrl
|
|
}
|
|
},
|
|
pageInfo{hasNextPage,endCursor}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`), afterClause)
|
|
}
|
|
|
|
func RequiredStatusCheckRollupGraphQL(prID, after string, includeEvent bool) string {
|
|
var afterClause string
|
|
if after != "" {
|
|
afterClause = ",after:" + after
|
|
}
|
|
eventField := "event,"
|
|
if !includeEvent {
|
|
eventField = ""
|
|
}
|
|
return fmt.Sprintf(shortenQuery(`
|
|
statusCheckRollup: commits(last: 1) {
|
|
nodes {
|
|
commit {
|
|
statusCheckRollup {
|
|
contexts(first:100%[1]s) {
|
|
nodes {
|
|
__typename
|
|
...on StatusContext {
|
|
context,
|
|
state,
|
|
targetUrl,
|
|
createdAt,
|
|
description,
|
|
isRequired(pullRequestId: %[2]s)
|
|
},
|
|
...on CheckRun {
|
|
name,
|
|
checkSuite{workflowRun{%[3]sworkflow{name}}},
|
|
status,
|
|
conclusion,
|
|
startedAt,
|
|
completedAt,
|
|
detailsUrl,
|
|
isRequired(pullRequestId: %[2]s)
|
|
}
|
|
},
|
|
pageInfo{hasNextPage,endCursor}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`), afterClause, prID, eventField)
|
|
}
|
|
|
|
var sharedIssuePRFields = []string{
|
|
"assignees",
|
|
"author",
|
|
"body",
|
|
"closed",
|
|
"comments",
|
|
"createdAt",
|
|
"closedAt",
|
|
"id",
|
|
"labels",
|
|
"milestone",
|
|
"number",
|
|
"projectCards",
|
|
"projectItems",
|
|
"reactionGroups",
|
|
"state",
|
|
"title",
|
|
"updatedAt",
|
|
"url",
|
|
}
|
|
|
|
// Some fields are only valid in the context of issues.
|
|
// They need to be enumerated separately in order to be filtered
|
|
// from existing code that expects to be able to pass Issue fields
|
|
// to PR queries, e.g. the PullRequestGraphql function.
|
|
var issueOnlyFields = []string{
|
|
"isPinned",
|
|
"stateReason",
|
|
"closedByPullRequestsReferences",
|
|
"issueType",
|
|
"parent",
|
|
"subIssues",
|
|
"subIssuesSummary",
|
|
"blockedBy",
|
|
"blocking",
|
|
}
|
|
|
|
var IssueFields = append(sharedIssuePRFields, issueOnlyFields...)
|
|
|
|
var PullRequestFields = append(sharedIssuePRFields,
|
|
"additions",
|
|
"autoMergeRequest",
|
|
"baseRefName",
|
|
"baseRefOid",
|
|
"changedFiles",
|
|
"closingIssuesReferences",
|
|
"commits",
|
|
"deletions",
|
|
"files",
|
|
"fullDatabaseId",
|
|
"headRefName",
|
|
"headRefOid",
|
|
"headRepository",
|
|
"headRepositoryOwner",
|
|
"isCrossRepository",
|
|
"isDraft",
|
|
"latestReviews",
|
|
"maintainerCanModify",
|
|
"mergeable",
|
|
"mergeCommit",
|
|
"mergedAt",
|
|
"mergedBy",
|
|
"mergeStateStatus",
|
|
"potentialMergeCommit",
|
|
"reviewDecision",
|
|
"reviewRequests",
|
|
"reviews",
|
|
"statusCheckRollup",
|
|
)
|
|
|
|
// IssueGraphQL constructs a GraphQL query fragment for a set of issue fields.
|
|
func IssueGraphQL(fields []string) string {
|
|
var q []string
|
|
for _, field := range fields {
|
|
switch field {
|
|
case "author":
|
|
q = append(q, `author{login,...on User{id,name}}`)
|
|
case "mergedBy":
|
|
q = append(q, `mergedBy{login,...on User{id,name}}`)
|
|
case "headRepositoryOwner":
|
|
q = append(q, `headRepositoryOwner{id,login,...on User{name}}`)
|
|
case "headRepository":
|
|
q = append(q, `headRepository{id,name,nameWithOwner}`)
|
|
case "assignees":
|
|
q = append(q, `assignees(first:100){nodes{id,login,name,databaseId},totalCount}`)
|
|
case "assignedActors":
|
|
q = append(q, assignedActors)
|
|
case "labels":
|
|
q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`)
|
|
case "projectCards":
|
|
q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`)
|
|
case "projectItems":
|
|
q = append(q, `projectItems(first:100){nodes{id, project{id,title}, status:fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue{optionId,name}}},totalCount}`)
|
|
case "milestone":
|
|
q = append(q, `milestone{number,title,description,dueOn}`)
|
|
case "reactionGroups":
|
|
q = append(q, `reactionGroups{content,users{totalCount}}`)
|
|
case "mergeCommit":
|
|
q = append(q, `mergeCommit{oid}`)
|
|
case "potentialMergeCommit":
|
|
q = append(q, `potentialMergeCommit{oid}`)
|
|
case "autoMergeRequest":
|
|
q = append(q, autoMergeRequest)
|
|
case "comments":
|
|
q = append(q, issueComments)
|
|
case "lastComment": // pseudo-field
|
|
q = append(q, issueCommentLast)
|
|
case "reviewRequests":
|
|
q = append(q, prReviewRequests)
|
|
case "reviews":
|
|
q = append(q, prReviews)
|
|
case "latestReviews":
|
|
q = append(q, prLatestReviews)
|
|
case "files":
|
|
q = append(q, prFiles)
|
|
case "commits":
|
|
q = append(q, prCommits)
|
|
case "lastCommit": // pseudo-field
|
|
q = append(q, `commits(last:1){nodes{commit{oid}}}`)
|
|
case "commitsCount": // pseudo-field
|
|
q = append(q, `commits{totalCount}`)
|
|
case "requiresStrictStatusChecks": // pseudo-field
|
|
q = append(q, `baseRef{branchProtectionRule{requiresStrictStatusChecks}}`)
|
|
case "statusCheckRollup":
|
|
q = append(q, StatusCheckRollupGraphQLWithoutCountByState(""))
|
|
case "statusCheckRollupWithCountByState": // pseudo-field
|
|
q = append(q, StatusCheckRollupGraphQLWithCountByState())
|
|
case "closingIssuesReferences":
|
|
q = append(q, prClosingIssuesReferences)
|
|
case "closedByPullRequestsReferences":
|
|
q = append(q, issueClosedByPullRequestsReferences)
|
|
case "issueType":
|
|
q = append(q, `issueType{id,name,description,color}`)
|
|
case "parent":
|
|
q = append(q, `parent{id,number,title,url,state,repository{nameWithOwner}}`)
|
|
case "subIssues":
|
|
q = append(q, `subIssues(first:100){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`)
|
|
case "subIssuesSummary":
|
|
q = append(q, `subIssuesSummary{total,completed,percentCompleted}`)
|
|
case "blockedBy":
|
|
q = append(q, `blockedBy(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`)
|
|
case "blocking":
|
|
q = append(q, `blocking(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`)
|
|
default:
|
|
q = append(q, field)
|
|
}
|
|
}
|
|
return strings.Join(q, ",")
|
|
}
|
|
|
|
// PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields.
|
|
// It will try to sanitize the fields to just those available on pull request.
|
|
func PullRequestGraphQL(fields []string) string {
|
|
s := set.NewStringSet()
|
|
s.AddValues(fields)
|
|
s.RemoveValues(issueOnlyFields)
|
|
return IssueGraphQL(s.ToSlice())
|
|
}
|
|
|
|
var RepositoryFields = []string{
|
|
"id",
|
|
"name",
|
|
"nameWithOwner",
|
|
"owner",
|
|
"parent",
|
|
"templateRepository",
|
|
"description",
|
|
"homepageUrl",
|
|
"openGraphImageUrl",
|
|
"usesCustomOpenGraphImage",
|
|
"url",
|
|
"sshUrl",
|
|
"mirrorUrl",
|
|
"securityPolicyUrl",
|
|
|
|
"createdAt",
|
|
"pushedAt",
|
|
"updatedAt",
|
|
"archivedAt",
|
|
|
|
"isBlankIssuesEnabled",
|
|
"isSecurityPolicyEnabled",
|
|
"hasIssuesEnabled",
|
|
"hasProjectsEnabled",
|
|
"hasWikiEnabled",
|
|
"hasDiscussionsEnabled",
|
|
"mergeCommitAllowed",
|
|
"squashMergeAllowed",
|
|
"rebaseMergeAllowed",
|
|
|
|
"forkCount",
|
|
"stargazerCount",
|
|
"watchers",
|
|
"issues",
|
|
"pullRequests",
|
|
|
|
"codeOfConduct",
|
|
"contactLinks",
|
|
"defaultBranchRef",
|
|
"deleteBranchOnMerge",
|
|
"diskUsage",
|
|
"fundingLinks",
|
|
"isArchived",
|
|
"isEmpty",
|
|
"isFork",
|
|
"isInOrganization",
|
|
"isMirror",
|
|
"isPrivate",
|
|
"visibility",
|
|
"isTemplate",
|
|
"isUserConfigurationRepository",
|
|
"licenseInfo",
|
|
"viewerCanAdminister",
|
|
"viewerDefaultCommitEmail",
|
|
"viewerDefaultMergeMethod",
|
|
"viewerHasStarred",
|
|
"viewerPermission",
|
|
"viewerPossibleCommitEmails",
|
|
"viewerSubscription",
|
|
|
|
"repositoryTopics",
|
|
"primaryLanguage",
|
|
"languages",
|
|
"issueTemplates",
|
|
"pullRequestTemplates",
|
|
"labels",
|
|
"milestones",
|
|
"latestRelease",
|
|
|
|
"assignableUsers",
|
|
"mentionableUsers",
|
|
"projects",
|
|
"projectsV2",
|
|
|
|
// "branchProtectionRules", // too complex to expose
|
|
// "collaborators", // does it make sense to expose without affiliation filter?
|
|
}
|
|
|
|
func RepositoryGraphQL(fields []string) string {
|
|
var q []string
|
|
for _, field := range fields {
|
|
switch field {
|
|
case "codeOfConduct":
|
|
q = append(q, "codeOfConduct{key,name,url}")
|
|
case "contactLinks":
|
|
q = append(q, "contactLinks{about,name,url}")
|
|
case "fundingLinks":
|
|
q = append(q, "fundingLinks{platform,url}")
|
|
case "licenseInfo":
|
|
q = append(q, "licenseInfo{key,name,nickname}")
|
|
case "owner":
|
|
q = append(q, "owner{id,login}")
|
|
case "parent":
|
|
q = append(q, "parent{id,name,owner{id,login}}")
|
|
case "templateRepository":
|
|
q = append(q, "templateRepository{id,name,owner{id,login}}")
|
|
case "repositoryTopics":
|
|
q = append(q, "repositoryTopics(first:100){nodes{topic{name}}}")
|
|
case "issueTemplates":
|
|
q = append(q, "issueTemplates{name,title,body,about}")
|
|
case "pullRequestTemplates":
|
|
q = append(q, "pullRequestTemplates{body,filename}")
|
|
case "labels":
|
|
q = append(q, "labels(first:100){nodes{id,color,name,description}}")
|
|
case "languages":
|
|
q = append(q, "languages(first:100){edges{size,node{name}}}")
|
|
case "primaryLanguage":
|
|
q = append(q, "primaryLanguage{name}")
|
|
case "latestRelease":
|
|
q = append(q, "latestRelease{publishedAt,tagName,name,url}")
|
|
case "milestones":
|
|
q = append(q, "milestones(first:100,states:OPEN){nodes{number,title,description,dueOn}}")
|
|
case "assignableUsers":
|
|
q = append(q, "assignableUsers(first:100){nodes{id,login,name}}")
|
|
case "mentionableUsers":
|
|
q = append(q, "mentionableUsers(first:100){nodes{id,login,name}}")
|
|
case "projects":
|
|
q = append(q, "projects(first:100,states:OPEN){nodes{id,name,number,body,resourcePath}}")
|
|
case "projectsV2":
|
|
q = append(q, "projectsV2(first:100,query:\"is:open\"){nodes{id,number,title,resourcePath,closed,url}}")
|
|
case "watchers":
|
|
q = append(q, "watchers{totalCount}")
|
|
case "issues":
|
|
q = append(q, "issues(states:OPEN){totalCount}")
|
|
case "pullRequests":
|
|
q = append(q, "pullRequests(states:OPEN){totalCount}")
|
|
case "defaultBranchRef":
|
|
q = append(q, "defaultBranchRef{name}")
|
|
default:
|
|
q = append(q, field)
|
|
}
|
|
}
|
|
return strings.Join(q, ",")
|
|
}
|