From 1235e587968486dc159db474ed50f8134a2ec440 Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Tue, 4 Nov 2025 22:18:35 -0500 Subject: [PATCH 1/3] pagination, prompt updates --- README.md | 66 ++- .../__toolsnaps__/list_project_fields.snap | 10 +- .../__toolsnaps__/list_project_items.snap | 16 +- pkg/github/__toolsnaps__/list_projects.snap | 14 +- pkg/github/instructions.go | 39 ++ pkg/github/projects.go | 507 +++++++++++------- pkg/github/projects_test.go | 92 ++-- 7 files changed, 505 insertions(+), 239 deletions(-) diff --git a/README.md b/README.md index 2e896cea8..94a8f9134 100644 --- a/README.md +++ b/README.md @@ -830,24 +830,78 @@ Options are: - `project_number`: The project's number. (number, required) - **list_project_fields** - List project fields + - `after`: Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep per_page identical for every page. (string, optional) + - `before`: Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration. (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - - `per_page`: Number of results per page (max 100, default: 30) (number, optional) + - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional) - `project_number`: The project's number. (number, required) - **list_project_items** - List project items - - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) + - `after`: Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page. (string, optional) + - `before`: Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration. (string, optional) + - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first. (string[], optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - - `per_page`: Number of results per page (max 100, default: 30) (number, optional) + - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional) - `project_number`: The project's number. (number, required) - - `query`: Search query to filter items (string, optional) + - `query`: Query string - For advanced filtering of project items using GitHub's search syntax: + +MUST reflect user intent; strongly prefer explicit content type if narrowed: + - "open issues" → state:open is:issue + - "merged PRs" → state:merged is:pr + - "items updated this week" → updated:>@today-7d (omit type only if mixed desired) + - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user specifies "items") + - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies "issues" or "PRs") + - "all open issues I'm working on" → is:issue state:open assignee:@me + +Query Construction Heuristics: + a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity) + b. Map temporal phrases: "this week" → updated:>@today-7d + c. Map negations: "excluding wontfix" → -label:wontfix + d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence) + e. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. "bug 🐛" → label:*bug*) + f. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. "v1.0" → milestone:*v1.0*) + +Syntax Essentials (items): + AND: space-separated. (label:bug priority:high). + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). + Quote multi-word values. (status:"In Review" team-name:"Backend Team"). + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + Assigned to User: assignee:@me | assignee:username | no:assignee + +Common Qualifier Glossary (items): + is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE | + priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" | + updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label + +Pagination Mandate: + Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page. + +Recovery Guidance: + If user provides ambiguous request ("show project activity") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail. + +Never: + - Infer field IDs; fetch via list_project_fields. + - Drop 'fields' param on subsequent pages if field values are needed. (string, optional) - **list_projects** - List projects + - `after`: Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query and per_page identical for every page. (string, optional) + - `before`: Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration. (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - - `query`: Filter projects by a search query (matches title and description) (string, optional) + - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional) + - `query`: Filter projects by a search query + +Scope: title text + open/closed state. +PERMITTED qualifiers: is:open, is:closed (state), simple title terms. +FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc. +Examples: + - roadmap is:open + - is:open feature planning (string, optional) - **update_project_item** - Update project item - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap index 0a2180e2b..ec38d5568 100644 --- a/pkg/github/__toolsnaps__/list_project_fields.snap +++ b/pkg/github/__toolsnaps__/list_project_fields.snap @@ -6,6 +6,14 @@ "description": "List Project fields for a user or org", "inputSchema": { "properties": { + "after": { + "description": "Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep per_page identical for every page.", + "type": "string" + }, + "before": { + "description": "Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration.", + "type": "string" + }, "owner": { "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", "type": "string" @@ -19,7 +27,7 @@ "type": "string" }, "per_page": { - "description": "Number of results per page (max 100, default: 30)", + "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", "type": "number" }, "project_number": { diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap index ebc7d17df..f8eb5bfa5 100644 --- a/pkg/github/__toolsnaps__/list_project_items.snap +++ b/pkg/github/__toolsnaps__/list_project_items.snap @@ -3,11 +3,19 @@ "title": "List project items", "readOnlyHint": true }, - "description": "List Project items for a user or org", + "description": "Search project items with advanced filtering", "inputSchema": { "properties": { + "after": { + "description": "Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page.", + "type": "string" + }, + "before": { + "description": "Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration.", + "type": "string" + }, "fields": { - "description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", + "description": "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first.", "items": { "type": "string" }, @@ -26,7 +34,7 @@ "type": "string" }, "per_page": { - "description": "Number of results per page (max 100, default: 30)", + "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", "type": "number" }, "project_number": { @@ -34,7 +42,7 @@ "type": "number" }, "query": { - "description": "Search query to filter items", + "description": "Query string - For advanced filtering of project items using GitHub's search syntax:\n\nMUST reflect user intent; strongly prefer explicit content type if narrowed:\n\t- \"open issues\" → state:open is:issue\n\t- \"merged PRs\" → state:merged is:pr\n\t- \"items updated this week\" → updated:\u003e@today-7d (omit type only if mixed desired)\n\t- \"list all P1 priority items\" → priority:p1 (omit state if user wants all, omit type if user specifies \"items\")\n\t- \"list all open P2 issues\" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies \"issues\" or \"PRs\")\n\t- \"all open issues I'm working on\" → is:issue state:open assignee:@me\n\nQuery Construction Heuristics:\n\ta. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)\n\tb. Map temporal phrases: \"this week\" → updated:\u003e@today-7d\n\tc. Map negations: \"excluding wontfix\" → -label:wontfix\n\td. Map priority adjectives: \"high/sev1/p1\" → priority:high OR priority:p1 (choose based on field presence)\n\te. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. \"bug 🐛\" → label:*bug*)\n\tf. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. \"v1.0\" → milestone:*v1.0*)\n\nSyntax Essentials (items):\n AND: space-separated. (label:bug priority:high).\n OR: comma inside one qualifier (label:bug,critical).\n NOT: leading '-' (-label:wontfix).\n Hyphenate multi-word field names. (team-name:\"Backend Team\", story-points:\u003e5).\n Quote multi-word values. (status:\"In Review\" team-name:\"Backend Team\").\n Ranges: points:1..3, updated:\u003c@today-30d.\n Wildcards: title:*crash*, label:bug*.\n\t Assigned to User: assignee:@me | assignee:username | no:assignee\n\nCommon Qualifier Glossary (items):\n is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE |\n priority:p1|high | sprint-name:@current | team-name:\"Backend Team\" | parent-issue:\"org/repo#123\" |\n updated:\u003e@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label\n\nPagination Mandate:\n Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page.\n\nRecovery Guidance:\n If user provides ambiguous request (\"show project activity\") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail.\n\nNever:\n - Infer field IDs; fetch via list_project_fields.\n - Drop 'fields' param on subsequent pages if field values are needed.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap index 8de28989a..8725f554e 100644 --- a/pkg/github/__toolsnaps__/list_projects.snap +++ b/pkg/github/__toolsnaps__/list_projects.snap @@ -3,9 +3,17 @@ "title": "List projects", "readOnlyHint": true }, - "description": "List Projects for a user or org", + "description": "List Projects for a user or organization", "inputSchema": { "properties": { + "after": { + "description": "Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query and per_page identical for every page.", + "type": "string" + }, + "before": { + "description": "Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration.", + "type": "string" + }, "owner": { "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", "type": "string" @@ -19,11 +27,11 @@ "type": "string" }, "per_page": { - "description": "Number of results per page (max 100, default: 30)", + "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", "type": "number" }, "query": { - "description": "Filter projects by a search query (matches title and description)", + "description": "Filter projects by a search query\n\t\t\t\t\nScope: title text + open/closed state.\nPERMITTED qualifiers: is:open, is:closed (state), simple title terms.\nFORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc.\nExamples:\n\t- roadmap is:open\n\t- is:open feature planning", "type": "string" } }, diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go index e783c6c08..71e7dd812 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/instructions.go @@ -62,6 +62,45 @@ Check 'list_issue_types' first for organizations to use proper issue types. Use return `## Discussions Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.` + case "projects": + return `## Projects + +When using 'list_project_items', follow these guidelines: + + Field usage: + - Call list_project_fields first to understand available fields and get IDs/types before filtering. + - Use EXACT returned field names (case-insensitive match). Don't invent names or IDs. + - Iteration synonyms (sprint/cycle/iteration) only if that field exists; map to the actual name (e.g. sprint:@current). + - Only include filters for fields that exist and are relevant. + + Pagination (mandatory): + - Loop while pageInfo.hasNextPage=true using after=nextCursor. Keep query, fields, per_page IDENTICAL each page. + + Fields parameter: + - Include field IDs on EVERY paginated list_project_items call if you need values. Omit → title only. + + Counting rules: + - Count items array length after full pagination. + - If multi-page: collect all pages, dedupe by item.id (fallback node_id) before totals. + - Never count field objects, content, or nested arrays as separate items. + - item.id = project item ID (for updates/deletes). item.content.id = underlying issue/PR ID. + + Summary vs list: + - Summaries ONLY if user uses verbs: analyze | summarize | summary | report | overview | insights. + - Listing verbs (list/show/get/fetch/display/enumerate) → just enumerate + total. + + Examples: + - list_projects: "roadmap is:open" + - list_project_items: state:open is:issue sprint:@current priority:high updated:>@today-7d + + Self-check before returning: + - Paginated fully + - Dedupe by id/node_id + - Correct IDs used + - Field names valid + - Summary only if requested. + + Return COMPLETE data or state what's missing (e.g. pages skipped).` default: return "" } diff --git a/pkg/github/projects.go b/pkg/github/projects.go index eee4bcb6c..cfc9b57e6 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -23,11 +23,12 @@ const ( ProjectAddFailedError = "failed to add a project item" ProjectDeleteFailedError = "failed to delete a project item" ProjectListFailedError = "failed to list project items" + MaxProjectsPerPage = 50 ) func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_projects", - mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")), + mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), ReadOnlyHint: ToBoolPtr(true), @@ -40,28 +41,45 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithString("query", - mcp.Description("Filter projects by a search query (matches title and description)"), + mcp.Description(`Filter projects by a search query + +Scope: title text + open/closed state. +PERMITTED qualifiers: is:open, is:closed (state), simple title terms. +FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc. +Examples: + - roadmap is:open + - is:open feature planning`), ), mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), + mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)), + ), + mcp.WithString("after", + mcp.Description("Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query and per_page identical for every page."), + ), + mcp.WithString("before", + mcp.Description("Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + queryStr, err := OptionalParam[string](req, "query") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + + pagination, err := extractPaginationOptions(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -72,8 +90,12 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: perPage}, - Query: queryStr, + ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{ + PerPage: pagination.PerPage, + After: pagination.After, + Before: pagination.Before, + }, + Query: queryStr, } if ownerType == "org" { @@ -95,14 +117,12 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) } - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil + response := map[string]any{ + "projects": minimalProjects, + "pageInfo": buildPageInfo(resp), } - r, err := json.Marshal(minimalProjects) + + r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -208,25 +228,35 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu mcp.Description("The project's number."), ), mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), + mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)), + ), + mcp.WithString("after", + mcp.Description("Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep per_page identical for every page."), + ), + mcp.WithString("before", + mcp.Description("Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + + pagination, err := extractPaginationOptions(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -238,11 +268,8 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu } else { url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber) } - projectFields := []projectV2Field{} - opts := paginationOptions{PerPage: perPage} - - url, err = addOptions(url, opts) + url, err = addOptions(url, pagination) if err != nil { return nil, fmt.Errorf("failed to add options to request: %w", err) } @@ -252,6 +279,8 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu return nil, fmt.Errorf("failed to create request: %w", err) } + projectFields := []*projectV2Field{} + resp, err := client.Do(ctx, httpRequest, &projectFields) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -262,14 +291,12 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to list project fields: %s", string(body))), nil + response := map[string]any{ + "fields": projectFields, + "pageInfo": buildPageInfo(resp), } - r, err := json.Marshal(projectFields) + + r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -363,7 +390,7 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_project_items", - mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), + mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), ReadOnlyHint: ToBoolPtr(true), @@ -381,13 +408,60 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("The project's number."), ), mcp.WithString("query", - mcp.Description("Search query to filter items"), + mcp.Description(`Query string - For advanced filtering of project items using GitHub's search syntax: + +MUST reflect user intent; strongly prefer explicit content type if narrowed: + - "open issues" → state:open is:issue + - "merged PRs" → state:merged is:pr + - "items updated this week" → updated:>@today-7d (omit type only if mixed desired) + - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user specifies "items") + - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies "issues" or "PRs") + - "all open issues I'm working on" → is:issue state:open assignee:@me + +Query Construction Heuristics: + a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity) + b. Map temporal phrases: "this week" → updated:>@today-7d + c. Map negations: "excluding wontfix" → -label:wontfix + d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence) + e. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. "bug 🐛" → label:*bug*) + f. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. "v1.0" → milestone:*v1.0*) + +Syntax Essentials (items): + AND: space-separated. (label:bug priority:high). + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). + Quote multi-word values. (status:"In Review" team-name:"Backend Team"). + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + Assigned to User: assignee:@me | assignee:username | no:assignee + +Common Qualifier Glossary (items): + is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE | + priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" | + updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label + +Pagination Mandate: + Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page. + +Recovery Guidance: + If user provides ambiguous request ("show project activity") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail. + +Never: + - Infer field IDs; fetch via list_project_fields. + - Drop 'fields' param on subsequent pages if field values are needed.`), ), mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), + mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)), + ), + mcp.WithString("after", + mcp.Description("Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page."), + ), + mcp.WithString("before", + mcp.Description("Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration."), ), mcp.WithArray("fields", - mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), + mcp.Description("Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first."), mcp.WithStringItems(), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -395,23 +469,28 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + + queryStr, err := OptionalParam[string](req, "query") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - queryStr, err := OptionalParam[string](req, "query") + + fields, err := OptionalStringArrayParam(req, "fields") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - fields, err := OptionalStringArrayParam(req, "fields") + + pagination, err := extractPaginationOptions(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -427,12 +506,13 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun } else { url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) } + projectItems := []projectV2Item{} opts := listProjectItemsOptions{ - paginationOptions: paginationOptions{PerPage: perPage}, + paginationOptions: pagination, filterQueryOptions: filterQueryOptions{Query: queryStr}, - fieldSelectionOptions: fieldSelectionOptions{Fields: fields}, + fieldSelectionOptions: fieldSelectionOptions{Fields: strings.Join(fields, ",")}, } url, err = addOptions(url, opts) @@ -455,15 +535,12 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectListFailedError, string(body))), nil + response := map[string]any{ + "items": projectItems, + "pageInfo": buildPageInfo(resp), } - r, err := json.Marshal(projectItems) + r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -505,18 +582,22 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + itemID, err := RequiredInt(req, "item_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + fields, err := OptionalStringArrayParam(req, "fields") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -537,7 +618,7 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) opts := fieldSelectionOptions{} if len(fields) > 0 { - opts.Fields = fields + opts.Fields = strings.Join(fields, ",") } url, err = addOptions(url, opts) @@ -562,13 +643,6 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get project item: %s", string(body))), nil - } r, err := json.Marshal(projectItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) @@ -879,21 +953,22 @@ type updateProjectItem struct { } type projectV2Field struct { - ID *int64 `json:"id,omitempty"` // The unique identifier for this field. - NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field. - Name string `json:"name,omitempty"` // The display name of the field. - DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). - URL string `json:"url,omitempty"` // The API URL for this field. - Options []*any `json:"options,omitempty"` // Available options for single_select and multi_select fields. - CreatedAt *github.Timestamp `json:"created_at,omitempty"` // The time when this field was created. - UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated. + ID *int64 `json:"id,omitempty"` + NodeID string `json:"node_id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` + URL string `json:"url,omitempty"` + Options []*any `json:"options,omitempty"` // For single-select fields + Configuration *any `json:"configuration,omitempty"` // For iteration fields + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` } type projectV2ItemFieldValue struct { - ID *int64 `json:"id,omitempty"` // The unique identifier for this field. - Name string `json:"name,omitempty"` // The display name of the field. - DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). - Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item. + ID *int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` + Value any `json:"value,omitempty"` } type projectV2Item struct { @@ -918,7 +993,6 @@ type projectV2ItemContent struct { CreatedAt *github.Timestamp `json:"created_at,omitempty"` ID *int64 `json:"id,omitempty"` Number *int `json:"number,omitempty"` - Repository MinimalRepository `json:"repository,omitempty"` State *string `json:"state,omitempty"` StateReason *string `json:"stateReason,omitempty"` Title *string `json:"title,omitempty"` @@ -926,8 +1000,17 @@ type projectV2ItemContent struct { URL *string `json:"url,omitempty"` } +type pageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor,omitempty"` + PrevCursor string `json:"prevCursor,omitempty"` +} + type paginationOptions struct { - PerPage int `url:"per_page,omitempty"` + PerPage int `url:"per_page,omitempty"` + After string `url:"after,omitempty"` + Before string `url:"before,omitempty"` } type filterQueryOptions struct { @@ -935,9 +1018,7 @@ type filterQueryOptions struct { } type fieldSelectionOptions struct { - // Specific list of field IDs to include in the response. If not provided, only the title field is included. - // Example: fields=102589,985201,169875 or fields[]=102589&fields[]=985201&fields[]=169875 - Fields []string `url:"fields,omitempty"` + Fields string `url:"fields,omitempty"` } type listProjectItemsOptions struct { @@ -981,6 +1062,41 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { return payload, nil } +func buildPageInfo(resp *github.Response) pageInfo { + return pageInfo{ + HasNextPage: resp.After != "", + HasPreviousPage: resp.Before != "", + NextCursor: resp.After, + PrevCursor: resp.Before, + } +} + +func extractPaginationOptions(request mcp.CallToolRequest) (paginationOptions, error) { + perPage, err := OptionalIntParamWithDefault(request, "per_page", MaxProjectsPerPage) + if err != nil { + return paginationOptions{}, err + } + if perPage > MaxProjectsPerPage { + perPage = MaxProjectsPerPage + } + + after, err := OptionalParam[string](request, "after") + if err != nil { + return paginationOptions{}, err + } + + before, err := OptionalParam[string](request, "before") + if err != nil { + return paginationOptions{}, err + } + + return paginationOptions{ + PerPage: perPage, + After: after, + Before: before, + }, nil +} + // addOptions adds the parameters in opts as URL query parameters to s. opts // must be a struct whose fields may contain "url" tags. func addOptions(s string, opts any) (string, error) { @@ -1005,10 +1121,9 @@ func addOptions(s string, opts any) (string, error) { func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { return mcp.NewPrompt("ManageProjectItems", - mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")), + mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Guide for GitHub Projects V2: discovery, fields, querying, updates.")), mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()), mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()), - mcp.WithArgument("task", mcp.ArgumentDescription("Optional: specific task to focus on (e.g., 'discover_projects', 'update_items', 'create_reports')")), ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { owner := request.Params.Arguments["owner"] ownerType := request.Params.Arguments["owner_type"] @@ -1021,161 +1136,179 @@ func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr messages := []mcp.PromptMessage{ { Role: "system", - Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" + - "**Core Capabilities:**\n" + - "- Project discovery and field analysis\n" + - "- Item querying with advanced filters\n" + - "- Field value updates and management\n" + - "- Progress reporting and insights\n\n" + - "**Key Rules:**\n" + - "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" + - "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" + - "- Use proper field IDs (not names) when updating items\n" + - "- Provide step-by-step workflows with concrete examples\n\n" + - "**Understanding Project Items:**\n" + - "- Project items reference underlying content (issues or pull requests)\n" + - "- Project tools provide: project fields, item metadata, and basic content info\n" + - "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" + - "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" + - "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" + - "**Available Tools:**\n" + - "- **list_projects**: Discover available projects\n" + - "- **get_project**: Get detailed project information\n" + - "- **list_project_fields**: Get field definitions and IDs\n" + - "- **list_project_items**: Query items with filters and field selection\n" + - "- **get_project_item**: Get specific item details\n" + - "- **add_project_item**: Add issues/PRs to projects\n" + - "- **update_project_item**: Update field values\n" + - "- **delete_project_item**: Remove items from projects"), + Content: mcp.NewTextContent(`System guide: GitHub Projects V2. +Goal: Pick correct tool, fetch COMPLETE data (no early pagination stop), apply accurate filters, and count items correctly. + +Available tools (9 total): + +Read-only tools: +- list_projects: List all projects for a user/org +- get_project: Get details of a single project by project_number +- list_project_fields: List all fields in a project (CALL THIS FIRST before filtering) +- get_project_field: Get details of a single field by field_id +- list_project_items: List items (issues/PRs) in a project with filtering & field values +- get_project_item: Get a single item by item_id + +Write tools: +- add_project_item: Add an issue or PR to a project +- update_project_item: Update field values for an item (status, priority, etc.) +- delete_project_item: Remove an item from a project + +Core rules: +- list_projects: NEVER include item-level filters (no is:issue, assignee:, label:, etc.) +- Before filtering on fields, call list_project_fields to get field IDs +- Always paginate until pageInfo.hasNextPage=false +- Keep query, fields, per_page identical across pages +- Include field IDs on every list_project_items page if you need values +- Prefer explicit is:issue / is:pr unless mixed set requested +- Only summarize if verbs like analyze / summarize / report / overview / insights appear; otherwise enumerate + +Field resolution: +- Use exact returned field names; don't invent +- Iteration synonyms map to actual existing name (Sprint → sprint:@current, etc.). If none exist, omit +- Only add filters for fields that exist and matter to the user goal + +Query syntax essentials: +AND space | OR comma | NOT prefix - | quote multi-word values | hyphenate names | ranges points:1..5 | comparisons updated:>@today-7d priority:>1 | wildcards title:*crash* + +Pagination pattern: +Call list_project_items → if hasNextPage true, repeat with after=nextCursor → stop only when false → then count/deduplicate + +Counting: +- Items array length after full pagination (dedupe by item.id or node_id) +- Never count fields array, content, assignees, labels as separate items +- item.id = project item identifier; content.id = underlying issue/PR id + +Edge handling: +Empty pages → total=0 still return pageInfo +Duplicates → keep first for totals +Missing field values → null/omit, never fabricate + +Self-check: paginated? deduped? correct IDs? field names valid? summary allowed?`), }, { Role: "user", - Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+ - "Help me get started with project management tasks.", + Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s", owner, ownerType, func() string { if task != "" { - return fmt.Sprintf(" I'm specifically interested in: %s.", task) + return fmt.Sprintf(" Focus: %s.", task) } return "" }())), }, + { + Role: "assistant", + Content: mcp.NewTextContent("Start by listing projects: use list_projects tool with owner and owner_type parameters."), + }, + { + Role: "user", + Content: mcp.NewTextContent("How do I work with fields and items?"), + }, { Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("Perfect! I'll help you manage GitHub Projects for %s. Let me guide you through the essential workflows.\n\n"+ - "**🔍 Step 1: Project Discovery**\n"+ - "First, let's see what projects are available using **list_projects**.", owner)), + Content: mcp.NewTextContent(`Fields & items workflow: +1. Call list_project_fields to get field definitions → map lowercased name -> {id,type} +2. Use only existing field names; no invention +3. Iteration mapping: pick sprint/cycle/iteration only if present (sprint:@current etc.) +4. Include only relevant fields (e.g. Priority + Label for high priority bugs) +5. Build query after resolving fields ("last week" → updated:>@today-7d) +6. Call list_project_items with query and field IDs → paginate until hasNextPage=false +7. Keep query/fields/per_page stable across all pages +8. Include field IDs on every page when you need their values +Missing field? Omit or clarify—never guess.`), }, { Role: "user", - Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."), + Content: mcp.NewTextContent("How do I update item field values?"), }, { Role: "assistant", - Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" + - "Once you select a project, I'll help you:\n\n" + - "1. **Get field information** using **list_project_fields**\n" + - " - Find field IDs, names, and data types\n" + - " - Understand available options for select fields\n" + - " - Identify required vs. optional fields\n\n" + - "2. **Query project items** using **list_project_items**\n" + - " - Filter by assignees: query=\"assignee:@me\"\n" + - " - Filter by status: query=\"status:In Progress\"\n" + - " - Filter by labels: query=\"label:bug\"\n" + - " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" + - "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"), + Content: mcp.NewTextContent(`Updating fields (update_project_item tool): +Input format: updated_field parameter with {id: , value: } +Examples: +- Text field: {"id":123,"value":"hello"} +- Single-select: {"id":456,"value":789} (value is option ID, not name) +- Number: {"id":321,"value":5} +- Date: {"id":654,"value":"2025-03-15"} +- Clear field: {"id":123,"value":null} + +Rules: +- item_id parameter = project item ID (from list_project_items), NOT issue/PR ID +- Get field IDs from list_project_fields first +- For select/iteration fields, pass option/iteration ID as value, not the name +- To add an item first: use add_project_item tool with issue/PR ID +- To remove an item: use delete_project_item tool`), }, { Role: "user", - Content: mcp.NewTextContent("How do I update field values? What about the different field types?"), + Content: mcp.NewTextContent("Show me a workflow example."), }, { Role: "assistant", - Content: mcp.NewTextContent("**✏️ Step 3: Updating Field Values**\n\n" + - "Use **update_project_item** with the updated_field parameter. The format varies by field type:\n\n" + - "**Text fields:**\n" + - "```json\n" + - "{\"id\": 123456, \"value\": \"Updated text content\"}\n" + - "```\n\n" + - "**Single-select fields:**\n" + - "```json\n" + - "{\"id\": 198354254, \"value\": 18498754}\n" + - "```\n" + - "*(Use option ID, not option name)*\n\n" + - "**Date fields:**\n" + - "```json\n" + - "{\"id\": 789012, \"value\": \"2024-03-15\"}\n" + - "```\n\n" + - "**Number fields:**\n" + - "```json\n" + - "{\"id\": 345678, \"value\": 5}\n" + - "```\n\n" + - "**Clear a field:**\n" + - "```json\n" + - "{\"id\": 123456, \"value\": null}\n" + - "```\n\n" + - "**⚠️ Important:** Use the internal project item_id (not issue/PR number) for updates!"), + Content: mcp.NewTextContent(`Workflow example: +1. list_projects → pick project_number +2. list_project_fields → build field map {name: {id, type}} +3. Build query (e.g. is:issue sprint:@current priority:high updated:>@today-7d) +4. list_project_items with field IDs → paginate fully (loop until hasNextPage=false) +5. Optional: get_project_item for specific item details +6. Optional: add_project_item to add issue/PR to project +7. Optional: update_project_item to change field values for an item +8. Optional: delete_project_item to remove from an item from project + +Important: +- Iteration filter must match existing field name +- Keep fields parameter consistent across pages +- Summarize only if explicitly asked +- item_id for updates/deletes comes from list_project_items response +- content.id in items is the underlying issue/PR ID (use with add_project_item)`), }, { Role: "user", - Content: mcp.NewTextContent("Can you show me a complete workflow example?"), + Content: mcp.NewTextContent("How do I handle pagination?"), }, { Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("**🔄 Complete Workflow Example**\n\n"+ - "Here's how to find and update your assigned items:\n\n"+ - "**Step 1:** Discover projects\n\n"+ - "**list_projects** owner=\"%s\" owner_type=\"%s\"\n\n\n"+ - "**Step 2:** Get project fields (using project #123)\n\n"+ - "**list_project_fields** owner=\"%s\" owner_type=\"%s\" project_number=123\n\n"+ - "*(Note the Status field ID, e.g., 198354254)*\n\n"+ - "**Step 3:** Query your assigned items\n\n"+ - "**list_project_items**\n"+ - " owner=\"%s\"\n"+ - " owner_type=\"%s\"\n"+ - " project_number=123\n"+ - " query=\"assignee:@me\"\n"+ - " fields=[\"198354254\", \"other_field_ids\"]\n\n\n"+ - "**Step 4:** Update item status\n\n"+ - "**update_project_item**\n"+ - " owner=\"%s\"\n"+ - " owner_type=\"%s\"\n"+ - " project_number=123\n"+ - " item_id=789123\n"+ - " updated_field={\"id\": 198354254, \"value\": 18498754}\n\n\n"+ - "Let me start by listing your projects now!", owner, ownerType, owner, ownerType, owner, ownerType, owner, ownerType)), + Content: mcp.NewTextContent(`Pagination with list_project_items: +1. Make initial call with query, fields, per_page parameters +2. Check response pageInfo.hasNextPage +3. If true: call again with same query/fields/per_page + after=pageInfo.nextCursor +4. Repeat step 2-3 until hasNextPage=false +5. Collect all items from all pages before counting/analyzing + +Critical: Do NOT change query, fields, or per_page between pages. Always include same field IDs on every page if you need field values.`), }, { Role: "user", - Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"), + Content: mcp.NewTextContent("How do I get more details about items?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(`Getting additional item details: + +- First inspect item's content object for info, e.g. title, assignees, labels +- If additional detail is needed, and relevant fields are present from list_project_fields, include their IDs in list_project_items and request with list_project_items again. +- If more detail needed, use separate issue/PR tools`), }, { Role: "assistant", - Content: mcp.NewTextContent("**📝 Accessing Underlying Issue/PR Details**\n\n" + - "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" + - "**From project items, extract:**\n" + - "- content.repository.name and content.repository.owner.login\n" + - "- content.number (the issue/PR number)\n" + - "- content_type (\"Issue\" or \"PullRequest\")\n\n" + - "**Then use these tools for details:**\n\n" + - "1. **Get full issue/PR details:**\n" + - " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Returns: full body, labels, assignees, milestone, etc.\n\n" + - "2. **Get recent comments:**\n" + - " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Add since parameter to filter recent comments\n\n" + - "3. **Get issue events:**\n" + - " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Shows timeline: assignments, label changes, status updates\n\n" + - "4. **For pull requests specifically:**\n" + - " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" + - " - **list_pull_request_reviews** for review status\n\n" + - "**💡 Example:** To check for blockers in comments:\n" + - "1. Get project items with query=\"assignee:@me is:open\"\n" + - "2. For each item, extract repository and issue number from content\n" + - "3. Use **list_comments** to get recent comments\n" + - "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""), + Content: mcp.NewTextContent(`Query patterns for list_project_items: + +Common scenarios: +- Blocked issues: is:issue (label:blocked OR status:"Blocked") +- Overdue tasks: is:issue due-date:<@today state:open +- PRs ready for review: is:pr review-status:"Ready for Review" state:open +- Stale issues: is:issue updated:<@today-30d state:open +- High priority bugs: is:issue label:bug priority:high state:open +- Team sprint PRs: is:pr team-name:"Backend Team" sprint:@current + +Rules: +- Summarize only if user requests it with verbs like "analyze", "summarize", "report" +- Deduplicate by item.id before counting totals +- Quote multi-word values: status:"In Progress" +- Never invent field names or IDs - always verify with list_project_fields first +- Use explicit is:issue or is:pr unless user wants mixed items`), }, } return &mcp.GetPromptResult{ diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 6cfbda0fe..ebb1f27d7 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -28,8 +28,9 @@ func Test_ListProjects(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "per_page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) - orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} - userProjects := []map[string]any{{"id": 2, "title": "User Project"}} + // API returns full ProjectV2 objects; we only need minimal fields for decoding. + orgProjects := []map[string]any{{"id": 1, "node_id": "NODE1", "title": "Org Project"}} + userProjects := []map[string]any{{"id": 2, "node_id": "NODE2", "title": "User Project"}} tests := []struct { name string @@ -44,7 +45,10 @@ func Test_ListProjects(t *testing.T) { mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgProjects), + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgProjects)) + }), ), ), requestArgs: map[string]interface{}{ @@ -59,7 +63,10 @@ func Test_ListProjects(t *testing.T) { mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userProjects), + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userProjects)) + }), ), ), requestArgs: map[string]interface{}{ @@ -153,10 +160,15 @@ func Test_ListProjects(t *testing.T) { require.False(t, result.IsError) textContent := getTextResult(t, result) - var arr []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &arr) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.Equal(t, tc.expectedLength, len(arr)) + projects, ok := response["projects"].([]interface{}) + require.True(t, ok) + assert.Equal(t, tc.expectedLength, len(projects)) + // pageInfo should exist + _, hasPageInfo := response["pageInfo"].(map[string]interface{}) + assert.True(t, hasPageInfo) }) } } @@ -305,12 +317,8 @@ func Test_ListProjectFields(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "per_page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) - orgFields := []map[string]any{ - {"id": 101, "name": "Status", "dataType": "single_select"}, - } - userFields := []map[string]any{ - {"id": 201, "name": "Priority", "dataType": "single_select"}, - } + orgFields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} + userFields := []map[string]any{{"id": 201, "name": "Priority", "data_type": "single_select"}} tests := []struct { name string @@ -325,7 +333,10 @@ func Test_ListProjectFields(t *testing.T) { mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgFields), + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgFields)) + }), ), ), requestArgs: map[string]interface{}{ @@ -433,10 +444,14 @@ func Test_ListProjectFields(t *testing.T) { require.False(t, result.IsError) textContent := getTextResult(t, result) - var fields []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &fields) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) + fields, ok := response["fields"].([]interface{}) + require.True(t, ok) assert.Equal(t, tc.expectedLength, len(fields)) + _, hasPageInfo := response["pageInfo"].(map[string]interface{}) + assert.True(t, hasPageInfo) }) } } @@ -653,8 +668,7 @@ func Test_ListProjectItems(t *testing.T) { mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - fieldParams := q["fields"] - if len(fieldParams) == 3 && fieldParams[0] == "123" && fieldParams[1] == "456" && fieldParams[2] == "789" { + if q.Get("fields") == "123,456,789" { w.WriteHeader(http.StatusOK) _, _ = w.Write(mock.MustMarshal(orgItems)) return @@ -786,10 +800,14 @@ func Test_ListProjectItems(t *testing.T) { require.False(t, result.IsError) textContent := getTextResult(t, result) - var items []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &items) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) + items, ok := response["items"].([]interface{}) + require.True(t, ok) assert.Equal(t, tc.expectedLength, len(items)) + _, hasPageInfo := response["pageInfo"].(map[string]interface{}) + assert.True(t, hasPageInfo) }) } } @@ -852,8 +870,7 @@ func Test_GetProjectItem(t *testing.T) { mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - fieldParams := q["fields"] - if len(fieldParams) == 2 && fieldParams[0] == "123" && fieldParams[1] == "456" { + if q.Get("fields") == "123,456" { w.WriteHeader(http.StatusOK) _, _ = w.Write(mock.MustMarshal(orgItem)) return @@ -1351,8 +1368,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(1), "item_id": float64(2), - "field_id": float64(1), - "new_field": map[string]any{ + "updated_field": map[string]any{ + "id": float64(1), "value": "X", }, }, @@ -1365,7 +1382,7 @@ func Test_UpdateProjectItem(t *testing.T) { "owner": "octo-org", "project_number": float64(1), "item_id": float64(2), - "new_field": map[string]any{ + "updated_field": map[string]any{ "id": float64(1), "value": "X", }, @@ -1379,7 +1396,7 @@ func Test_UpdateProjectItem(t *testing.T) { "owner": "octo-org", "owner_type": "org", "item_id": float64(2), - "new_field": map[string]any{ + "updated_field": map[string]any{ "id": float64(1), "value": "X", }, @@ -1393,7 +1410,7 @@ func Test_UpdateProjectItem(t *testing.T) { "owner": "octo-org", "owner_type": "org", "project_number": float64(1), - "new_field": map[string]any{ + "updated_field": map[string]any{ "id": float64(1), "value": "X", }, @@ -1401,19 +1418,18 @@ func Test_UpdateProjectItem(t *testing.T) { expectError: true, }, { - name: "missing field_value", + name: "missing updated_field", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(2), - "field_id": float64(2), }, expectError: true, }, { - name: "new_field not object", + name: "updated_field not object", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "octo-org", @@ -1425,7 +1441,7 @@ func Test_UpdateProjectItem(t *testing.T) { expectError: true, }, { - name: "new_field missing id", + name: "updated_field missing id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "octo-org", @@ -1437,7 +1453,7 @@ func Test_UpdateProjectItem(t *testing.T) { expectError: true, }, { - name: "new_field missing value", + name: "updated_field missing value", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "octo-org", @@ -1475,14 +1491,14 @@ func Test_UpdateProjectItem(t *testing.T) { assert.Contains(t, text, "missing required parameter: project_number") case "missing item_id": assert.Contains(t, text, "missing required parameter: item_id") - case "missing field_value": + case "missing updated_field": assert.Contains(t, text, "missing required parameter: updated_field") - case "field_value not object": + case "updated_field not object": assert.Contains(t, text, "field_value must be an object") - case "field_value missing id": - assert.Contains(t, text, "missing required parameter: field_id") - case "field_value missing value": - assert.Contains(t, text, "field_value.value is required") + case "updated_field missing id": + assert.Contains(t, text, "updated_field.id is required") + case "updated_field missing value": + assert.Contains(t, text, "updated_field.value is required") } return } From e6f77c0eb5d7b8ec1bd220dcf1a1992070220931 Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Wed, 5 Nov 2025 12:59:44 -0500 Subject: [PATCH 2/3] update pagination rules --- .../__toolsnaps__/list_project_fields.snap | 6 +-- .../__toolsnaps__/list_project_items.snap | 6 +-- pkg/github/__toolsnaps__/list_projects.snap | 6 +-- pkg/github/instructions.go | 44 +++++++++++++------ pkg/github/projects.go | 18 ++++---- 5 files changed, 49 insertions(+), 31 deletions(-) diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap index ec38d5568..c543e69d7 100644 --- a/pkg/github/__toolsnaps__/list_project_fields.snap +++ b/pkg/github/__toolsnaps__/list_project_fields.snap @@ -7,11 +7,11 @@ "inputSchema": { "properties": { "after": { - "description": "Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep per_page identical for every page.", + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", "type": "string" }, "before": { - "description": "Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration.", + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", "type": "string" }, "owner": { @@ -27,7 +27,7 @@ "type": "string" }, "per_page": { - "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", + "description": "Results per page (max 50)", "type": "number" }, "project_number": { diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap index f8eb5bfa5..2f3eb7614 100644 --- a/pkg/github/__toolsnaps__/list_project_items.snap +++ b/pkg/github/__toolsnaps__/list_project_items.snap @@ -7,11 +7,11 @@ "inputSchema": { "properties": { "after": { - "description": "Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page.", + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", "type": "string" }, "before": { - "description": "Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration.", + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", "type": "string" }, "fields": { @@ -34,7 +34,7 @@ "type": "string" }, "per_page": { - "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", + "description": "Results per page (max 50)", "type": "number" }, "project_number": { diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap index 8725f554e..a15d4344d 100644 --- a/pkg/github/__toolsnaps__/list_projects.snap +++ b/pkg/github/__toolsnaps__/list_projects.snap @@ -7,11 +7,11 @@ "inputSchema": { "properties": { "after": { - "description": "Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query and per_page identical for every page.", + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", "type": "string" }, "before": { - "description": "Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration.", + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", "type": "string" }, "owner": { @@ -27,7 +27,7 @@ "type": "string" }, "per_page": { - "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", + "description": "Results per page (max 50)", "type": "number" }, "query": { diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go index 71e7dd812..92e0aa27d 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/instructions.go @@ -65,42 +65,60 @@ Use 'list_discussion_categories' to understand available categories before creat case "projects": return `## Projects -When using 'list_project_items', follow these guidelines: - - Field usage: +Read Tools: + - list_projects + - get_project + - list_project_fields + - get_project_field + - list_project_items + - get_project_item +Write Tools: + - add_project_item + - update_project_item + - delete_project_item + +Field usage: - Call list_project_fields first to understand available fields and get IDs/types before filtering. - Use EXACT returned field names (case-insensitive match). Don't invent names or IDs. - Iteration synonyms (sprint/cycle/iteration) only if that field exists; map to the actual name (e.g. sprint:@current). - Only include filters for fields that exist and are relevant. - Pagination (mandatory): - - Loop while pageInfo.hasNextPage=true using after=nextCursor. Keep query, fields, per_page IDENTICAL each page. - - Fields parameter: +Pagination (mandatory): + Forward (normal) flow: + - Loop while pageInfo.hasNextPage=true using after=pageInfo.nextCursor. + - Keep query, fields, per_page IDENTICAL on every page. + Backward (rare) flow: + - Use before=pageInfo.prevCursor only when explicitly navigating to a previous page. + Parameters: + - per_page: results per page (max 50). Choose a stable value; do not change mid-sequence. + - after: forward cursor from prior response (pageInfo.nextCursor). + - before: backward cursor from prior response (pageInfo.prevCursor); seldom needed. + +Fields parameter: - Include field IDs on EVERY paginated list_project_items call if you need values. Omit → title only. - Counting rules: +Counting rules: - Count items array length after full pagination. - If multi-page: collect all pages, dedupe by item.id (fallback node_id) before totals. - Never count field objects, content, or nested arrays as separate items. - item.id = project item ID (for updates/deletes). item.content.id = underlying issue/PR ID. - Summary vs list: +Summary vs list: - Summaries ONLY if user uses verbs: analyze | summarize | summary | report | overview | insights. - - Listing verbs (list/show/get/fetch/display/enumerate) → just enumerate + total. + - Listing verbs (list/show/get/fetch/display/enumerate) → enumerate + total. - Examples: +Examples: - list_projects: "roadmap is:open" - list_project_items: state:open is:issue sprint:@current priority:high updated:>@today-7d - Self-check before returning: +Self-check before returning: - Paginated fully - Dedupe by id/node_id - Correct IDs used - Field names valid - Summary only if requested. - Return COMPLETE data or state what's missing (e.g. pages skipped).` +Return COMPLETE data or state what's missing (e.g. pages skipped).` default: return "" } diff --git a/pkg/github/projects.go b/pkg/github/projects.go index cfc9b57e6..98f03e272 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -51,13 +51,13 @@ Examples: - is:open feature planning`), ), mcp.WithNumber("per_page", - mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)), + mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)), ), mcp.WithString("after", - mcp.Description("Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query and per_page identical for every page."), + mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."), ), mcp.WithString("before", - mcp.Description("Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration."), + mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") @@ -228,13 +228,13 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu mcp.Description("The project's number."), ), mcp.WithNumber("per_page", - mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)), + mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)), ), mcp.WithString("after", - mcp.Description("Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep per_page identical for every page."), + mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."), ), mcp.WithString("before", - mcp.Description("Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration."), + mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") @@ -452,13 +452,13 @@ Never: - Drop 'fields' param on subsequent pages if field values are needed.`), ), mcp.WithNumber("per_page", - mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)), + mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)), ), mcp.WithString("after", - mcp.Description("Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page."), + mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."), ), mcp.WithString("before", - mcp.Description("Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration."), + mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."), ), mcp.WithArray("fields", mcp.Description("Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first."), From 2ba80b83c227b88c544c7e79ef3189ad9e49242a Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Wed, 5 Nov 2025 13:14:24 -0500 Subject: [PATCH 3/3] move query rules --- README.md | 61 +++---------------- .../__toolsnaps__/list_project_items.snap | 2 +- pkg/github/instructions.go | 43 ++++++++++++- pkg/github/projects.go | 43 +------------ 4 files changed, 54 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 94a8f9134..5eae23c6c 100644 --- a/README.md +++ b/README.md @@ -830,70 +830,29 @@ Options are: - `project_number`: The project's number. (number, required) - **list_project_fields** - List project fields - - `after`: Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep per_page identical for every page. (string, optional) - - `before`: Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration. (string, optional) + - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) + - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional) + - `per_page`: Results per page (max 50) (number, optional) - `project_number`: The project's number. (number, required) - **list_project_items** - List project items - - `after`: Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page. (string, optional) - - `before`: Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration. (string, optional) + - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) + - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first. (string[], optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional) + - `per_page`: Results per page (max 50) (number, optional) - `project_number`: The project's number. (number, required) - - `query`: Query string - For advanced filtering of project items using GitHub's search syntax: - -MUST reflect user intent; strongly prefer explicit content type if narrowed: - - "open issues" → state:open is:issue - - "merged PRs" → state:merged is:pr - - "items updated this week" → updated:>@today-7d (omit type only if mixed desired) - - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user specifies "items") - - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies "issues" or "PRs") - - "all open issues I'm working on" → is:issue state:open assignee:@me - -Query Construction Heuristics: - a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity) - b. Map temporal phrases: "this week" → updated:>@today-7d - c. Map negations: "excluding wontfix" → -label:wontfix - d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence) - e. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. "bug 🐛" → label:*bug*) - f. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. "v1.0" → milestone:*v1.0*) - -Syntax Essentials (items): - AND: space-separated. (label:bug priority:high). - OR: comma inside one qualifier (label:bug,critical). - NOT: leading '-' (-label:wontfix). - Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). - Quote multi-word values. (status:"In Review" team-name:"Backend Team"). - Ranges: points:1..3, updated:<@today-30d. - Wildcards: title:*crash*, label:bug*. - Assigned to User: assignee:@me | assignee:username | no:assignee - -Common Qualifier Glossary (items): - is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE | - priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" | - updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label - -Pagination Mandate: - Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page. - -Recovery Guidance: - If user provides ambiguous request ("show project activity") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail. - -Never: - - Infer field IDs; fetch via list_project_fields. - - Drop 'fields' param on subsequent pages if field values are needed. (string, optional) + - `query`: Query string for advanced filtering of project items. See Projects server instructions (list_project_items query rules) for full construction heuristics, syntax essentials, qualifier glossary, pagination mandate, recovery guidance, and prohibited behaviors. (string, optional) - **list_projects** - List projects - - `after`: Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query and per_page identical for every page. (string, optional) - - `before`: Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration. (string, optional) + - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) + - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional) + - `per_page`: Results per page (max 50) (number, optional) - `query`: Filter projects by a search query Scope: title text + open/closed state. diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap index 2f3eb7614..656c42038 100644 --- a/pkg/github/__toolsnaps__/list_project_items.snap +++ b/pkg/github/__toolsnaps__/list_project_items.snap @@ -42,7 +42,7 @@ "type": "number" }, "query": { - "description": "Query string - For advanced filtering of project items using GitHub's search syntax:\n\nMUST reflect user intent; strongly prefer explicit content type if narrowed:\n\t- \"open issues\" → state:open is:issue\n\t- \"merged PRs\" → state:merged is:pr\n\t- \"items updated this week\" → updated:\u003e@today-7d (omit type only if mixed desired)\n\t- \"list all P1 priority items\" → priority:p1 (omit state if user wants all, omit type if user specifies \"items\")\n\t- \"list all open P2 issues\" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies \"issues\" or \"PRs\")\n\t- \"all open issues I'm working on\" → is:issue state:open assignee:@me\n\nQuery Construction Heuristics:\n\ta. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)\n\tb. Map temporal phrases: \"this week\" → updated:\u003e@today-7d\n\tc. Map negations: \"excluding wontfix\" → -label:wontfix\n\td. Map priority adjectives: \"high/sev1/p1\" → priority:high OR priority:p1 (choose based on field presence)\n\te. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. \"bug 🐛\" → label:*bug*)\n\tf. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. \"v1.0\" → milestone:*v1.0*)\n\nSyntax Essentials (items):\n AND: space-separated. (label:bug priority:high).\n OR: comma inside one qualifier (label:bug,critical).\n NOT: leading '-' (-label:wontfix).\n Hyphenate multi-word field names. (team-name:\"Backend Team\", story-points:\u003e5).\n Quote multi-word values. (status:\"In Review\" team-name:\"Backend Team\").\n Ranges: points:1..3, updated:\u003c@today-30d.\n Wildcards: title:*crash*, label:bug*.\n\t Assigned to User: assignee:@me | assignee:username | no:assignee\n\nCommon Qualifier Glossary (items):\n is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE |\n priority:p1|high | sprint-name:@current | team-name:\"Backend Team\" | parent-issue:\"org/repo#123\" |\n updated:\u003e@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label\n\nPagination Mandate:\n Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page.\n\nRecovery Guidance:\n If user provides ambiguous request (\"show project activity\") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail.\n\nNever:\n - Infer field IDs; fetch via list_project_fields.\n - Drop 'fields' param on subsequent pages if field values are needed.", + "description": "Query string for advanced filtering of project items. See Projects server instructions (list_project_items query rules) for full construction heuristics, syntax essentials, qualifier glossary, pagination mandate, recovery guidance, and prohibited behaviors.", "type": "string" } }, diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go index 92e0aa27d..48a2e9748 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/instructions.go @@ -118,7 +118,48 @@ Self-check before returning: - Field names valid - Summary only if requested. -Return COMPLETE data or state what's missing (e.g. pages skipped).` +Return COMPLETE data or state what's missing (e.g. pages skipped). + +list_project_items query rules: +Query string - For advanced filtering of project items using GitHub's search syntax: + +MUST reflect user intent; strongly prefer explicit content type if narrowed: + - "open issues" → state:open is:issue + - "merged PRs" → state:merged is:pr + - "items updated this week" → updated:>@today-7d (omit type only if mixed desired) + - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user specifies "items") + - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies "issues" or "PRs") + - "all open issues I'm working on" → is:issue state:open assignee:@me + +Query Construction Heuristics: + a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity) + b. Map temporal phrases: "this week" → updated:>@today-7d + c. Map negations: "excluding wontfix" → -label:wontfix + d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence) + e. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. "bug 🐛" → label:*bug*) + f. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. "v1.0" → milestone:*v1.0*) + +Syntax Essentials (items): + AND: space-separated. (label:bug priority:high). + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). + Quote multi-word values. (status:"In Review" team-name:"Backend Team"). + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + Assigned to User: assignee:@me | assignee:username | no:assignee + +Common Qualifier Glossary (items): + is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE | + priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" | + updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label + +Pagination Mandate: + Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page. + +Never: + - Infer field IDs; fetch via list_project_fields. + - Drop 'fields' param on subsequent pages if field values are needed.` default: return "" } diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 98f03e272..cff1fa9b9 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -408,48 +408,7 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("The project's number."), ), mcp.WithString("query", - mcp.Description(`Query string - For advanced filtering of project items using GitHub's search syntax: - -MUST reflect user intent; strongly prefer explicit content type if narrowed: - - "open issues" → state:open is:issue - - "merged PRs" → state:merged is:pr - - "items updated this week" → updated:>@today-7d (omit type only if mixed desired) - - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user specifies "items") - - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies "issues" or "PRs") - - "all open issues I'm working on" → is:issue state:open assignee:@me - -Query Construction Heuristics: - a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity) - b. Map temporal phrases: "this week" → updated:>@today-7d - c. Map negations: "excluding wontfix" → -label:wontfix - d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence) - e. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. "bug 🐛" → label:*bug*) - f. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. "v1.0" → milestone:*v1.0*) - -Syntax Essentials (items): - AND: space-separated. (label:bug priority:high). - OR: comma inside one qualifier (label:bug,critical). - NOT: leading '-' (-label:wontfix). - Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). - Quote multi-word values. (status:"In Review" team-name:"Backend Team"). - Ranges: points:1..3, updated:<@today-30d. - Wildcards: title:*crash*, label:bug*. - Assigned to User: assignee:@me | assignee:username | no:assignee - -Common Qualifier Glossary (items): - is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE | - priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" | - updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label - -Pagination Mandate: - Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page. - -Recovery Guidance: - If user provides ambiguous request ("show project activity") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail. - -Never: - - Infer field IDs; fetch via list_project_fields. - - Drop 'fields' param on subsequent pages if field values are needed.`), + mcp.Description(`Query string for advanced filtering of project items. See Projects server instructions (list_project_items query rules) for full construction heuristics, syntax essentials, qualifier glossary, pagination mandate, recovery guidance, and prohibited behaviors.`), ), mcp.WithNumber("per_page", mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)),