Skip to content

Commit b2b9273

Browse files
zwickCopilot
andcommitted
Add atomic parent issue creation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 64a49f3 commit b2b9273

10 files changed

Lines changed: 697 additions & 40 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,7 @@ The following sets of tools are available:
937937
(string, required)
938938
- `milestone`: Milestone number (number, optional)
939939
- `owner`: Repository owner (string, required)
940+
- `parent_issue_number`: Issue number of the parent issue. Only used when method is 'create'. The new issue is created and attached to this parent in the same operation. (number, optional)
940941
- `repo`: Repository name (string, required)
941942
- `state`: New state (string, optional)
942943
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)

docs/feature-flags.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ runtime behavior (such as output formatting) won't appear here.
6767
(string, required)
6868
- `milestone`: Milestone number (number, optional)
6969
- `owner`: Repository owner (string, required)
70+
- `parent_issue_number`: Issue number of the parent issue. Only used when method is 'create'. The new issue is created and attached to this parent in the same operation. (number, optional)
7071
- `repo`: Repository name (string, required)
7172
- `state`: New state (string, optional)
7273
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
@@ -122,6 +123,7 @@ runtime behavior (such as output formatting) won't appear here.
122123
- **Required OAuth Scopes**: `repo`
123124
- `body`: Issue body content (optional) (string, optional)
124125
- `owner`: Repository owner (username or organization) (string, required)
126+
- `parent_issue_number`: Issue number of the parent issue. The new issue is created and attached to this parent in the same operation. (number, optional)
125127
- `repo`: Repository name (string, required)
126128
- `title`: Issue title (string, required)
127129

docs/insiders-features.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
6161
(string, required)
6262
- `milestone`: Milestone number (number, optional)
6363
- `owner`: Repository owner (string, required)
64+
- `parent_issue_number`: Issue number of the parent issue. Only used when method is 'create'. The new issue is created and attached to this parent in the same operation. (number, optional)
6465
- `repo`: Repository name (string, required)
6566
- `state`: New state (string, optional)
6667
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)

pkg/github/__toolsnaps__/create_issue.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
"description": "Repository owner (username or organization)",
1818
"type": "string"
1919
},
20+
"parent_issue_number": {
21+
"description": "Issue number of the parent issue. The new issue is created and attached to this parent in the same operation.",
22+
"minimum": 1,
23+
"type": "number"
24+
},
2025
"repo": {
2126
"description": "Repository name",
2227
"type": "string"

pkg/github/__toolsnaps__/issue_write.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@
9191
"description": "Repository owner",
9292
"type": "string"
9393
},
94+
"parent_issue_number": {
95+
"description": "Issue number of the parent issue. Only used when method is 'create'. The new issue is created and attached to this parent in the same operation.",
96+
"minimum": 1,
97+
"type": "number"
98+
},
9499
"repo": {
95100
"description": "Repository name",
96101
"type": "string"

pkg/github/issues.go

Lines changed: 82 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -116,31 +116,36 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
116116
}
117117

118118
// issueFieldWriteMetadataNode queries only the fields needed to resolve a write: the field's
119-
// fullDatabaseId (BigInt scalar, returned as string) plus its name and data type for validation.
119+
// node ID, fullDatabaseId (BigInt scalar, returned as string), name, and data type for validation.
120120
// shurcooL/githubv4 cannot use interface-level fragments at union top-level, so we repeat
121121
// fullDatabaseId on each concrete type; all four implement IssueFieldCommon.
122122
type issueFieldWriteMetadataNode struct {
123123
TypeName githubv4.String `graphql:"__typename"`
124124
IssueFieldText struct {
125+
ID githubv4.ID
125126
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
126127
Name githubv4.String
127128
DataType githubv4.String
128129
} `graphql:"... on IssueFieldText"`
129130
IssueFieldNumber struct {
131+
ID githubv4.ID
130132
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
131133
Name githubv4.String
132134
DataType githubv4.String
133135
} `graphql:"... on IssueFieldNumber"`
134136
IssueFieldDate struct {
137+
ID githubv4.ID
135138
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
136139
Name githubv4.String
137140
DataType githubv4.String
138141
} `graphql:"... on IssueFieldDate"`
139142
IssueFieldSingleSelect struct {
143+
ID githubv4.ID
140144
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
141145
Name githubv4.String
142146
DataType githubv4.String
143147
Options []struct {
148+
ID githubv4.ID
144149
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
145150
Name githubv4.String
146151
}
@@ -308,33 +313,9 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
308313
return nil, nil, nil
309314
}
310315

311-
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
312-
var query issueFieldWriteMetadataQuery
313-
vars := map[string]any{
314-
"owner": githubv4.String(owner),
315-
"repo": githubv4.String(repo),
316-
}
317-
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
318-
return nil, nil, fmt.Errorf("failed to query issue fields metadata: %w", err)
319-
}
320-
321-
// Build name → node map, dispatching on concrete type to extract name.
322-
fieldByName := make(map[string]issueFieldWriteMetadataNode, len(query.Repository.IssueFields.Nodes))
323-
for _, node := range query.Repository.IssueFields.Nodes {
324-
var name string
325-
switch string(node.TypeName) {
326-
case "IssueFieldText":
327-
name = string(node.IssueFieldText.Name)
328-
case "IssueFieldNumber":
329-
name = string(node.IssueFieldNumber.Name)
330-
case "IssueFieldDate":
331-
name = string(node.IssueFieldDate.Name)
332-
case "IssueFieldSingleSelect":
333-
name = string(node.IssueFieldSingleSelect.Name)
334-
default:
335-
continue
336-
}
337-
fieldByName[strings.ToLower(strings.TrimSpace(name))] = node
316+
fieldByName, err := fetchIssueFieldWriteMetadata(ctx, gqlClient, owner, repo)
317+
if err != nil {
318+
return nil, nil, err
338319
}
339320

340321
resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields))
@@ -401,6 +382,38 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
401382
return resolved, fieldIDsToDelete, nil
402383
}
403384

385+
func fetchIssueFieldWriteMetadata(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) (map[string]issueFieldWriteMetadataNode, error) {
386+
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
387+
var query issueFieldWriteMetadataQuery
388+
vars := map[string]any{
389+
"owner": githubv4.String(owner),
390+
"repo": githubv4.String(repo),
391+
}
392+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
393+
return nil, fmt.Errorf("failed to query issue fields metadata: %w", err)
394+
}
395+
396+
fieldByName := make(map[string]issueFieldWriteMetadataNode, len(query.Repository.IssueFields.Nodes))
397+
for _, node := range query.Repository.IssueFields.Nodes {
398+
var name string
399+
switch string(node.TypeName) {
400+
case "IssueFieldText":
401+
name = string(node.IssueFieldText.Name)
402+
case "IssueFieldNumber":
403+
name = string(node.IssueFieldNumber.Name)
404+
case "IssueFieldDate":
405+
name = string(node.IssueFieldDate.Name)
406+
case "IssueFieldSingleSelect":
407+
name = string(node.IssueFieldSingleSelect.Name)
408+
default:
409+
continue
410+
}
411+
fieldByName[strings.ToLower(strings.TrimSpace(name))] = node
412+
}
413+
414+
return fieldByName, nil
415+
}
416+
404417
// fetchExistingIssueFieldValues retrieves the current field values for an issue
405418
// as IssueRequestFieldValue entries, ready to be merged before an update.
406419
func fetchExistingIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) ([]*github.IssueRequestFieldValue, error) {
@@ -2349,6 +2362,9 @@ var issueWriteFormParams = map[string]struct{}{
23492362
"_ui_submitted": {},
23502363
}
23512364

2365+
// parent_issue_number is intentionally omitted because the current form cannot
2366+
// represent it. Calls that supply a parent bypass the form instead of dropping it.
2367+
23522368
// issueWriteAwaitingFormResult builds the "awaiting form submission" stub
23532369
// returned when issue_write hands off to the MCP App form. The body is shared
23542370
// by IssueWrite and LegacyIssueWrite. The result is marked IsError=true so
@@ -2425,6 +2441,11 @@ Options are:
24252441
Type: "number",
24262442
Description: "Issue number to update",
24272443
},
2444+
"parent_issue_number": {
2445+
Type: "number",
2446+
Description: "Issue number of the parent issue. Only used when method is 'create'. The new issue is created and attached to this parent in the same operation.",
2447+
Minimum: jsonschema.Ptr(1.0),
2448+
},
24282449
"title": {
24292450
Type: "string",
24302451
Description: "Issue title",
@@ -2611,6 +2632,19 @@ Options are:
26112632
return utils.NewToolResultError(err.Error()), nil, nil
26122633
}
26132634

2635+
parentIssueNumber, err := OptionalIntParam(args, "parent_issue_number")
2636+
if err != nil {
2637+
return utils.NewToolResultError(err.Error()), nil, nil
2638+
}
2639+
parentValue, parentProvided := args["parent_issue_number"]
2640+
parentProvided = parentProvided && parentValue != nil
2641+
if parentProvided && parentIssueNumber < 1 {
2642+
return utils.NewToolResultError("parent_issue_number must be greater than 0"), nil, nil
2643+
}
2644+
if parentProvided && method != "create" {
2645+
return utils.NewToolResultError("parent_issue_number can only be used with the create method"), nil, nil
2646+
}
2647+
26142648
var issueFields []issueWriteFieldInput
26152649
issueFields, err = optionalIssueWriteFields(args)
26162650
if err != nil {
@@ -2627,24 +2661,35 @@ Options are:
26272661
return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil
26282662
}
26292663

2630-
var issueFieldValues []*github.IssueRequestFieldValue
2631-
var fieldIDsToDelete []int64
2632-
if len(issueFields) > 0 {
2633-
issueFieldValues, fieldIDsToDelete, err = resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
2634-
if err != nil {
2635-
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
2636-
}
2637-
}
2638-
26392664
switch method {
26402665
case "create":
2666+
if parentProvided {
2667+
result, err := createIssueWithParent(ctx, client, gqlClient, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFields, parentIssueNumber)
2668+
return result, nil, err
2669+
}
2670+
2671+
var issueFieldValues []*github.IssueRequestFieldValue
2672+
if len(issueFields) > 0 {
2673+
issueFieldValues, _, err = resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
2674+
if err != nil {
2675+
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
2676+
}
2677+
}
26412678
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues)
26422679
return result, nil, err
26432680
case "update":
26442681
issueNumber, err := RequiredInt(args, "issue_number")
26452682
if err != nil {
26462683
return utils.NewToolResultError(err.Error()), nil, nil
26472684
}
2685+
var issueFieldValues []*github.IssueRequestFieldValue
2686+
var fieldIDsToDelete []int64
2687+
if len(issueFields) > 0 {
2688+
issueFieldValues, fieldIDsToDelete, err = resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
2689+
if err != nil {
2690+
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
2691+
}
2692+
}
26482693
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{
26492694
AssigneesProvided: assigneesProvided,
26502695
LabelsProvided: labelsProvided,

0 commit comments

Comments
 (0)