cli/api/query_builder.go
Kynan Ware 20105edbcb Include id in linked-issue JSON exports
Add id to the GraphQL fragment for subIssues, blockedBy, and blocking
nodes so the canonical node identifier is fetched, and surface it in
the JSON output alongside number/title/url/state for parent, subIssues,
blockedBy, and blocking.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 15:54:39 -06:00

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:50){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}}}`)
case "blocking":
q = append(q, `blocking(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}}}`)
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, ",")
}