forgejo-mcp/operation/pull/resources.go
Christoph Görn 182898a2e3
fix: 🐛 request EmbeddedListCap+1 PR reviews to detect truncation
prResourceHandler called ListPullReviews with default options, so
Forgejo's server default PageSize (30 == EmbeddedListCap) silently
capped responses at the cap. The reviews_truncated/reviews_list_tool
sentinel never fired for PRs with more than 30 reviews. Same
pattern as ee1217d (status), 321b1a0 (issue comments), and 9e5b930
(PR comments).

Closes forgejo-mcp-5dz.
Closes the PR #172 review-fix umbrella (forgejo-mcp-ylb).
Ref PR #172 review https://codeberg.org/goern/forgejo-mcp/pulls/172#issuecomment-16022165
2026-05-28 16:18:20 +02:00

260 lines
7.7 KiB
Go

package pull
import (
"context"
"encoding/json"
"fmt"
"codeberg.org/goern/forgejo-mcp/v2/operation/resource"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// RegisterPullResources registers the forgejo://repo/{owner}/{repo}/pr/{index} resource template.
func RegisterPullResources(s *server.MCPServer) {
resource.RegisterTemplate(
s,
"forgejo://repo/{owner}/{repo}/pr/{index}",
"Forgejo Pull Request",
prResourceHandler,
mcp.WithTemplateDescription(
"Pull request metadata, head/base refs, mergeability, body sidecar, "+
"and bounded recent comments and reviews. "+
"URI: forgejo://repo/{owner}/{repo}/pr/{index}.",
),
mcp.WithTemplateMIMEType("application/json"),
)
log.Debug("Registered pull request resource template")
}
type prBranchRef struct {
Label string `json:"label"`
Ref string `json:"ref"`
SHA string `json:"sha"`
}
type prCommentRef struct {
ID int64 `json:"id"`
Author string `json:"author"`
CreatedAt string `json:"created_at"`
BodyExcerpt string `json:"body_excerpt"`
}
type prReviewRef struct {
ID int64 `json:"id"`
Reviewer string `json:"reviewer"`
State string `json:"state"`
SubmittedAt string `json:"submitted_at"`
BodyExcerpt string `json:"body_excerpt"`
}
type prResourcePayload struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Index int64 `json:"index"`
Title string `json:"title"`
State string `json:"state"` // "open", "closed", "merged"
Author string `json:"author"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ClosedAt string `json:"closed_at,omitempty"`
MergedAt string `json:"merged_at,omitempty"`
Mergeable bool `json:"mergeable"`
Labels []string `json:"labels"`
Assignees []string `json:"assignees"`
Milestone string `json:"milestone,omitempty"`
Head prBranchRef `json:"head"`
Base prBranchRef `json:"base"`
CommentCount int `json:"comment_count"`
ReviewCount int `json:"review_count"`
RecentComments []prCommentRef `json:"recent_comments"`
CommentsTruncated bool `json:"comments_truncated,omitempty"`
CommentsListTool string `json:"comments_list_tool,omitempty"`
RecentReviews []prReviewRef `json:"recent_reviews"`
ReviewsTruncated bool `json:"reviews_truncated,omitempty"`
ReviewsListTool string `json:"reviews_list_tool,omitempty"`
}
func prResourceHandler(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
params, err := resource.ParsePR(uri)
if err != nil {
return nil, resource.MapForgejoError(uri, err)
}
client, err := forgejo.Client(ctx)
if err != nil {
return nil, fmt.Errorf("forgejo client: %w", err)
}
pr, resp, err := client.GetPullRequest(params.Owner, params.Repo, params.Index)
if err != nil {
if resp != nil {
return nil, resource.MapForgejoError(uri, fmt.Errorf("%d %s", resp.StatusCode, err.Error()))
}
return nil, resource.MapForgejoError(uri, err)
}
// fetch comments (PR comments live on the issue-comments endpoint)
comments, _, _ := client.ListIssueComments(params.Owner, params.Repo, params.Index, forgejo_sdk.ListIssueCommentOptions{
ListOptions: forgejo_sdk.ListOptions{PageSize: resource.EmbeddedListCap + 1},
})
// fetch reviews
reviews, _, _ := client.ListPullReviews(params.Owner, params.Repo, params.Index, forgejo_sdk.ListPullReviewsOptions{
ListOptions: forgejo_sdk.ListOptions{PageSize: resource.EmbeddedListCap + 1},
})
// bound comments
commentItems := make([]string, len(comments))
commentRefs := make([]prCommentRef, len(comments))
for i, c := range comments {
author := ""
if c.Poster != nil {
author = c.Poster.UserName
}
excerpt := c.Body
if len(excerpt) > 200 {
excerpt = excerpt[:200] + "…"
}
commentRefs[i] = prCommentRef{
ID: c.ID,
Author: author,
CreatedAt: c.Created.Format("2006-01-02T15:04:05Z07:00"),
BodyExcerpt: excerpt,
}
commentItems[i] = fmt.Sprintf("%d", c.ID)
}
boundedComments := resource.Bounded(commentItems, resource.EmbeddedListCap, "list_issue_comments")
boundedCommentRefs := commentRefs
if boundedComments.Truncated {
boundedCommentRefs = commentRefs[:resource.EmbeddedListCap]
}
// bound reviews
reviewItems := make([]string, len(reviews))
reviewRefs := make([]prReviewRef, len(reviews))
for i, r := range reviews {
reviewer := ""
if r.Reviewer != nil {
reviewer = r.Reviewer.UserName
}
excerpt := r.Body
if len(excerpt) > 200 {
excerpt = excerpt[:200] + "…"
}
reviewRefs[i] = prReviewRef{
ID: r.ID,
Reviewer: reviewer,
State: string(r.State),
SubmittedAt: r.Submitted.Format("2006-01-02T15:04:05Z07:00"),
BodyExcerpt: excerpt,
}
reviewItems[i] = fmt.Sprintf("%d", r.ID)
}
boundedReviews := resource.Bounded(reviewItems, resource.EmbeddedListCap, "list_pull_reviews")
boundedReviewRefs := reviewRefs
if boundedReviews.Truncated {
boundedReviewRefs = reviewRefs[:resource.EmbeddedListCap]
}
// compute state string
state := string(pr.State)
if pr.HasMerged {
state = "merged"
}
author := ""
if pr.Poster != nil {
author = pr.Poster.UserName
}
labels := make([]string, 0, len(pr.Labels))
for _, l := range pr.Labels {
labels = append(labels, l.Name)
}
assignees := make([]string, 0, len(pr.Assignees))
for _, a := range pr.Assignees {
assignees = append(assignees, a.UserName)
}
milestone := ""
if pr.Milestone != nil {
milestone = pr.Milestone.Title
}
createdAt, updatedAt, closedAt, mergedAt := "", "", "", ""
if pr.Created != nil {
createdAt = pr.Created.Format("2006-01-02T15:04:05Z07:00")
}
if pr.Updated != nil {
updatedAt = pr.Updated.Format("2006-01-02T15:04:05Z07:00")
}
if pr.Closed != nil {
closedAt = pr.Closed.Format("2006-01-02T15:04:05Z07:00")
}
if pr.Merged != nil {
mergedAt = pr.Merged.Format("2006-01-02T15:04:05Z07:00")
}
headRef, baseRef := prBranchRef{}, prBranchRef{}
if pr.Head != nil {
headRef = prBranchRef{Label: pr.Head.Name, Ref: pr.Head.Ref, SHA: pr.Head.Sha}
}
if pr.Base != nil {
baseRef = prBranchRef{Label: pr.Base.Name, Ref: pr.Base.Ref, SHA: pr.Base.Sha}
}
payload := prResourcePayload{
Owner: params.Owner,
Repo: params.Repo,
Index: params.Index,
Title: pr.Title,
State: state,
Author: author,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ClosedAt: closedAt,
MergedAt: mergedAt,
Mergeable: pr.Mergeable,
Labels: labels,
Assignees: assignees,
Milestone: milestone,
Head: headRef,
Base: baseRef,
CommentCount: pr.Comments,
ReviewCount: len(reviews),
RecentComments: boundedCommentRefs,
CommentsTruncated: boundedComments.Truncated,
RecentReviews: boundedReviewRefs,
ReviewsTruncated: boundedReviews.Truncated,
}
if boundedComments.Truncated {
payload.CommentsListTool = "list_issue_comments"
}
if boundedReviews.Truncated {
payload.ReviewsListTool = "list_pull_reviews"
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal PR payload: %w", err)
}
mdSidecar := fmt.Sprintf("# %s\n%s · #%d · %s · %s\nhead: %s · base: %s\n\n%s",
pr.Title, state, params.Index, author, createdAt,
headRef.Label, baseRef.Label, pr.Body)
return []mcp.ResourceContents{
mcp.TextResourceContents{URI: uri, MIMEType: "application/json", Text: string(jsonBytes)},
mcp.TextResourceContents{URI: uri, MIMEType: "text/markdown", Text: mdSidecar},
}, nil
}