Wrap relationship JSON output in {nodes, totalCount}, fetch full pages

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>
This commit is contained in:
Kynan Ware 2026-05-06 16:05:07 -06:00
parent 20105edbcb
commit a78bedb772
4 changed files with 39 additions and 15 deletions

View file

@ -71,7 +71,10 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
"state": n.State,
})
}
data[f] = items
data[f] = map[string]interface{}{
"nodes": items,
"totalCount": issue.SubIssues.TotalCount,
}
case "subIssuesSummary":
data[f] = map[string]interface{}{
"total": issue.SubIssuesSummary.Total,
@ -89,7 +92,10 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
"state": n.State,
})
}
data[f] = items
data[f] = map[string]interface{}{
"nodes": items,
"totalCount": issue.BlockedBy.TotalCount,
}
case "blocking":
items := make([]map[string]interface{}, 0, len(issue.Blocking.Nodes))
for _, n := range issue.Blocking.Nodes {
@ -101,7 +107,10 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
"state": n.State,
})
}
data[f] = items
data[f] = map[string]interface{}{
"nodes": items,
"totalCount": issue.Blocking.TotalCount,
}
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()

View file

@ -91,7 +91,8 @@ type SubIssuesSummary struct {
// LinkedIssueConnection is a connection of related issues (blocked-by or blocking).
type LinkedIssueConnection struct {
Nodes []LinkedIssue `json:"nodes"`
Nodes []LinkedIssue `json:"nodes"`
TotalCount int `json:"totalCount"`
}
type ClosedByPullRequestsReferences struct {

View file

@ -447,13 +447,13 @@ func IssueGraphQL(fields []string) string {
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}`)
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}}}`)
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}}}`)
q = append(q, `blocking(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`)
default:
q = append(q, field)
}

View file

@ -674,10 +674,12 @@ func issueResponseAllIssues2Fields() string {
},
"subIssuesSummary": {"total":2,"completed":1,"percentCompleted":50.0},
"blockedBy": {
"nodes": [{"number":200,"title":"API rate limiting","url":"https://github.com/OWNER/REPO/issues/200","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}]
"nodes": [{"number":200,"title":"API rate limiting","url":"https://github.com/OWNER/REPO/issues/200","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}],
"totalCount": 1
},
"blocking": {
"nodes": [{"number":300,"title":"Release v2.0","url":"https://github.com/OWNER/REPO/issues/300","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}]
"nodes": [{"number":300,"title":"Release v2.0","url":"https://github.com/OWNER/REPO/issues/300","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}],
"totalCount": 1
}
} } } }`
}
@ -878,8 +880,12 @@ func TestIssueView_json_ParentSubIssues(t *testing.T) {
assert.Equal(t, "OPEN", parent["state"])
// Sub-issues
subIssues, ok := data["subIssues"].([]interface{})
require.True(t, ok, "subIssues should be an array")
subIssuesObj, ok := data["subIssues"].(map[string]interface{})
require.True(t, ok, "subIssues should be an object")
assert.Equal(t, float64(2), subIssuesObj["totalCount"])
subIssues, ok := subIssuesObj["nodes"].([]interface{})
require.True(t, ok, "subIssues.nodes should be an array")
require.Len(t, subIssues, 2)
sub0 := subIssues[0].(map[string]interface{})
@ -916,8 +922,12 @@ func TestIssueView_json_BlockedByBlocking(t *testing.T) {
require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data))
// Blocked by
blockedBy, ok := data["blockedBy"].([]interface{})
require.True(t, ok, "blockedBy should be an array")
blockedByObj, ok := data["blockedBy"].(map[string]interface{})
require.True(t, ok, "blockedBy should be an object")
assert.Equal(t, float64(1), blockedByObj["totalCount"])
blockedBy, ok := blockedByObj["nodes"].([]interface{})
require.True(t, ok, "blockedBy.nodes should be an array")
require.Len(t, blockedBy, 1)
blocked0 := blockedBy[0].(map[string]interface{})
@ -927,8 +937,12 @@ func TestIssueView_json_BlockedByBlocking(t *testing.T) {
assert.Equal(t, "OPEN", blocked0["state"])
// Blocking
blocking, ok := data["blocking"].([]interface{})
require.True(t, ok, "blocking should be an array")
blockingObj, ok := data["blocking"].(map[string]interface{})
require.True(t, ok, "blocking should be an object")
assert.Equal(t, float64(1), blockingObj["totalCount"])
blocking, ok := blockingObj["nodes"].([]interface{})
require.True(t, ok, "blocking.nodes should be an array")
require.Len(t, blocking, 1)
blocking0 := blocking[0].(map[string]interface{})