Skip to content

Commit 0639436

Browse files
committed
feat(github): streamline issue project workflow tools
- Adds explicit issue and project item identifiers so workflow agents can connect issues, project items, sub-issues, and dependencies without parsing ambiguous response IDs - Adds safe issue label mutation so readiness labels can be added and removed without replacing unrelated labels - Adds project item updates by issue or pull request identity with batched field updates so Status and Priority changes no longer require brittle project item lookup steps - Adds project issue creation that creates the issue, adds it to the project, and applies initial Status and Priority for the common backlog workflow - Updates tool metadata and tests to document the safer GitHub workflow surface and preserve existing compatible paths
1 parent 0d37853 commit 0639436

13 files changed

Lines changed: 1321 additions & 73 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
{
2+
"annotations": {
3+
"destructiveHint": false,
4+
"openWorldHint": true,
5+
"title": "Create project issue"
6+
},
7+
"description": "Create a GitHub issue, add it to a GitHub Project, and set initial project Status and Priority fields.",
8+
"inputSchema": {
9+
"properties": {
10+
"assignees": {
11+
"description": "GitHub usernames to assign to this issue",
12+
"items": {
13+
"type": "string"
14+
},
15+
"type": "array"
16+
},
17+
"body": {
18+
"description": "Issue body content",
19+
"type": "string"
20+
},
21+
"labels": {
22+
"description": "Labels to apply to the issue",
23+
"items": {
24+
"type": "string"
25+
},
26+
"type": "array"
27+
},
28+
"owner": {
29+
"description": "Repository owner",
30+
"type": "string"
31+
},
32+
"priority_field_id": {
33+
"description": "Project Priority field ID.",
34+
"type": "number"
35+
},
36+
"priority_value": {
37+
"description": "Initial Priority field value or option ID.",
38+
"type": "string"
39+
},
40+
"project_number": {
41+
"description": "The project's number.",
42+
"type": "number"
43+
},
44+
"project_owner": {
45+
"description": "Project owner login. Defaults to the repository owner when omitted.",
46+
"type": "string"
47+
},
48+
"project_owner_type": {
49+
"description": "Project owner type.",
50+
"enum": [
51+
"user",
52+
"org"
53+
],
54+
"type": "string"
55+
},
56+
"repo": {
57+
"description": "Repository name",
58+
"type": "string"
59+
},
60+
"status_field_id": {
61+
"description": "Project Status field ID.",
62+
"type": "number"
63+
},
64+
"status_value": {
65+
"description": "Initial Status field value or option ID. For github-workflow this should be Backlog's option ID.",
66+
"type": "string"
67+
},
68+
"title": {
69+
"description": "Issue title",
70+
"type": "string"
71+
},
72+
"type": {
73+
"description": "Issue type name, when the repository supports issue types",
74+
"type": "string"
75+
}
76+
},
77+
"required": [
78+
"owner",
79+
"repo",
80+
"title",
81+
"project_number",
82+
"status_field_id",
83+
"status_value",
84+
"priority_field_id",
85+
"priority_value"
86+
],
87+
"type": "object"
88+
},
89+
"name": "create_project_issue"
90+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"annotations": {
3+
"title": "Change issue labels"
4+
},
5+
"description": "Add or remove labels on an issue without replacing unrelated labels.",
6+
"inputSchema": {
7+
"properties": {
8+
"issue_number": {
9+
"description": "Issue number to update",
10+
"type": "number"
11+
},
12+
"labels": {
13+
"description": "Labels to add or remove",
14+
"items": {
15+
"type": "string"
16+
},
17+
"type": "array"
18+
},
19+
"method": {
20+
"description": "The label operation to perform.",
21+
"enum": [
22+
"add",
23+
"remove"
24+
],
25+
"type": "string"
26+
},
27+
"owner": {
28+
"description": "Repository owner",
29+
"type": "string"
30+
},
31+
"repo": {
32+
"description": "Repository name",
33+
"type": "string"
34+
}
35+
},
36+
"required": [
37+
"method",
38+
"owner",
39+
"repo",
40+
"issue_number",
41+
"labels"
42+
],
43+
"type": "object"
44+
},
45+
"name": "issue_label_write"
46+
}

pkg/github/__toolsnaps__/label_write.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"annotations": {
33
"title": "Write operations on repository labels."
44
},
5-
"description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.",
5+
"description": "Perform write operations on repository labels. To add or remove labels on issues, use the 'issue_label_write' tool.",
66
"inputSchema": {
77
"properties": {
88
"color": {
@@ -48,4 +48,4 @@
4848
"type": "object"
4949
},
5050
"name": "label_write"
51-
}
51+
}

pkg/github/__toolsnaps__/projects_write.snap

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"type": "number"
1616
},
1717
"item_id": {
18-
"description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.",
18+
"description": "The numeric project item ID. Required for 'delete_project_item'. For 'update_project_item', provide this or identify the content with item_owner, item_repo, item_type, and issue_number or pull_request_number.",
1919
"type": "number"
2020
},
2121
"item_owner": {
@@ -84,8 +84,15 @@
8484
"type": "string"
8585
},
8686
"updated_field": {
87-
"description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.",
87+
"description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. For 'update_project_item', provide updated_field or updated_fields.",
8888
"type": "object"
89+
},
90+
"updated_fields": {
91+
"description": "List of project field updates, each with the field ID and new value. Example: [{\"id\": 123456, \"value\": \"In Progress\"}, {\"id\": 234567, \"value\": \"P1\"}].",
92+
"items": {
93+
"type": "object"
94+
},
95+
"type": "array"
8996
}
9097
},
9198
"required": [
@@ -96,4 +103,4 @@
96103
"type": "object"
97104
},
98105
"name": "projects_write"
99-
}
106+
}

pkg/github/helper_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ const (
5959
PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
6060
PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
6161
PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"
62+
GetReposIssuesLabelsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/labels"
63+
PostReposIssuesLabelsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/labels"
64+
DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}"
6265
GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by"
6366
PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by"
6467
DeleteReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumberByIssueID = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id}"

pkg/github/issues.go

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
104104

105105
// IssueFragment represents a fragment of an issue node in the GraphQL API.
106106
type IssueFragment struct {
107+
ID githubv4.ID
107108
Number githubv4.Int
108109
Title githubv4.String
109110
Body githubv4.String
@@ -763,6 +764,127 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
763764
})
764765
}
765766

767+
// IssueLabelWrite creates a tool to add or remove issue labels without replacing unrelated labels.
768+
func IssueLabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
769+
return NewTool(
770+
ToolsetMetadataIssues,
771+
mcp.Tool{
772+
Name: "issue_label_write",
773+
Description: t("TOOL_ISSUE_LABEL_WRITE_DESCRIPTION", "Add or remove labels on an issue without replacing unrelated labels."),
774+
Annotations: &mcp.ToolAnnotations{
775+
Title: t("TOOL_ISSUE_LABEL_WRITE_USER_TITLE", "Change issue labels"),
776+
ReadOnlyHint: false,
777+
},
778+
InputSchema: &jsonschema.Schema{
779+
Type: "object",
780+
Properties: map[string]*jsonschema.Schema{
781+
"method": {
782+
Type: "string",
783+
Description: "The label operation to perform.",
784+
Enum: []any{"add", "remove"},
785+
},
786+
"owner": {
787+
Type: "string",
788+
Description: "Repository owner",
789+
},
790+
"repo": {
791+
Type: "string",
792+
Description: "Repository name",
793+
},
794+
"issue_number": {
795+
Type: "number",
796+
Description: "Issue number to update",
797+
},
798+
"labels": {
799+
Type: "array",
800+
Description: "Labels to add or remove",
801+
Items: &jsonschema.Schema{Type: "string"},
802+
},
803+
},
804+
Required: []string{"method", "owner", "repo", "issue_number", "labels"},
805+
},
806+
},
807+
[]scopes.Scope{scopes.Repo},
808+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
809+
method, err := RequiredParam[string](args, "method")
810+
if err != nil {
811+
return utils.NewToolResultError(err.Error()), nil, nil
812+
}
813+
owner, err := RequiredParam[string](args, "owner")
814+
if err != nil {
815+
return utils.NewToolResultError(err.Error()), nil, nil
816+
}
817+
repo, err := RequiredParam[string](args, "repo")
818+
if err != nil {
819+
return utils.NewToolResultError(err.Error()), nil, nil
820+
}
821+
issueNumber, err := RequiredInt(args, "issue_number")
822+
if err != nil {
823+
return utils.NewToolResultError(err.Error()), nil, nil
824+
}
825+
labels, err := OptionalStringArrayParam(args, "labels")
826+
if err != nil {
827+
return utils.NewToolResultError(err.Error()), nil, nil
828+
}
829+
if len(labels) == 0 {
830+
return utils.NewToolResultError("labels must contain at least one label"), nil, nil
831+
}
832+
833+
client, err := deps.GetClient(ctx)
834+
if err != nil {
835+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
836+
}
837+
838+
switch method {
839+
case "add":
840+
addedLabels, resp, err := client.Issues.AddLabelsToIssue(ctx, owner, repo, issueNumber, labels)
841+
if err != nil {
842+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue labels", resp, err), nil, nil
843+
}
844+
defer func() { _ = resp.Body.Close() }()
845+
return marshalIssueLabelWriteResponse(issueNumber, addedLabels)
846+
case "remove":
847+
for _, label := range labels {
848+
resp, err := client.Issues.RemoveLabelForIssue(ctx, owner, repo, issueNumber, label)
849+
if err != nil {
850+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue label", resp, err), nil, nil
851+
}
852+
if resp != nil && resp.Body != nil {
853+
_ = resp.Body.Close()
854+
}
855+
}
856+
remainingLabels, resp, err := client.Issues.ListLabelsByIssue(ctx, owner, repo, issueNumber, nil)
857+
if err != nil {
858+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue labels", resp, err), nil, nil
859+
}
860+
defer func() { _ = resp.Body.Close() }()
861+
return marshalIssueLabelWriteResponse(issueNumber, remainingLabels)
862+
default:
863+
return utils.NewToolResultError("method must be either 'add' or 'remove'"), nil, nil
864+
}
865+
},
866+
)
867+
}
868+
869+
func marshalIssueLabelWriteResponse(issueNumber int, labels []*github.Label) (*mcp.CallToolResult, any, error) {
870+
labelNames := make([]string, 0, len(labels))
871+
for _, label := range labels {
872+
if label != nil {
873+
labelNames = append(labelNames, label.GetName())
874+
}
875+
}
876+
877+
response := map[string]any{
878+
"issue_number": issueNumber,
879+
"labels": labelNames,
880+
}
881+
r, err := json.Marshal(response)
882+
if err != nil {
883+
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
884+
}
885+
return utils.NewToolResultText(string(r)), nil, nil
886+
}
887+
766888
// IssueDependencyWrite creates a tool to add or remove dependency edges for an issue.
767889
func IssueDependencyWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
768890
return NewTool(
@@ -1448,8 +1570,11 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo
14481570

14491571
// Return minimal response with just essential information
14501572
minimalResponse := MinimalResponse{
1451-
ID: fmt.Sprintf("%d", issue.GetID()),
1452-
URL: issue.GetHTMLURL(),
1573+
ID: fmt.Sprintf("%d", issue.GetID()),
1574+
URL: issue.GetHTMLURL(),
1575+
IssueNumber: issue.GetNumber(),
1576+
IssueID: issue.GetID(),
1577+
IssueNodeID: issue.GetNodeID(),
14531578
}
14541579

14551580
r, err := json.Marshal(minimalResponse)
@@ -1573,8 +1698,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
15731698

15741699
// Return minimal response with just essential information
15751700
minimalResponse := MinimalResponse{
1576-
ID: fmt.Sprintf("%d", updatedIssue.GetID()),
1577-
URL: updatedIssue.GetHTMLURL(),
1701+
ID: fmt.Sprintf("%d", updatedIssue.GetID()),
1702+
URL: updatedIssue.GetHTMLURL(),
1703+
IssueNumber: updatedIssue.GetNumber(),
1704+
IssueID: updatedIssue.GetID(),
1705+
IssueNodeID: updatedIssue.GetNodeID(),
15781706
}
15791707

15801708
r, err := json.Marshal(minimalResponse)

0 commit comments

Comments
 (0)