Skip to content

Commit 60c43f9

Browse files
committed
Fold atomic creation into issue tools
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f71d9868-eef8-4fb0-84c6-df7c9a6a0ade
1 parent 8481cdc commit 60c43f9

2 files changed

Lines changed: 231 additions & 245 deletions

File tree

pkg/github/issues.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package github
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
@@ -2702,6 +2703,236 @@ Options are:
27022703
return st
27032704
}
27042705

2706+
type CreateIssueInput struct {
2707+
RepositoryID githubv4.ID `json:"repositoryId"`
2708+
Title githubv4.String `json:"title"`
2709+
2710+
Body *githubv4.String `json:"body,omitempty"`
2711+
AssigneeIDs *[]githubv4.ID `json:"assigneeIds,omitempty"`
2712+
MilestoneID *githubv4.ID `json:"milestoneId,omitempty"`
2713+
LabelIDs *[]githubv4.ID `json:"labelIds,omitempty"`
2714+
IssueTypeID *githubv4.ID `json:"issueTypeId,omitempty"`
2715+
ParentIssueID *githubv4.ID `json:"parentIssueId,omitempty"`
2716+
}
2717+
2718+
type createIssueMutation struct {
2719+
CreateIssue struct {
2720+
Issue struct {
2721+
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
2722+
URL githubv4.URI
2723+
}
2724+
} `graphql:"createIssue(input: $input)"`
2725+
}
2726+
2727+
type createIssueParentMetadataQuery struct {
2728+
ChildRepository struct {
2729+
ID githubv4.ID
2730+
} `graphql:"childRepository: repository(owner: $owner, name: $repo)"`
2731+
ParentRepository struct {
2732+
Issue struct {
2733+
ID githubv4.ID
2734+
} `graphql:"issue(number: $parentIssueNumber)"`
2735+
} `graphql:"parentRepository: repository(owner: $parentOwner, name: $parentRepo)"`
2736+
}
2737+
2738+
func createIssueWithParent(
2739+
ctx context.Context,
2740+
client *github.Client,
2741+
gqlClient *githubv4.Client,
2742+
owner string,
2743+
repo string,
2744+
title string,
2745+
body string,
2746+
assignees []string,
2747+
labels []string,
2748+
milestoneNumber int,
2749+
issueType string,
2750+
parentIssueNumber int,
2751+
parentOwner string,
2752+
parentRepo string,
2753+
) (*mcp.CallToolResult, error) {
2754+
if title == "" {
2755+
return utils.NewToolResultError("missing required parameter: title"), nil
2756+
}
2757+
if parentIssueNumber < 1 {
2758+
return utils.NewToolResultError("parent_issue_number must be greater than 0"), nil
2759+
}
2760+
2761+
parentOwner, parentRepo = parentRepository(owner, repo, parentOwner, parentRepo)
2762+
repositoryID, parentIssueID, err := resolveCreateIssueParent(ctx, gqlClient, owner, repo, parentOwner, parentRepo, parentIssueNumber)
2763+
if err != nil {
2764+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve parent issue", err), nil
2765+
}
2766+
2767+
input := CreateIssueInput{
2768+
RepositoryID: repositoryID,
2769+
Title: githubv4.String(title),
2770+
ParentIssueID: &parentIssueID,
2771+
}
2772+
if body != "" {
2773+
input.Body = githubv4.NewString(githubv4.String(body))
2774+
}
2775+
2776+
if len(labels) > 0 {
2777+
labelIDs := make([]githubv4.ID, 0, len(labels))
2778+
for _, label := range labels {
2779+
labelID, err := getLabelID(ctx, gqlClient, owner, repo, label)
2780+
if err != nil {
2781+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, fmt.Sprintf("failed to resolve label %q", label), err), nil
2782+
}
2783+
labelIDs = append(labelIDs, labelID)
2784+
}
2785+
input.LabelIDs = &labelIDs
2786+
}
2787+
2788+
if len(assignees) > 0 {
2789+
assigneeIDs := make([]githubv4.ID, 0, len(assignees))
2790+
for _, assignee := range assignees {
2791+
assigneeID, err := resolveUserID(ctx, gqlClient, assignee)
2792+
if err != nil {
2793+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, fmt.Sprintf("failed to resolve assignee %q", assignee), err), nil
2794+
}
2795+
assigneeIDs = append(assigneeIDs, assigneeID)
2796+
}
2797+
input.AssigneeIDs = &assigneeIDs
2798+
}
2799+
2800+
if milestoneNumber != 0 {
2801+
milestoneID, err := resolveMilestoneID(ctx, gqlClient, owner, repo, milestoneNumber)
2802+
if err != nil {
2803+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve milestone", err), nil
2804+
}
2805+
input.MilestoneID = &milestoneID
2806+
}
2807+
2808+
if issueType != "" {
2809+
issueTypeID, resp, err := resolveIssueTypeID(ctx, client, owner, repo, issueType)
2810+
if err != nil {
2811+
return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to resolve issue type %q", issueType), resp, err), nil
2812+
}
2813+
input.IssueTypeID = &issueTypeID
2814+
}
2815+
2816+
var mutation createIssueMutation
2817+
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
2818+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to create issue", err), nil
2819+
}
2820+
2821+
response := MinimalResponse{
2822+
ID: string(mutation.CreateIssue.Issue.FullDatabaseID),
2823+
URL: mutation.CreateIssue.Issue.URL.String(),
2824+
}
2825+
encoded, err := json.Marshal(response)
2826+
if err != nil {
2827+
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil
2828+
}
2829+
return utils.NewToolResultText(string(encoded)), nil
2830+
}
2831+
2832+
func parentRepository(owner, repo, parentOwner, parentRepo string) (string, string) {
2833+
if parentOwner == "" && parentRepo == "" {
2834+
return owner, repo
2835+
}
2836+
return parentOwner, parentRepo
2837+
}
2838+
2839+
func validateParentRepository(parentProvided bool, parentOwner, parentRepo string) error {
2840+
if !parentProvided {
2841+
if parentOwner != "" || parentRepo != "" {
2842+
return errors.New("parent_owner and parent_repo can only be used when parent_issue_number is provided")
2843+
}
2844+
return nil
2845+
}
2846+
if (parentOwner == "") != (parentRepo == "") {
2847+
return errors.New("parent_owner and parent_repo must be provided together")
2848+
}
2849+
return nil
2850+
}
2851+
2852+
func resolveCreateIssueParent(ctx context.Context, gqlClient *githubv4.Client, owner, repo, parentOwner, parentRepo string, parentIssueNumber int) (githubv4.ID, githubv4.ID, error) {
2853+
var query createIssueParentMetadataQuery
2854+
variables := map[string]any{
2855+
"owner": githubv4.String(owner),
2856+
"repo": githubv4.String(repo),
2857+
"parentOwner": githubv4.String(parentOwner),
2858+
"parentRepo": githubv4.String(parentRepo),
2859+
"parentIssueNumber": githubv4.Int(parentIssueNumber), // #nosec G115 - issue numbers are small positive integers
2860+
}
2861+
if err := gqlClient.Query(ctx, &query, variables); err != nil {
2862+
return "", "", err
2863+
}
2864+
if query.ChildRepository.ID == "" {
2865+
return "", "", fmt.Errorf("repository %s/%s was not found", owner, repo)
2866+
}
2867+
if query.ParentRepository.Issue.ID == "" {
2868+
return "", "", fmt.Errorf("parent issue #%d was not found in %s/%s", parentIssueNumber, parentOwner, parentRepo)
2869+
}
2870+
return query.ChildRepository.ID, query.ParentRepository.Issue.ID, nil
2871+
}
2872+
2873+
func resolveUserID(ctx context.Context, gqlClient *githubv4.Client, login string) (githubv4.ID, error) {
2874+
var query struct {
2875+
User struct {
2876+
ID githubv4.ID
2877+
Login githubv4.String
2878+
} `graphql:"user(login: $login)"`
2879+
}
2880+
if err := gqlClient.Query(ctx, &query, map[string]any{"login": githubv4.String(login)}); err != nil {
2881+
return "", err
2882+
}
2883+
if query.User.ID == "" {
2884+
return "", fmt.Errorf("user %q was not found", login)
2885+
}
2886+
return query.User.ID, nil
2887+
}
2888+
2889+
func resolveMilestoneID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, milestoneNumber int) (githubv4.ID, error) {
2890+
var query struct {
2891+
Repository struct {
2892+
Milestone struct {
2893+
ID githubv4.ID
2894+
} `graphql:"milestone(number: $milestoneNumber)"`
2895+
} `graphql:"repository(owner: $owner, name: $repo)"`
2896+
}
2897+
variables := map[string]any{
2898+
"owner": githubv4.String(owner),
2899+
"repo": githubv4.String(repo),
2900+
"milestoneNumber": githubv4.Int(milestoneNumber), // #nosec G115 - milestone numbers are small positive integers
2901+
}
2902+
if err := gqlClient.Query(ctx, &query, variables); err != nil {
2903+
return "", err
2904+
}
2905+
if query.Repository.Milestone.ID == "" {
2906+
return "", fmt.Errorf("milestone #%d was not found in %s/%s", milestoneNumber, owner, repo)
2907+
}
2908+
return query.Repository.Milestone.ID, nil
2909+
}
2910+
2911+
func resolveIssueTypeID(ctx context.Context, client *github.Client, owner, repo, issueTypeName string) (githubv4.ID, *github.Response, error) {
2912+
req, err := client.NewRequest(ctx, "GET", fmt.Sprintf("repos/%s/%s/issue-types", owner, repo), nil)
2913+
if err != nil {
2914+
return "", nil, err
2915+
}
2916+
2917+
var issueTypes []*github.IssueType
2918+
resp, err := client.Do(req, &issueTypes)
2919+
if resp != nil && resp.Body != nil {
2920+
defer func() { _ = resp.Body.Close() }()
2921+
}
2922+
if err != nil {
2923+
return "", resp, err
2924+
}
2925+
for _, issueType := range issueTypes {
2926+
if issueType != nil && strings.EqualFold(strings.TrimSpace(issueType.GetName()), strings.TrimSpace(issueTypeName)) {
2927+
if issueType.GetNodeID() == "" {
2928+
return "", resp, fmt.Errorf("issue type %q is missing a node ID", issueTypeName)
2929+
}
2930+
return githubv4.ID(issueType.GetNodeID()), resp, nil
2931+
}
2932+
}
2933+
return "", resp, fmt.Errorf("issue type %q was not found in %s/%s", issueTypeName, owner, repo)
2934+
}
2935+
27052936
func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue) (*mcp.CallToolResult, error) {
27062937
if title == "" {
27072938
return utils.NewToolResultError("missing required parameter: title"), nil

0 commit comments

Comments
 (0)