From c00257386e881868246119922b1f713c916fc690 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:38:09 -0600 Subject: [PATCH] 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> --- api/export_pr.go | 52 +++++ api/queries_issue.go | 215 ++++++++++++++++++ api/query_builder.go | 18 ++ .../featuredetection/feature_detection.go | 40 +++- .../feature_detection_test.go | 29 ++- 5 files changed, 347 insertions(+), 7 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index 9b030c39e..f5592befd 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -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() diff --git a/api/queries_issue.go b/api/queries_issue.go index bff84029d..f243dd6ba 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -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 +} diff --git a/api/query_builder.go b/api/query_builder.go index 9c97e67e9..5b9bf30b4 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -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) } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index d4ef62070..e60c89e70 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -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) { diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index f24e31f4c..6b6ed6751 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -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, },