Skip to content

Commit 0d37853

Browse files
committed
feat(issues): add native issue dependency tools
- Adds issue dependency read operations so agents can inspect blockers and downstream blocked work through the issue read tool - Adds issue dependency write support so agents can create and remove native blocked-by relationships without relying on issue comments - Registers the dependency tool and updates tests and tool snapshots so the GitHub workflow exposes blocker management consistently
1 parent 926d049 commit 0d37853

6 files changed

Lines changed: 562 additions & 15 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"annotations": {
3+
"title": "Change issue dependency"
4+
},
5+
"description": "Add or remove issue dependencies in a GitHub repository.",
6+
"inputSchema": {
7+
"properties": {
8+
"issue_id": {
9+
"description": "The ID of the related issue to add or remove as a blocking dependency",
10+
"type": "number"
11+
},
12+
"issue_number": {
13+
"description": "The issue number to modify",
14+
"type": "number"
15+
},
16+
"method": {
17+
"description": "The action to perform on an issue dependency.\nOptions are:\n- 'add_blocked_by' - add a blocking dependency to the issue.\n- 'remove_blocked_by' - remove a blocking dependency from the issue.\n",
18+
"enum": [
19+
"add_blocked_by",
20+
"remove_blocked_by"
21+
],
22+
"type": "string"
23+
},
24+
"owner": {
25+
"description": "Repository owner",
26+
"type": "string"
27+
},
28+
"repo": {
29+
"description": "Repository name",
30+
"type": "string"
31+
}
32+
},
33+
"required": [
34+
"method",
35+
"owner",
36+
"repo",
37+
"issue_number",
38+
"issue_id"
39+
],
40+
"type": "object"
41+
},
42+
"name": "issue_dependency_write"
43+
}

pkg/github/__toolsnaps__/issue_read.snap

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
"type": "number"
1212
},
1313
"method": {
14-
"description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n",
14+
"description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n5. get_dependencies_blocked_by - Get dependencies that block the issue.\n6. get_dependencies_blocking - Get dependencies the issue is blocking.\n",
1515
"enum": [
1616
"get",
1717
"get_comments",
1818
"get_sub_issues",
19-
"get_labels"
19+
"get_labels",
20+
"get_dependencies_blocked_by",
21+
"get_dependencies_blocking"
2022
],
2123
"type": "string"
2224
},

pkg/github/helper_test.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,19 @@ const (
5454
GetReposCommitsCheckRunsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/check-runs"
5555

5656
// Issues endpoints
57-
GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}"
58-
GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments"
59-
PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
60-
PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
61-
PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"
62-
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
63-
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
64-
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue"
65-
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority"
57+
GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}"
58+
GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments"
59+
PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
60+
PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
61+
PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"
62+
GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by"
63+
PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by"
64+
DeleteReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumberByIssueID = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id}"
65+
GetReposIssuesDependenciesBlockingByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocking"
66+
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
67+
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
68+
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue"
69+
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority"
6670

6771
// Pull request endpoints
6872
GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls"

pkg/github/issues.go

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,10 @@ Options are:
214214
2. get_comments - Get issue comments.
215215
3. get_sub_issues - Get sub-issues of the issue.
216216
4. get_labels - Get labels assigned to the issue.
217+
5. get_dependencies_blocked_by - Get dependencies that block the issue.
218+
6. get_dependencies_blocking - Get dependencies the issue is blocking.
217219
`,
218-
Enum: []any{"get", "get_comments", "get_sub_issues", "get_labels"},
220+
Enum: []any{"get", "get_comments", "get_sub_issues", "get_labels", "get_dependencies_blocked_by", "get_dependencies_blocking"},
219221
},
220222
"owner": {
221223
Type: "string",
@@ -293,6 +295,12 @@ Options are:
293295
case "get_labels":
294296
result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
295297
return result, nil, err
298+
case "get_dependencies_blocked_by":
299+
result, err := GetIssueDependenciesBlockedBy(ctx, client, deps, owner, repo, issueNumber, pagination)
300+
return result, nil, err
301+
case "get_dependencies_blocking":
302+
result, err := GetIssueDependenciesBlocking(ctx, client, deps, owner, repo, issueNumber, pagination)
303+
return result, nil, err
296304
default:
297305
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
298306
}
@@ -477,6 +485,86 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc
477485
return utils.NewToolResultText(string(r)), nil
478486
}
479487

488+
func GetIssueDependenciesBlockedBy(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
489+
return getIssueDependencies(ctx, client, deps, owner, repo, issueNumber, "blocked_by", pagination)
490+
}
491+
492+
func GetIssueDependenciesBlocking(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
493+
return getIssueDependencies(ctx, client, deps, owner, repo, issueNumber, "blocking", pagination)
494+
}
495+
496+
func getIssueDependencies(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, relation string, pagination PaginationParams) (*mcp.CallToolResult, error) {
497+
cache, err := deps.GetRepoAccessCache(ctx)
498+
if err != nil {
499+
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
500+
}
501+
flags := deps.GetFlags(ctx)
502+
503+
path := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies/%s", owner, repo, issueNumber, relation)
504+
req, err := client.NewRequest(http.MethodGet, path, nil)
505+
if err != nil {
506+
return nil, fmt.Errorf("failed to create dependencies request: %w", err)
507+
}
508+
509+
q := req.URL.Query()
510+
if pagination.Page != 0 {
511+
q.Set("page", fmt.Sprintf("%d", pagination.Page))
512+
}
513+
if pagination.PerPage != 0 {
514+
q.Set("per_page", fmt.Sprintf("%d", pagination.PerPage))
515+
}
516+
req.URL.RawQuery = q.Encode()
517+
518+
var issues []*github.Issue
519+
resp, err := client.Do(ctx, req, &issues)
520+
if err != nil {
521+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue dependencies", resp, err), nil
522+
}
523+
defer func() { _ = resp.Body.Close() }()
524+
525+
if resp.StatusCode != http.StatusOK {
526+
body, err := io.ReadAll(resp.Body)
527+
if err != nil {
528+
return nil, fmt.Errorf("failed to read response body: %w", err)
529+
}
530+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue dependencies", resp, body), nil
531+
}
532+
533+
if flags.LockdownMode {
534+
if cache == nil {
535+
return nil, fmt.Errorf("lockdown cache is not configured")
536+
}
537+
filteredIssues := make([]*github.Issue, 0, len(issues))
538+
for _, issue := range issues {
539+
user := issue.GetUser()
540+
if user == nil {
541+
continue
542+
}
543+
login := user.GetLogin()
544+
if login == "" {
545+
continue
546+
}
547+
isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo)
548+
if err != nil {
549+
return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil
550+
}
551+
if isSafeContent {
552+
filteredIssues = append(filteredIssues, issue)
553+
}
554+
}
555+
issues = filteredIssues
556+
}
557+
558+
minimalIssues := make([]MinimalIssue, 0, len(issues))
559+
for _, issue := range issues {
560+
if issue != nil {
561+
minimalIssues = append(minimalIssues, convertToMinimalIssue(issue))
562+
}
563+
}
564+
565+
return MarshalledTextResult(minimalIssues), nil
566+
}
567+
480568
func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
481569
// Get current labels on the issue using GraphQL
482570
var query struct {
@@ -675,6 +763,140 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
675763
})
676764
}
677765

766+
// IssueDependencyWrite creates a tool to add or remove dependency edges for an issue.
767+
func IssueDependencyWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
768+
return NewTool(
769+
ToolsetMetadataIssues,
770+
mcp.Tool{
771+
Name: "issue_dependency_write",
772+
Description: t("TOOL_ISSUE_DEPENDENCY_WRITE_DESCRIPTION", "Add or remove issue dependencies in a GitHub repository."),
773+
Annotations: &mcp.ToolAnnotations{
774+
Title: t("TOOL_ISSUE_DEPENDENCY_WRITE_USER_TITLE", "Change issue dependency"),
775+
ReadOnlyHint: false,
776+
},
777+
InputSchema: &jsonschema.Schema{
778+
Type: "object",
779+
Properties: map[string]*jsonschema.Schema{
780+
"method": {
781+
Type: "string",
782+
Description: `The action to perform on an issue dependency.
783+
Options are:
784+
- 'add_blocked_by' - add a blocking dependency to the issue.
785+
- 'remove_blocked_by' - remove a blocking dependency from the issue.
786+
`,
787+
Enum: []any{"add_blocked_by", "remove_blocked_by"},
788+
},
789+
"owner": {
790+
Type: "string",
791+
Description: "Repository owner",
792+
},
793+
"repo": {
794+
Type: "string",
795+
Description: "Repository name",
796+
},
797+
"issue_number": {
798+
Type: "number",
799+
Description: "The issue number to modify",
800+
},
801+
"issue_id": {
802+
Type: "number",
803+
Description: "The ID of the related issue to add or remove as a blocking dependency",
804+
},
805+
},
806+
Required: []string{"method", "owner", "repo", "issue_number", "issue_id"},
807+
},
808+
},
809+
[]scopes.Scope{scopes.Repo},
810+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
811+
method, err := RequiredParam[string](args, "method")
812+
if err != nil {
813+
return utils.NewToolResultError(err.Error()), nil, nil
814+
}
815+
owner, err := RequiredParam[string](args, "owner")
816+
if err != nil {
817+
return utils.NewToolResultError(err.Error()), nil, nil
818+
}
819+
repo, err := RequiredParam[string](args, "repo")
820+
if err != nil {
821+
return utils.NewToolResultError(err.Error()), nil, nil
822+
}
823+
issueNumber, err := RequiredInt(args, "issue_number")
824+
if err != nil {
825+
return utils.NewToolResultError(err.Error()), nil, nil
826+
}
827+
issueID, err := RequiredInt(args, "issue_id")
828+
if err != nil {
829+
return utils.NewToolResultError(err.Error()), nil, nil
830+
}
831+
832+
client, err := deps.GetClient(ctx)
833+
if err != nil {
834+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
835+
}
836+
837+
switch strings.ToLower(method) {
838+
case "add_blocked_by":
839+
result, err := AddIssueDependencyBlockedBy(ctx, client, owner, repo, issueNumber, issueID)
840+
return result, nil, err
841+
case "remove_blocked_by":
842+
result, err := RemoveIssueDependencyBlockedBy(ctx, client, owner, repo, issueNumber, issueID)
843+
return result, nil, err
844+
default:
845+
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
846+
}
847+
})
848+
}
849+
850+
func AddIssueDependencyBlockedBy(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, issueID int) (*mcp.CallToolResult, error) {
851+
path := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies/blocked_by", owner, repo, issueNumber)
852+
req, err := client.NewRequest(http.MethodPost, path, map[string]int{"issue_id": issueID})
853+
if err != nil {
854+
return nil, fmt.Errorf("failed to create add dependency request: %w", err)
855+
}
856+
857+
var issue github.Issue
858+
resp, err := client.Do(ctx, req, &issue)
859+
if err != nil {
860+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue dependency", resp, err), nil
861+
}
862+
defer func() { _ = resp.Body.Close() }()
863+
864+
if resp.StatusCode != http.StatusCreated {
865+
body, err := io.ReadAll(resp.Body)
866+
if err != nil {
867+
return nil, fmt.Errorf("failed to read response body: %w", err)
868+
}
869+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add issue dependency", resp, body), nil
870+
}
871+
872+
return MarshalledTextResult(convertToMinimalIssue(&issue)), nil
873+
}
874+
875+
func RemoveIssueDependencyBlockedBy(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, issueID int) (*mcp.CallToolResult, error) {
876+
path := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies/blocked_by/%d", owner, repo, issueNumber, issueID)
877+
req, err := client.NewRequest(http.MethodDelete, path, nil)
878+
if err != nil {
879+
return nil, fmt.Errorf("failed to create remove dependency request: %w", err)
880+
}
881+
882+
var issue github.Issue
883+
resp, err := client.Do(ctx, req, &issue)
884+
if err != nil {
885+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue dependency", resp, err), nil
886+
}
887+
defer func() { _ = resp.Body.Close() }()
888+
889+
if resp.StatusCode != http.StatusOK {
890+
body, err := io.ReadAll(resp.Body)
891+
if err != nil {
892+
return nil, fmt.Errorf("failed to read response body: %w", err)
893+
}
894+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove issue dependency", resp, body), nil
895+
}
896+
897+
return MarshalledTextResult(convertToMinimalIssue(&issue)), nil
898+
}
899+
678900
// SubIssueWrite creates a tool to add a sub-issue to a parent issue.
679901
func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
680902
st := NewTool(

0 commit comments

Comments
 (0)