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:
parent
611b01f6c8
commit
c00257386e
5 changed files with 347 additions and 7 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue