feat: add Issues 2.0 API types, mutations, and feature detection

Add API infrastructure for issue types, sub-issues, and issue
relationships (blocked-by/blocking):

- New types: IssueType, LinkedIssue, SubIssues, SubIssuesSummary,
  LinkedIssueConnection
- New Issue struct fields for all Issues 2.0 data
- GraphQL query builder cases for new fields
- ExportData cases for JSON output
- Mutation functions: UpdateIssueIssueType, AddSubIssue,
  RemoveSubIssue, AddBlockedBy, RemoveBlockedBy
- Helper functions: RepoIssueTypes, IssueNodeID
- Feature detection: IssueRelationshipsSupported for GHES 3.19+
  (issue types and sub-issues are GA on GHES 3.17+, no detection needed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kynan Ware 2026-03-29 16:38:09 -06:00
parent 611b01f6c8
commit c00257386e
5 changed files with 347 additions and 7 deletions

View file

@ -46,6 +46,58 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
})
}
data[f] = items
case "issueType":
data[f] = issue.IssueType
case "parent":
if issue.Parent != nil {
data[f] = map[string]interface{}{
"number": issue.Parent.Number,
"title": issue.Parent.Title,
"url": issue.Parent.URL,
"state": issue.Parent.State,
}
} else {
data[f] = nil
}
case "subIssues":
items := make([]map[string]interface{}, 0, len(issue.SubIssues.Nodes))
for _, n := range issue.SubIssues.Nodes {
items = append(items, map[string]interface{}{
"number": n.Number,
"title": n.Title,
"url": n.URL,
"state": n.State,
})
}
data[f] = items
case "subIssuesSummary":
data[f] = map[string]interface{}{
"total": issue.SubIssuesSummary.Total,
"completed": issue.SubIssuesSummary.Completed,
"percentCompleted": issue.SubIssuesSummary.PercentCompleted,
}
case "blockedBy":
items := make([]map[string]interface{}, 0, len(issue.BlockedBy.Nodes))
for _, n := range issue.BlockedBy.Nodes {
items = append(items, map[string]interface{}{
"number": n.Number,
"title": n.Title,
"url": n.URL,
"state": n.State,
})
}
data[f] = items
case "blocking":
items := make([]map[string]interface{}, 0, len(issue.Blocking.Nodes))
for _, n := range issue.Blocking.Nodes {
items = append(items, map[string]interface{}{
"number": n.Number,
"title": n.Title,
"url": n.URL,
"state": n.State,
})
}
data[f] = items
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()

View file

@ -46,9 +46,53 @@ type Issue struct {
ReactionGroups ReactionGroups
IsPinned bool
IssueType *IssueType
Parent *LinkedIssue
SubIssues SubIssues
SubIssuesSummary SubIssuesSummary
BlockedBy LinkedIssueConnection
Blocking LinkedIssueConnection
ClosedByPullRequestsReferences ClosedByPullRequestsReferences
}
// IssueType represents an issue type configured for a repository.
type IssueType struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
}
// LinkedIssue represents a related issue (parent, sub-issue, or relationship target).
type LinkedIssue struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
State string `json:"state"`
Repository struct {
NameWithOwner string `json:"nameWithOwner"`
} `json:"repository"`
}
// SubIssues is a connection of sub-issues with a total count.
type SubIssues struct {
Nodes []LinkedIssue
TotalCount int
}
// SubIssuesSummary contains completion stats for sub-issues.
type SubIssuesSummary struct {
Total int `json:"total"`
Completed int `json:"completed"`
PercentCompleted float64 `json:"percentCompleted"`
}
// LinkedIssueConnection is a connection of related issues (blocked-by or blocking).
type LinkedIssueConnection struct {
Nodes []LinkedIssue
}
type ClosedByPullRequestsReferences struct {
Nodes []struct {
ID string
@ -431,3 +475,174 @@ func (i Issue) Identifier() string {
func (i Issue) CurrentUserComments() []Comment {
return i.Comments.CurrentUserComments()
}
// UpdateIssueIssueType sets the issue type on an issue.
func UpdateIssueIssueType(client *Client, hostname string, issueID string, issueTypeID string) error {
query := `
mutation UpdateIssueIssueType($input: UpdateIssueIssueTypeInput!) {
updateIssueIssueType(input: $input) {
issue { id }
}
}`
variables := map[string]interface{}{
"input": map[string]interface{}{
"issueId": issueID,
"issueTypeId": issueTypeID,
},
}
var result struct {
UpdateIssueIssueType struct {
Issue struct{ ID string }
}
}
return client.GraphQL(hostname, query, variables, &result)
}
// AddSubIssue adds a sub-issue to a parent issue.
func AddSubIssue(client *Client, hostname string, parentID string, subIssueID string, replaceParent bool) error {
query := `
mutation AddSubIssue($input: AddSubIssueInput!) {
addSubIssue(input: $input) {
issue { id }
}
}`
variables := map[string]interface{}{
"input": map[string]interface{}{
"issueId": parentID,
"subIssueId": subIssueID,
"replaceParent": replaceParent,
},
}
var result struct {
AddSubIssue struct {
Issue struct{ ID string }
}
}
return client.GraphQL(hostname, query, variables, &result)
}
// RemoveSubIssue removes a sub-issue from a parent issue.
func RemoveSubIssue(client *Client, hostname string, parentID string, subIssueID string) error {
query := `
mutation RemoveSubIssue($input: RemoveSubIssueInput!) {
removeSubIssue(input: $input) {
issue { id }
}
}`
variables := map[string]interface{}{
"input": map[string]interface{}{
"issueId": parentID,
"subIssueId": subIssueID,
},
}
var result struct {
RemoveSubIssue struct {
Issue struct{ ID string }
}
}
return client.GraphQL(hostname, query, variables, &result)
}
// AddBlockedBy marks an issue as blocked by another issue.
func AddBlockedBy(client *Client, hostname string, issueID string, blockingIssueID string) error {
query := `
mutation AddBlockedBy($input: AddBlockedByInput!) {
addBlockedBy(input: $input) {
blockedIssue { id }
}
}`
variables := map[string]interface{}{
"input": map[string]interface{}{
"issueId": issueID,
"blockingIssueId": blockingIssueID,
},
}
var result struct {
AddBlockedBy struct {
BlockedIssue struct{ ID string }
}
}
return client.GraphQL(hostname, query, variables, &result)
}
// RemoveBlockedBy removes a "blocked by" relationship between two issues.
func RemoveBlockedBy(client *Client, hostname string, issueID string, blockingIssueID string) error {
query := `
mutation RemoveBlockedBy($input: RemoveBlockedByInput!) {
removeBlockedBy(input: $input) {
blockedIssue { id }
}
}`
variables := map[string]interface{}{
"input": map[string]interface{}{
"issueId": issueID,
"blockingIssueId": blockingIssueID,
},
}
var result struct {
RemoveBlockedBy struct {
BlockedIssue struct{ ID string }
}
}
return client.GraphQL(hostname, query, variables, &result)
}
// RepoIssueTypes fetches the available issue types for a repository.
func RepoIssueTypes(client *Client, repo ghrepo.Interface) ([]IssueType, error) {
query := `
query RepositoryIssueTypes($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
issueTypes(first: 50) {
nodes { id, name, description, color }
}
}
}`
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
}
var result struct {
Repository struct {
IssueTypes struct {
Nodes []IssueType
}
}
}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
if err != nil {
return nil, err
}
return result.Repository.IssueTypes.Nodes, nil
}
// IssueNodeID fetches the node ID for an issue given its number and repository.
func IssueNodeID(client *Client, repo ghrepo.Interface, number int) (string, error) {
query := `
query IssueNodeID($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
id
}
}
}`
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
"number": number,
}
var result struct {
Repository struct {
Issue struct {
ID string
}
}
}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
if err != nil {
return "", err
}
if result.Repository.Issue.ID == "" {
return "", fmt.Errorf("issue #%d not found in %s", number, ghrepo.FullName(repo))
}
return result.Repository.Issue.ID, nil
}

View file

@ -340,6 +340,12 @@ var issueOnlyFields = []string{
"isPinned",
"stateReason",
"closedByPullRequestsReferences",
"issueType",
"parent",
"subIssues",
"subIssuesSummary",
"blockedBy",
"blocking",
}
var IssueFields = append(sharedIssuePRFields, issueOnlyFields...)
@ -436,6 +442,18 @@ func IssueGraphQL(fields []string) string {
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{number,title,url,state,repository{nameWithOwner}}`)
case "subIssues":
q = append(q, `subIssues(first:50){nodes{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{number,title,url,state,repository{nameWithOwner}}}`)
case "blocking":
q = append(q, `blocking(first:50){nodes{number,title,url,state,repository{nameWithOwner}}}`)
default:
q = append(q, field)
}

View file

@ -48,10 +48,18 @@ type IssueFeatures struct {
// - The replaceActorsForAssignable mutation
// - The requestReviewsByLogin mutation
ApiActorsSupported bool
// TODO IssueRelationshipsCleanup — remove when GHES 3.18 support ends (~October 2026)
// IssueRelationshipsSupported indicates the host supports issue
// relationships (blocked-by/blocking). Available on github.com and
// GHES 3.19+. Issue types and sub-issues are GA on all supported GHES
// versions (3.17+) and do not need feature detection.
IssueRelationshipsSupported bool
}
var allIssueFeatures = IssueFeatures{
ApiActorsSupported: true,
ApiActorsSupported: true,
IssueRelationshipsSupported: true,
}
type PullRequestFeatures struct {
@ -159,9 +167,35 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
return allIssueFeatures, nil
}
return IssueFeatures{
features := IssueFeatures{
ApiActorsSupported: false, // TODO ApiActorsSupported — actor-based mutations unavailable on GHES
}, nil
}
// Detect issue relationship support (GHES 3.19+) via schema introspection.
// Issue types and sub-issues are GA on all supported GHES versions (3.17+)
// and do not need detection.
var featureDetection struct {
Issue struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Issue: __type(name: \"Issue\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "Issue_fields", &featureDetection, nil)
if err != nil {
return IssueFeatures{}, err
}
for _, field := range featureDetection.Issue.Fields {
if field.Name == "blockedBy" {
features.IssueRelationshipsSupported = true
break
}
}
return features, nil
}
func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {

View file

@ -12,6 +12,9 @@ import (
)
func TestIssueFeatures(t *testing.T) {
issueFieldsWithRelationships := `{"data":{"Issue":{"fields":[{"name":"title"},{"name":"body"},{"name":"blockedBy"}]}}}`
issueFieldsWithoutRelationships := `{"data":{"Issue":{"fields":[{"name":"title"},{"name":"body"}]}}}`
tests := []struct {
name string
hostname string
@ -23,7 +26,8 @@ func TestIssueFeatures(t *testing.T) {
name: "github.com",
hostname: "github.com",
wantFeatures: IssueFeatures{
ApiActorsSupported: true,
ApiActorsSupported: true,
IssueRelationshipsSupported: true,
},
wantErr: false,
},
@ -31,15 +35,32 @@ func TestIssueFeatures(t *testing.T) {
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: IssueFeatures{
ApiActorsSupported: true,
ApiActorsSupported: true,
IssueRelationshipsSupported: true,
},
wantErr: false,
},
{
name: "GHE",
name: "GHE with relationship support",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields`: issueFieldsWithRelationships,
},
wantFeatures: IssueFeatures{
ApiActorsSupported: false,
ApiActorsSupported: false,
IssueRelationshipsSupported: true,
},
wantErr: false,
},
{
name: "GHE without relationship support",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields`: issueFieldsWithoutRelationships,
},
wantFeatures: IssueFeatures{
ApiActorsSupported: false,
IssueRelationshipsSupported: false,
},
wantErr: false,
},