Compare commits

...
2 Commits
Author SHA1 Message Date
gronod 07949c1a63 fix(issue): satisfy embedlit lint in check_blocked
check-and-test / check-and-test (pull_request) Successful in 4m23s
release-nightly / release-image (push) Successful in 3m8s
Use the same ListOptions local as the other list helpers instead of
embedding gitea_sdk.ListOptions in the composite literal.
2026-09-16 08:20:35 +00:00
gronod 3e093c10b9 feat(issue): support cross-repo dependencies and check_blocked
check-and-test / check-and-test (pull_request) Failing after 2m25s
Send blocked_owner/blocked_repo and blocking_owner/blocking_repo in the
IssueMeta body so Gitea can resolve cross-repository links. Default those
fields to the path issue's owner/repo.

Add issue_read method check_blocked to summarize open blockers, and
document that dependency endpoints 404 when the repo has issue
dependencies disabled.
2026-09-16 08:03:23 +00:00
4 changed files with 117 additions and 12 deletions
+2 -2
View File
@@ -160,8 +160,8 @@ Once configured, try `list all my repositories` in the chat box.
| package_write | packages | Write | Delete a package version (irreversible) | | package_write | packages | Write | Delete a package version (irreversible) |
| list_issues | issue | Read | List repository issues | | list_issues | issue | Read | List repository issues |
| attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content | | attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content |
| issue_read | issue | Read | Read issue: details, comments, labels, or blocking relationships | | issue_read | issue | Read | Read issue: details, comments, labels, or dependency/blocking links |
| issue_write | issue | Write | Write issues: create, update, manage comments, labels, and blocking relationships | | issue_write | issue | Write | Write issues: create, update, manage comments, labels, and dependency/blocking links |
| list_pull_requests | pull_request | Read | List repository pull requests | | list_pull_requests | pull_request | Read | List repository pull requests |
| pull_request_read | pull_request | Read | Read pull request: details, diff, files, status, reviews, review comments | | pull_request_read | pull_request | Read | Read pull request: details, diff, files, status, reviews, review comments |
| pull_request_write | pull_request | Write | Write pull requests: create, update, close, reopen, merge, update branch, manage reviewers | | pull_request_write | pull_request | Write | Write pull requests: create, update, close, reopen, merge, update branch, manage reviewers |
+49 -2
View File
@@ -59,13 +59,14 @@ func setIssueBlockingFn(ctx context.Context, args map[string]any, block bool) (*
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
blockedOwner, blockedRepo := relatedIssueRepo(args, "blocked_owner", "blocked_repo", owner, repo)
method := http.MethodPost method := http.MethodPost
if !block { if !block {
method = http.MethodDelete method = http.MethodDelete
} }
var issue gitea_sdk.Issue var issue gitea_sdk.Issue
path := fmt.Sprintf("repos/%s/%s/issues/%d/blocks", url.PathEscape(owner), url.PathEscape(repo), index) path := fmt.Sprintf("repos/%s/%s/issues/%d/blocks", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, method, path, nil, issueMeta{Index: blocked, Owner: owner, Repo: repo}, &issue); err != nil { if _, err := gitea.DoJSON(ctx, method, path, nil, issueMeta{Index: blocked, Owner: blockedOwner, Repo: blockedRepo}, &issue); err != nil {
return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/blocks err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/blocks err: %v", owner, repo, index, err))
} }
return to.TextResult(slimIssue(&issue)) return to.TextResult(slimIssue(&issue))
@@ -108,18 +109,64 @@ func setIssueDependencyFn(ctx context.Context, args map[string]any, add bool) (*
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
blockingOwner, blockingRepo := relatedIssueRepo(args, "blocking_owner", "blocking_repo", owner, repo)
method := http.MethodPost method := http.MethodPost
if !add { if !add {
method = http.MethodDelete method = http.MethodDelete
} }
var issue gitea_sdk.Issue var issue gitea_sdk.Issue
path := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies", url.PathEscape(owner), url.PathEscape(repo), index) path := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, method, path, nil, issueMeta{Index: blocking, Owner: owner, Repo: repo}, &issue); err != nil { if _, err := gitea.DoJSON(ctx, method, path, nil, issueMeta{Index: blocking, Owner: blockingOwner, Repo: blockingRepo}, &issue); err != nil {
return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err))
} }
return to.TextResult(slimIssue(&issue)) return to.TextResult(slimIssue(&issue))
} }
func checkIssueBlockedFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, repo, index, err := issueRef(args)
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
listOpt := gitea_sdk.ListOptions{Page: page, PageSize: pageSize}
issues, _, err := client.Issues.ListIssueDependencies(ctx, owner, repo, index, gitea_sdk.ListIssueDependenciesOptions{
ListOptions: listOpt,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err))
}
openBlockers := make([]map[string]any, 0)
for _, issue := range issues {
if issue == nil {
continue
}
if issue.State == gitea_sdk.StateOpen || issue.State == "" {
openBlockers = append(openBlockers, slimIssue(issue))
}
}
return to.TextResult(map[string]any{
"blocked": len(openBlockers) > 0,
"total_dependencies": len(issues),
"open_blockers": len(openBlockers),
"blockers": slimIssues(issues),
"open": openBlockers,
})
}
func relatedIssueRepo(args map[string]any, ownerKey, repoKey, owner, repo string) (string, string) {
if o := params.GetOptionalString(args, ownerKey, ""); o != "" {
owner = o
}
if r := params.GetOptionalString(args, repoKey, ""); r != "" {
repo = r
}
return owner, repo
}
func issueRef(args map[string]any) (owner, repo string, index int64, err error) { func issueRef(args map[string]any) (owner, repo string, index int64, err error) {
if owner, err = params.GetString(args, "owner"); err != nil { if owner, err = params.GetString(args, "owner"); err != nil {
return "", "", 0, err return "", "", 0, err
+57 -5
View File
@@ -31,6 +31,9 @@ func Test_issueBlocking(t *testing.T) {
wantMethod string wantMethod string
wantQuery string wantQuery string
wantIndex float64 wantIndex float64
wantOwner string
wantRepo string
wantBody string
}{ }{
{ {
name: "list blocks paginates", name: "list blocks paginates",
@@ -47,6 +50,22 @@ func Test_issueBlocking(t *testing.T) {
wantPath: blocksPath, wantPath: blocksPath,
wantMethod: http.MethodPost, wantMethod: http.MethodPost,
wantIndex: 7, wantIndex: 7,
wantOwner: owner,
wantRepo: repo,
},
{
name: "block issue posts cross-repo target",
fn: blockIssueFn,
args: map[string]any{
"blocked_issue": float64(7),
"blocked_owner": "other",
"blocked_repo": "elsewhere",
},
wantPath: blocksPath,
wantMethod: http.MethodPost,
wantIndex: 7,
wantOwner: "other",
wantRepo: "elsewhere",
}, },
{ {
name: "unblock issue deletes with index", name: "unblock issue deletes with index",
@@ -55,6 +74,8 @@ func Test_issueBlocking(t *testing.T) {
wantPath: blocksPath, wantPath: blocksPath,
wantMethod: http.MethodDelete, wantMethod: http.MethodDelete,
wantIndex: 7, wantIndex: 7,
wantOwner: owner,
wantRepo: repo,
}, },
{ {
name: "list dependencies paginates", name: "list dependencies paginates",
@@ -71,6 +92,22 @@ func Test_issueBlocking(t *testing.T) {
wantPath: depsPath, wantPath: depsPath,
wantMethod: http.MethodPost, wantMethod: http.MethodPost,
wantIndex: 9, wantIndex: 9,
wantOwner: owner,
wantRepo: repo,
},
{
name: "add dependency posts cross-repo target",
fn: addIssueDependencyFn,
args: map[string]any{
"blocking_issue": float64(9),
"blocking_owner": "other",
"blocking_repo": "elsewhere",
},
wantPath: depsPath,
wantMethod: http.MethodPost,
wantIndex: 9,
wantOwner: "other",
wantRepo: "elsewhere",
}, },
{ {
name: "remove dependency deletes with index", name: "remove dependency deletes with index",
@@ -79,6 +116,17 @@ func Test_issueBlocking(t *testing.T) {
wantPath: depsPath, wantPath: depsPath,
wantMethod: http.MethodDelete, wantMethod: http.MethodDelete,
wantIndex: 9, wantIndex: 9,
wantOwner: owner,
wantRepo: repo,
},
{
name: "check blocked summarizes open dependencies",
fn: checkIssueBlockedFn,
args: map[string]any{"page": float64(1), "per_page": float64(30)},
wantPath: depsPath,
wantMethod: http.MethodGet,
wantQuery: "limit=30&page=1",
wantBody: `"blocked":true`,
}, },
} }
@@ -139,15 +187,19 @@ func Test_issueBlocking(t *testing.T) {
if gotBody["index"] != tt.wantIndex { if gotBody["index"] != tt.wantIndex {
t.Fatalf("body index = %v, want %v", gotBody["index"], tt.wantIndex) t.Fatalf("body index = %v, want %v", gotBody["index"], tt.wantIndex)
} }
if gotBody["owner"] != owner { if gotBody["owner"] != tt.wantOwner {
t.Fatalf("body owner = %v, want %s", gotBody["owner"], owner) t.Fatalf("body owner = %v, want %s", gotBody["owner"], tt.wantOwner)
} }
if gotBody["repo"] != repo { if gotBody["repo"] != tt.wantRepo {
t.Fatalf("body repo = %v, want %s", gotBody["repo"], repo) t.Fatalf("body repo = %v, want %s", gotBody["repo"], tt.wantRepo)
} }
} }
body := res.Content[0].(*mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"number":7`) && !strings.Contains(body, `"number": 7`) { if tt.wantBody != "" {
if !strings.Contains(body, tt.wantBody) {
t.Fatalf("expected %s in output, got: %s", tt.wantBody, body)
}
} else if !strings.Contains(body, `"number":7`) && !strings.Contains(body, `"number": 7`) {
t.Fatalf("expected slim issue in output, got: %s", body) t.Fatalf("expected slim issue in output, got: %s", body)
} }
}) })
+9 -3
View File
@@ -55,9 +55,9 @@ var (
IssueReadTool = tool.NewDefinition( IssueReadTool = tool.NewDefinition(
IssueReadToolName, IssueReadToolName,
"Read issue: details, comments, labels, or blocking relationships.", "Read issue: details, comments, labels, or dependency/blocking links. list_dependencies is issues that block this one (blocked-by); list_blocks is issues this one blocks. check_blocked summarizes whether any of those dependencies are still open. These methods 404 if the repo has issue dependencies disabled.",
annotation.ReadOnly("Read issue details"), annotation.ReadOnly("Read issue details"),
tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "list_blocks", "list_dependencies")), tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "list_blocks", "list_dependencies", "check_blocked")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.Number("issue_number", tool.Required()), tool.Number("issue_number", tool.Required()),
@@ -67,7 +67,7 @@ var (
IssueWriteTool = tool.NewDefinition( IssueWriteTool = tool.NewDefinition(
IssueWriteToolName, IssueWriteToolName,
"Write issues: create, update, manage comments, labels, and blocking relationships.", "Write issues: create, update, manage comments, labels, and dependency/blocking links. block_issue makes issue_number block blocked_issue; add_dependency makes issue_number depend on blocking_issue. Cross-repo links use blocked_owner/blocked_repo or blocking_owner/blocking_repo (instance must allow cross-repository dependencies). Repo must have issue dependencies enabled or Gitea returns 404.",
annotation.Write("Create or update issues, comments, and labels"), annotation.Write("Create or update issues, comments, and labels"),
tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels", "block_issue", "unblock_issue", "add_dependency", "remove_dependency")), tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels", "block_issue", "unblock_issue", "add_dependency", "remove_dependency")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
@@ -85,7 +85,11 @@ var (
tool.String("deadline", tool.Description("ISO 8601")), tool.String("deadline", tool.Description("ISO 8601")),
tool.Boolean("remove_deadline"), tool.Boolean("remove_deadline"),
tool.Number("blocked_issue", tool.Description("issue number that issue_number blocks (required for 'block_issue'/'unblock_issue')")), tool.Number("blocked_issue", tool.Description("issue number that issue_number blocks (required for 'block_issue'/'unblock_issue')")),
tool.String("blocked_owner", tool.Description("owner of blocked_issue; defaults to owner")),
tool.String("blocked_repo", tool.Description("repo of blocked_issue; defaults to repo")),
tool.Number("blocking_issue", tool.Description("issue number that blocks issue_number (required for 'add_dependency'/'remove_dependency')")), tool.Number("blocking_issue", tool.Description("issue number that blocks issue_number (required for 'add_dependency'/'remove_dependency')")),
tool.String("blocking_owner", tool.Description("owner of blocking_issue; defaults to owner")),
tool.String("blocking_repo", tool.Description("repo of blocking_issue; defaults to repo")),
) )
) )
@@ -120,6 +124,8 @@ func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult,
return listIssueBlocksFn(ctx, args) return listIssueBlocksFn(ctx, args)
case "list_dependencies": case "list_dependencies":
return listIssueDependenciesFn(ctx, args) return listIssueDependenciesFn(ctx, args)
case "check_blocked":
return checkIssueBlockedFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }