From 5cd81eafbdd7c0f4e763a4201a9f3b2d343d4326 Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Fri, 31 Oct 2025 18:32:39 -0400 Subject: [PATCH 1/2] tool consolidation and prompt updates --- README.md | 125 +- pkg/github/__toolsnaps__/project_read.snap | 77 + pkg/github/__toolsnaps__/project_write.snap | 62 + pkg/github/projects.go | 1917 ++++++++++--------- pkg/github/projects_test.go | 1575 ++------------- pkg/github/tools.go | 13 +- 6 files changed, 1407 insertions(+), 2362 deletions(-) create mode 100644 pkg/github/__toolsnaps__/project_read.snap create mode 100644 pkg/github/__toolsnaps__/project_write.snap diff --git a/README.md b/README.md index 2e896cea8..86debf4cf 100644 --- a/README.md +++ b/README.md @@ -798,63 +798,74 @@ Options are: Projects -- **add_project_item** - Add project item - - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) - - `item_type`: The item's type, either issue or pull_request. (string, required) - - `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) - - `project_number`: The project's number. (number, required) - -- **delete_project_item** - Delete project item - - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required) - - `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) - - `project_number`: The project's number. (number, required) - -- **get_project** - Get project - - `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) - - `project_number`: The project's number (number, required) - -- **get_project_field** - Get project field - - `field_id`: The field's id. (number, required) - - `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) - - `project_number`: The project's number. (number, required) - -- **get_project_item** - Get project item - - `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) - - `item_id`: The item's ID. (number, required) - - `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) - - `project_number`: The project's number. (number, required) - -- **list_project_fields** - List project fields - - `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) - - `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) - - `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) - - `project_number`: The project's number. (number, required) - - `query`: Search query to filter items (string, optional) - -- **list_projects** - List projects - - `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) - -- **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) - - `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) - - `project_number`: The project's number. (number, required) - - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"} (object, required) +- **project_read** - Read project information + - `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) + - `field_id`: Field ID (required for get_project_field) (number, 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) + - `item_id`: Item ID (required for get_project_item) (number, optional) + - `method`: Read operation: get_project, list_projects, get_project_field, list_project_fields (call FIRST for IDs), get_project_item, list_project_items (use query + fields) (string, required) + - `owner`: GitHub username or org name (case-insensitive) (string, required) + - `owner_type`: Owner type: 'user' or 'org' (string, required) + - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional) + - `project_number`: Project number (required for most methods) (number, optional) + - `query`: Query string (used ONLY with list_projects and list_project_items). + +Pattern Split: + +1. list_projects (project metadata only): + 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 + Reject & switch method if user intends items. + +2. list_project_items (issues / PRs inside ONE project): + 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) + 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. Map blocking relations: "blocked by 123" → parent-issue:"owner/repo#123" + +Syntax Essentials (items): + AND: space-separated. + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. + Quote multi-word values. + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + +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) + +- **project_write** - Modify project items + - `item_id`: For add: issue/PR ID. For update/delete: project item ID (not issue/PR ID) (number, required) + - `item_type`: Type to add: 'issue' or 'pull_request' (required for add_project_item) (string, optional) + - `method`: Write operation: add_project_item (needs item_type, item_id), update_project_item (needs item_id, updated_field), delete_project_item (needs item_id) (string, required) + - `owner`: GitHub username or org name (case-insensitive) (string, required) + - `owner_type`: Owner type: 'user' or 'org' (string, required) + - `project_number`: Project number (number, required) + - `updated_field`: Field update object (required for update_project_item). Format: {"id": 123456, "value": }. Value types: text=string, single-select=option ID (number), date=ISO string, number=number. Set value to null to clear. (object, optional) diff --git a/pkg/github/__toolsnaps__/project_read.snap b/pkg/github/__toolsnaps__/project_read.snap new file mode 100644 index 000000000..126e03016 --- /dev/null +++ b/pkg/github/__toolsnaps__/project_read.snap @@ -0,0 +1,77 @@ +{ + "annotations": { + "title": "Read project information", + "readOnlyHint": true + }, + "description": "Read operations for GitHub Projects.\n\nDECISION GUIDE (choose method):\n- get_project: You have a project number; you need its metadata.\n- list_projects: User wants to discover or filter projects (TITLE / OPEN STATE ONLY).\n- get_project_field: You know a field_id and need its definition.\n- list_project_fields: MUST call before fetching item field values (get IDs \u0026 types).\n- get_project_item: You have an item_id (project item) and want its details.\n- list_project_items: User wants issues/PRs inside a project filtered by criteria.\n\nINTENT TOKENS (map user phrasing → method):\n[INTENT:DISCOVER_PROJECTS] → list_projects\n[INTENT:INSPECT_PROJECT] → get_project\n[INTENT:ENUM_FIELDS] → list_project_fields\n[INTENT:FIELD_DETAILS] → get_project_field\n[INTENT:LIST_ITEMS] → list_project_items\n[INTENT:ITEM_DETAILS] → get_project_item\n\nCRITICAL DISTINCTION:\nProjects ≠ Project Items.\n- list_projects filters ONLY project metadata (title, open/closed).\n DO NOT use item-level qualifiers (is:issue, is:pr, assignee:, label:, status:, parent-issue:, sprint-name:, etc).\n- list_project_items filters ISSUES or PRs inside ONE project. Strongly prefer explicit type: is:issue OR is:pr unless user requests a mixed set.\n\nFAILURE MODES TO AVOID:\n1. Missing pagination (stops early) → ALWAYS loop while pageInfo.hasNextPage=true.\n2. Missing 'fields' when listing items → only title returned; no field values.\n3. Using item filters in list_projects → returns zero or irrelevant results.\n4. Ambiguous item type (issues vs PRs) → default to clarifying OR supply both (omit type only if user truly wants both).\n5. Inventing field IDs → fetch via list_project_fields first.\n6. INVENTING FIELD NAMES (NEW) → MUST use exact names returned by list_project_fields (case-insensitive match, preserve original spelling/hyphenation).\n\nFIELD NAME RESOLUTION (CRITICAL – ALWAYS DO BEFORE BUILDING QUERY WITH CUSTOM FIELDS):\n1. Call list_project_fields → build a map of lowercased field name → original field name + type.\n2. When user mentions a concept (e.g. \"current sprint\", \"this iteration\", \"in the cycle\"):\n - Identify iteration-type fields (type == iteration).\n - Accept synonyms in user phrasing: sprint, iteration, cycle.\n - If user uses a generic phrase (\"current sprint\") and the existing iteration field is named \"Sprint\" → use sprint:@current.\n - If the field is named \"Cycle\" → cycle:@current.\n - If the field is named \"Iteration\" → iteration:@current.\n - NEVER substitute a synonym that does not exist among field names.\n3. For any other custom fields (e.g. \"dev phase\", \"story points\", \"team name\"):\n - Normalize user phrase → lower-case, replace spaces with hyphens.\n - Match against available field names in lower-case.\n - Use the ORIGINAL field name in the query exactly (including hyphenation and case if needed).\n4. If multiple iteration-type fields exist and the user intent is ambiguous → ask for clarification OR pick the one whose name best matches the user phrase.\n5. INVALID if you use a field name not present in list_project_fields.\n\nVALID vs INVALID (Iteration Example):\nUser request: \"Analyze the last week's activity ... for issues in the current sprint\"\nFields contain iteration field named \"sprint\":\n VALID: is:issue updated:\u003e@today-7d sprint:@current\n INVALID: is:issue updated:\u003e@today-7d iteration:@current\nFields contain iteration field named \"cycle\":\n VALID: is:issue updated:\u003e@today-7d cycle:@current\n INVALID: is:issue updated:\u003e@today-7d iteration:@current\nFields contain iteration field named \"iteration\":\n VALID: is:issue updated:\u003e@today-7d iteration:@current\n INVALID: is:issue updated:\u003e@today-7d sprint:@current (if 'sprint' not defined)\n\nIf NO iteration-type field exists → omit that qualifier OR clarify with user (\"No iteration field found; continue without sprint filter?\").\n\nQUERY TRANSLATION (items):\nUser: \"Open sprint issues assigned to me\" →\n state:open is:issue assignee:@me sprint:@current\nUser: \"PRs waiting for review\" →\n is:pr status:\"Ready for Review\"\nUser: \"High priority bugs updated this week\" →\n is:issue label:bug priority:high updated:\u003e@today-7d\n\nSYNTAX RULES (items):\n- AND: space-separated qualifiers.\n- OR: comma inside one qualifier (label:bug,critical).\n- NOT: prefix qualifier with '-' (-label:wontfix).\n- Hyphenate multi-word field names: sprint-name, team-name, parent-issue.\n- Quote multi-word values: status:\"In Review\".\n- Comparison \u0026 ranges: priority:1..3 updated:\u003c@today-14d.\n- Wildcards: title:*search*, label:bug*.\n- Presence: has:assignee, no:label, -no:assignee (force presence).\n\nGOOD PROJECT QUERIES (list_projects):\n roadmap is:open\n is:open feature planning\nBAD (reject for list_projects — item filters present):\n is:issue state:open\n assignee:@me sprint-name:\"Q3\"\n label:bug priority:high\n\nVALID ITEM QUERIES (list_project_items):\n state:open is:issue priority:high sprint:@current\n is:pr status:\"In Review\" team-name:\"Backend Team\"\n is:issue -label:wontfix updated:\u003e@today-30d\n is:issue parent-issue:\"github/repo#123\"\n\nPAGINATION LOOP (ALL list_*):\n1. Call list_*.\n2. Read pageInfo.hasNextPage.\n3. If true → call again with after=pageInfo.nextCursor (same query, fields, per_page).\n4. Repeat until hasNextPage=false.\n5. Aggregate ALL pages BEFORE summarizing.\n\nDATA COMPLETENESS RULE:\nNever summarize, infer trends, or perform counts until all pages are retrieved.\n\nDEEP DETAILS:\nProject item = lightweight wrapper. For full issue/PR inspection use issue_read or pull_request_read after enumerating items.\n\nDO:\n- Normalize user intent → precise filters.\n- Fetch fields first → pass IDs every page.\n- Preserve consistency across pagination.\n- Resolve and validate field names from list_project_fields BEFORE using them.\n\nDON'T:\n- Mix project-only and item-only filters.\n- Omit type when user scope is explicit.\n- Invent field IDs or option IDs.\n- Invent field names (e.g. use iteration:@current when only sprint exists).\n- Stop early on pagination.", + "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" + }, + "field_id": { + "description": "Field ID (required for get_project_field)", + "type": "number" + }, + "fields": { + "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" + }, + "type": "array" + }, + "item_id": { + "description": "Item ID (required for get_project_item)", + "type": "number" + }, + "method": { + "description": "Read operation: get_project, list_projects, get_project_field, list_project_fields (call FIRST for IDs), get_project_item, list_project_items (use query + fields)", + "enum": [ + "get_project", + "list_projects", + "get_project_field", + "list_project_fields", + "get_project_item", + "list_project_items" + ], + "type": "string" + }, + "owner": { + "description": "GitHub username or org name (case-insensitive)", + "type": "string" + }, + "owner_type": { + "description": "Owner type: 'user' or 'org'", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "per_page": { + "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", + "type": "number" + }, + "project_number": { + "description": "Project number (required for most methods)", + "type": "number" + }, + "query": { + "description": "Query string (used ONLY with list_projects and list_project_items). \n\nPattern Split:\n\n1. list_projects (project metadata only):\n Scope: title text + open/closed state.\n PERMITTED qualifiers: is:open, is:closed (state), simple title terms.\n FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc.\n Examples:\n - roadmap is:open\n - is:open feature planning\n Reject \u0026 switch method if user intends items.\n\n2. list_project_items (issues / PRs inside ONE project):\n MUST reflect user intent; strongly prefer explicit content type if narrowed:\n - \"open issues\" → state:open is:issue\n - \"merged PRs\" → state:merged is:pr\n - \"items updated this week\" → updated:\u003e@today-7d (omit type only if mixed desired)\n Query Construction Heuristics:\n a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)\n b. Map temporal phrases: \"this week\" → updated:\u003e@today-7d\n c. Map negations: \"excluding wontfix\" → -label:wontfix\n d. Map priority adjectives: \"high/sev1/p1\" → priority:high OR priority:p1 (choose based on field presence)\n e. Map blocking relations: \"blocked by 123\" → parent-issue:\"owner/repo#123\"\n\nSyntax Essentials (items):\n AND: space-separated.\n OR: comma inside one qualifier (label:bug,critical).\n NOT: leading '-' (-label:wontfix).\n Hyphenate multi-word field names.\n Quote multi-word values.\n Ranges: points:1..3, updated:\u003c@today-30d.\n Wildcards: title:*crash*, label:bug*.\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" + } + }, + "required": [ + "method", + "owner_type", + "owner" + ], + "type": "object" + }, + "name": "project_read" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/project_write.snap b/pkg/github/__toolsnaps__/project_write.snap new file mode 100644 index 000000000..c63db05c5 --- /dev/null +++ b/pkg/github/__toolsnaps__/project_write.snap @@ -0,0 +1,62 @@ +{ + "annotations": { + "title": "Modify project items", + "readOnlyHint": false + }, + "description": "Write operations for GitHub Projects.\n\nMethods: add_project_item (add issue/PR), update_project_item (update fields), delete_project_item (remove item).\nNote: item_id for add is the issue/PR ID; for update/delete it's the project item ID.", + "inputSchema": { + "properties": { + "item_id": { + "description": "For add: issue/PR ID. For update/delete: project item ID (not issue/PR ID)", + "type": "number" + }, + "item_type": { + "description": "Type to add: 'issue' or 'pull_request' (required for add_project_item)", + "enum": [ + "issue", + "pull_request" + ], + "type": "string" + }, + "method": { + "description": "Write operation: add_project_item (needs item_type, item_id), update_project_item (needs item_id, updated_field), delete_project_item (needs item_id)", + "enum": [ + "add_project_item", + "update_project_item", + "delete_project_item" + ], + "type": "string" + }, + "owner": { + "description": "GitHub username or org name (case-insensitive)", + "type": "string" + }, + "owner_type": { + "description": "Owner type: 'user' or 'org'", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "project_number": { + "description": "Project number", + "type": "number" + }, + "updated_field": { + "description": "Field update object (required for update_project_item). Format: {\"id\": 123456, \"value\": \u003cvalue\u003e}. Value types: text=string, single-select=option ID (number), date=ISO string, number=number. Set value to null to clear.", + "properties": {}, + "type": "object" + } + }, + "required": [ + "method", + "owner_type", + "owner", + "project_number", + "item_id" + ], + "type": "object" + }, + "name": "project_write" +} \ No newline at end of file diff --git a/pkg/github/projects.go b/pkg/github/projects.go index eee4bcb6c..e02648c21 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -23,501 +23,235 @@ 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")), +// ProjectRead creates a tool to perform read operations on GitHub Projects V2. +// Supports getting and listing projects, project fields, and project items. +func ProjectRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("project_read", + mcp.WithDescription(t("TOOL_PROJECT_READ_DESCRIPTION", `Read operations for GitHub Projects. + +DECISION GUIDE (choose method): +- get_project: You have a project number; you need its metadata. +- list_projects: User wants to discover or filter projects (TITLE / OPEN STATE ONLY). +- get_project_field: You know a field_id and need its definition. +- list_project_fields: MUST call before fetching item field values (get IDs & types). +- get_project_item: You have an item_id (project item) and want its details. +- list_project_items: User wants issues/PRs inside a project filtered by criteria. + +INTENT TOKENS (map user phrasing → method): +[INTENT:DISCOVER_PROJECTS] → list_projects +[INTENT:INSPECT_PROJECT] → get_project +[INTENT:ENUM_FIELDS] → list_project_fields +[INTENT:FIELD_DETAILS] → get_project_field +[INTENT:LIST_ITEMS] → list_project_items +[INTENT:ITEM_DETAILS] → get_project_item + +CRITICAL DISTINCTION: +Projects ≠ Project Items. +- list_projects filters ONLY project metadata (title, open/closed). + DO NOT use item-level qualifiers (is:issue, is:pr, assignee:, label:, status:, parent-issue:, sprint-name:, etc). +- list_project_items filters ISSUES or PRs inside ONE project. Strongly prefer explicit type: is:issue OR is:pr unless user requests a mixed set. + +FAILURE MODES TO AVOID: +1. Missing pagination (stops early) → ALWAYS loop while pageInfo.hasNextPage=true. +2. Missing 'fields' when listing items → only title returned; no field values. +3. Using item filters in list_projects → returns zero or irrelevant results. +4. Ambiguous item type (issues vs PRs) → default to clarifying OR supply both (omit type only if user truly wants both). +5. Inventing field IDs → fetch via list_project_fields first. +6. INVENTING FIELD NAMES (NEW) → MUST use exact names returned by list_project_fields (case-insensitive match, preserve original spelling/hyphenation). + +FIELD NAME RESOLUTION (CRITICAL – ALWAYS DO BEFORE BUILDING QUERY WITH CUSTOM FIELDS): +1. Call list_project_fields → build a map of lowercased field name → original field name + type. +2. When user mentions a concept (e.g. "current sprint", "this iteration", "in the cycle"): + - Identify iteration-type fields (type == iteration). + - Accept synonyms in user phrasing: sprint, iteration, cycle. + - If user uses a generic phrase ("current sprint") and the existing iteration field is named "Sprint" → use sprint:@current. + - If the field is named "Cycle" → cycle:@current. + - If the field is named "Iteration" → iteration:@current. + - NEVER substitute a synonym that does not exist among field names. +3. For any other custom fields (e.g. "dev phase", "story points", "team name"): + - Normalize user phrase → lower-case, replace spaces with hyphens. + - Match against available field names in lower-case. + - Use the ORIGINAL field name in the query exactly (including hyphenation and case if needed). +4. If multiple iteration-type fields exist and the user intent is ambiguous → ask for clarification OR pick the one whose name best matches the user phrase. +5. INVALID if you use a field name not present in list_project_fields. + +VALID vs INVALID (Iteration Example): +User request: "Analyze the last week's activity ... for issues in the current sprint" +Fields contain iteration field named "sprint": + VALID: is:issue updated:>@today-7d sprint:@current + INVALID: is:issue updated:>@today-7d iteration:@current +Fields contain iteration field named "cycle": + VALID: is:issue updated:>@today-7d cycle:@current + INVALID: is:issue updated:>@today-7d iteration:@current +Fields contain iteration field named "iteration": + VALID: is:issue updated:>@today-7d iteration:@current + INVALID: is:issue updated:>@today-7d sprint:@current (if 'sprint' not defined) + +If NO iteration-type field exists → omit that qualifier OR clarify with user ("No iteration field found; continue without sprint filter?"). + +QUERY TRANSLATION (items): +User: "Open sprint issues assigned to me" → + state:open is:issue assignee:@me sprint:@current +User: "PRs waiting for review" → + is:pr status:"Ready for Review" +User: "High priority bugs updated this week" → + is:issue label:bug priority:high updated:>@today-7d + +SYNTAX RULES (items): +- AND: space-separated qualifiers. +- OR: comma inside one qualifier (label:bug,critical). +- NOT: prefix qualifier with '-' (-label:wontfix). +- Hyphenate multi-word field names: sprint-name, team-name, parent-issue. +- Quote multi-word values: status:"In Review". +- Comparison & ranges: priority:1..3 updated:<@today-14d. +- Wildcards: title:*search*, label:bug*. +- Presence: has:assignee, no:label, -no:assignee (force presence). + +GOOD PROJECT QUERIES (list_projects): + roadmap is:open + is:open feature planning +BAD (reject for list_projects — item filters present): + is:issue state:open + assignee:@me sprint-name:"Q3" + label:bug priority:high + +VALID ITEM QUERIES (list_project_items): + state:open is:issue priority:high sprint:@current + is:pr status:"In Review" team-name:"Backend Team" + is:issue -label:wontfix updated:>@today-30d + is:issue parent-issue:"github/repo#123" + +PAGINATION LOOP (ALL list_*): +1. Call list_*. +2. Read pageInfo.hasNextPage. +3. If true → call again with after=pageInfo.nextCursor (same query, fields, per_page). +4. Repeat until hasNextPage=false. +5. Aggregate ALL pages BEFORE summarizing. + +DATA COMPLETENESS RULE: +Never summarize, infer trends, or perform counts until all pages are retrieved. + +DEEP DETAILS: +Project item = lightweight wrapper. For full issue/PR inspection use issue_read or pull_request_read after enumerating items. + +DO: +- Normalize user intent → precise filters. +- Fetch fields first → pass IDs every page. +- Preserve consistency across pagination. +- Resolve and validate field names from list_project_fields BEFORE using them. + +DON'T: +- Mix project-only and item-only filters. +- Omit type when user scope is explicit. +- Invent field IDs or option IDs. +- Invent field names (e.g. use iteration:@current when only sprint exists). +- Stop early on pagination.`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), + Title: t("TOOL_PROJECT_READ_USER_TITLE", "Read project information"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("owner_type", - mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - 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.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), - ), - ), 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) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - var projects []*github.ProjectV2 - minimalProjects := []MinimalProject{} - - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: perPage}, - Query: queryStr, - } - - if ownerType == "org" { - projects, resp, err = client.Projects.ListProjectsForOrg(ctx, owner, opts) - } else { - projects, resp, err = client.Projects.ListProjectsForUser(ctx, owner, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - for _, project := range projects { - 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 - } - r, err := json.Marshal(minimalProjects) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_project", - mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithNumber("project_number", + mcp.WithString("method", mcp.Required(), - mcp.Description("The project's number"), + mcp.Description(`Read operation: get_project, list_projects, get_project_field, list_project_fields (call FIRST for IDs), get_project_item, list_project_items (use query + fields)`), + mcp.Enum("get_project", "list_projects", "get_project_field", "list_project_fields", "get_project_item", "list_project_items"), ), mcp.WithString("owner_type", mcp.Required(), - mcp.Description("Owner type"), + mcp.Description("Owner type: 'user' or 'org'"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), - 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."), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - 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 - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - var project *github.ProjectV2 - - if ownerType == "org" { - project, resp, err = client.Projects.GetProjectForOrg(ctx, owner, projectNumber) - } else { - project, resp, err = client.Projects.GetProjectForUser(ctx, owner, projectNumber) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project", - resp, - err, - ), nil - } - 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: %s", string(body))), nil - } - - minimalProject := convertToMinimalProject(project) - r, err := json.Marshal(minimalProject) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_project_fields", - mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org")), - mcp.WithString("owner", - mcp.Required(), - 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.Description("GitHub username or org name (case-insensitive)"), ), mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), + mcp.Description("Project number (required for most methods)"), ), - mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), - ), - ), 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) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var url string - if ownerType == "org" { - url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber) - } else { - url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber) - } - projectFields := []projectV2Field{} - - opts := paginationOptions{PerPage: perPage} - - url, err = addOptions(url, opts) - if err != nil { - return nil, fmt.Errorf("failed to add options to request: %w", err) - } - - httpRequest, err := client.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(ctx, httpRequest, &projectFields) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list project fields", - resp, - err, - ), nil - } - 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 - } - r, err := json.Marshal(projectFields) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_project_field", - mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), mcp.Enum("user", "org")), - mcp.WithString("owner", - mcp.Required(), - 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.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number.")), mcp.WithNumber("field_id", - mcp.Required(), - mcp.Description("The field's id."), - ), - ), 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 - } - fieldID, err := RequiredInt(req, "field_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var url string - if ownerType == "org" { - url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) - } else { - url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) - } - - projectField := projectV2Field{} - - httpRequest, err := client.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(ctx, httpRequest, &projectField) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project field", - resp, - err, - ), nil - } - 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 field: %s", string(body))), nil - } - r, err := json.Marshal(projectField) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -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.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - 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.Description("Field ID (required for get_project_field)"), ), - mcp.WithNumber("project_number", mcp.Required(), - mcp.Description("The project's number."), + mcp.WithNumber("item_id", + mcp.Description("Item ID (required for get_project_item)"), ), mcp.WithString("query", - mcp.Description("Search query to filter items"), + mcp.Description(`Query string (used ONLY with list_projects and list_project_items). + +Pattern Split: + +1. list_projects (project metadata only): + 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 + Reject & switch method if user intends items. + +2. list_project_items (issues / PRs inside ONE project): + 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) + 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. Map blocking relations: "blocked by 123" → parent-issue:"owner/repo#123" + +Syntax Essentials (items): + AND: space-separated. + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. + Quote multi-word values. + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + +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.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.WithStringItems(), - ), - ), 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) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - queryStr, err := OptionalParam[string](req, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - fields, err := OptionalStringArrayParam(req, "fields") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var url string - if ownerType == "org" { - url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber) - } else { - url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) - } - projectItems := []projectV2Item{} - - opts := listProjectItemsOptions{ - paginationOptions: paginationOptions{PerPage: perPage}, - filterQueryOptions: filterQueryOptions{Query: queryStr}, - fieldSelectionOptions: fieldSelectionOptions{Fields: fields}, - } - - url, err = addOptions(url, opts) - if err != nil { - return nil, fmt.Errorf("failed to add options to request: %w", err) - } - - httpRequest, err := client.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(ctx, httpRequest, &projectItems) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectListFailedError, - resp, - err, - ), nil - } - 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 - } - - r, err := json.Marshal(projectItems) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_project_item", - mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org"), + mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)), ), - mcp.WithString("owner", - mcp.Required(), - 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("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.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithNumber("item_id", - mcp.Required(), - mcp.Description("The item's ID."), + 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) { - owner, err := RequiredParam[string](req, "owner") + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := RequiredParam[string](request, "method") 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") + + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - fields, err := OptionalStringArrayParam(req, "fields") + + ownerType, err := RequiredParam[string](request, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -527,215 +261,159 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) return mcp.NewToolResultError(err.Error()), nil } - var url string - if ownerType == "org" { - url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } else { - url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } - - opts := fieldSelectionOptions{} - - if len(fields) > 0 { - opts.Fields = fields - } - - url, err = addOptions(url, opts) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + switch method { + case "get_project": + projectNumber, err := RequiredInt(request, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return getProject(ctx, client, owner, ownerType, projectNumber) - projectItem := projectV2Item{} + case "list_projects": + queryStr, err := OptionalParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - httpRequest, err := client.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } + pagination, err := extractPaginationOptions(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - resp, err := client.Do(ctx, httpRequest, &projectItem) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project item", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + return listProjects(ctx, client, owner, ownerType, queryStr, pagination) - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) + case "get_project_field": + projectNumber, err := RequiredInt(request, "project_number") if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - 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) - } + fieldID, err := RequiredInt(request, "field_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) - return mcp.NewToolResultText(string(r)), nil - } -} + case "list_project_fields": + projectNumber, err := RequiredInt(request, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } -func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("add_project_item", - mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - 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.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithString("item_type", - mcp.Required(), - mcp.Description("The item's type, either issue or pull_request."), - mcp.Enum("issue", "pull_request"), - ), - mcp.WithNumber("item_id", - mcp.Required(), - mcp.Description("The numeric ID of the issue or pull request to add to the project."), - ), - ), 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 - } - itemID, err := RequiredInt(req, "item_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + pagination, err := extractPaginationOptions(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - itemType, err := RequiredParam[string](req, "item_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if itemType != "issue" && itemType != "pull_request" { - return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil - } + return listProjectFields(ctx, client, owner, ownerType, projectNumber, pagination) - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + case "get_project_item": + projectNumber, err := RequiredInt(request, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredInt(request, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fields, err := OptionalStringArrayParam(request, "fields") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) - var projectsURL string - if ownerType == "org" { - projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber) - } else { - projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) - } + case "list_project_items": + projectNumber, err := RequiredInt(request, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - newItem := &newProjectItem{ - ID: int64(itemID), - Type: toNewProjectType(itemType), - } - httpRequest, err := client.NewRequest("POST", projectsURL, newItem) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - addedItem := projectV2Item{} + queryStr, err := OptionalParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - resp, err := client.Do(ctx, httpRequest, &addedItem) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectAddFailedError, - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + pagination, err := extractPaginationOptions(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) + fields, err := OptionalStringArrayParam(request, "fields") if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectAddFailedError, string(body))), nil - } - r, err := json.Marshal(addedItem) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - return mcp.NewToolResultText(string(r)), nil + return listProjectItems(ctx, client, owner, ownerType, projectNumber, queryStr, pagination, fields) + + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil + } } } -func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("update_project_item", - mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")), +// ProjectWrite creates a tool to perform write operations on GitHub Projects V2. +// Supports adding, updating, and deleting project items. +func ProjectWrite(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("project_write", + mcp.WithDescription(t("TOOL_PROJECT_WRITE_DESCRIPTION", `Write operations for GitHub Projects. + +Methods: add_project_item (add issue/PR), update_project_item (update fields), delete_project_item (remove item). +Note: item_id for add is the issue/PR ID; for update/delete it's the project item ID.`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), + Title: t("TOOL_PROJECT_WRITE_USER_TITLE", "Modify project items"), ReadOnlyHint: ToBoolPtr(false), }), + mcp.WithString("method", + mcp.Required(), + mcp.Description(`Write operation: add_project_item (needs item_type, item_id), update_project_item (needs item_id, updated_field), delete_project_item (needs item_id)`), + mcp.Enum("add_project_item", "update_project_item", "delete_project_item"), + ), mcp.WithString("owner_type", - mcp.Required(), mcp.Description("Owner type"), + mcp.Required(), + mcp.Description("Owner type: 'user' or 'org'"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), - 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.Description("GitHub username or org name (case-insensitive)"), ), mcp.WithNumber("project_number", mcp.Required(), - mcp.Description("The project's number."), + mcp.Description("Project number"), ), mcp.WithNumber("item_id", mcp.Required(), - mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), + mcp.Description("For add: issue/PR ID. For update/delete: project item ID (not issue/PR ID)"), + ), + mcp.WithString("item_type", + mcp.Description("Type to add: 'issue' or 'pull_request' (required for add_project_item)"), + mcp.Enum("issue", "pull_request"), ), mcp.WithObject("updated_field", - mcp.Required(), - mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), + mcp.Description("Field update object (required for update_project_item). Format: {\"id\": 123456, \"value\": }. Value types: text=string, single-select=option ID (number), date=ISO string, number=number. Set value to null to clear."), ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := RequiredParam[string](request, "method") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - ownerType, err := RequiredParam[string](req, "owner_type") + + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - projectNumber, err := RequiredInt(req, "project_number") + + ownerType, err := RequiredParam[string](request, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - itemID, err := RequiredInt(req, "item_id") + + projectNumber, err := RequiredInt(request, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - rawUpdatedField, exists := req.GetArguments()["updated_field"] - if !exists { - return mcp.NewToolResultError("missing required parameter: updated_field"), nil - } - - fieldValue, ok := rawUpdatedField.(map[string]any) - if !ok || fieldValue == nil { - return mcp.NewToolResultError("field_value must be an object"), nil - } - - updatePayload, err := buildUpdateProjectItem(fieldValue) + itemID, err := RequiredInt(request, "item_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -745,123 +423,443 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultError(err.Error()), nil } - var projectsURL string - if ownerType == "org" { - projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } else { - projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } - httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{ - Fields: []updateProjectItem{*updatePayload}, - }) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - updatedItem := projectV2Item{} - - resp, err := client.Do(ctx, httpRequest, &updatedItem) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + switch method { + case "add_project_item": + itemType, err := RequiredParam[string](request, "item_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if itemType != "issue" && itemType != "pull_request" { + return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil + } + return addProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, itemType) - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) + case "update_project_item": + rawUpdatedField, exists := request.GetArguments()["updated_field"] + if !exists { + return mcp.NewToolResultError("missing required parameter: updated_field"), nil + } + fieldValue, ok := rawUpdatedField.(map[string]any) + if !ok || fieldValue == nil { + return mcp.NewToolResultError("updated_field must be an object"), nil + } + updatePayload, err := buildUpdateProjectItem(fieldValue) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectUpdateFailedError, string(body))), nil - } - r, err := json.Marshal(updatedItem) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return updateProjectItemHelper(ctx, client, owner, ownerType, projectNumber, itemID, updatePayload) + + case "delete_project_item": + return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil } + } +} + +func getProject(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int) (*mcp.CallToolResult, error) { + var project *github.ProjectV2 + var resp *github.Response + var err error + + if ownerType == "org" { + project, resp, err = client.Projects.GetProjectForOrg(ctx, owner, projectNumber) + } else { + project, resp, err = client.Projects.GetProjectForUser(ctx, owner, projectNumber) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + resp, + err, + ), nil + } + + minimalProject := convertToMinimalProject(project) + r, err := json.Marshal(minimalProject) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func listProjects(ctx context.Context, client *github.Client, owner string, ownerType string, queryStr string, pagination paginationOptions) (*mcp.CallToolResult, error) { + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{ + PerPage: pagination.PerPage, + After: pagination.After, + Before: pagination.Before, + }, + Query: queryStr, + } + + var projects []*github.ProjectV2 + var resp *github.Response + var err error + + if ownerType == "org" { + projects, resp, err = client.Projects.ListProjectsForOrg(ctx, owner, opts) + } else { + projects, resp, err = client.Projects.ListProjectsForUser(ctx, owner, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil + } + + minimalProjects := []MinimalProject{} + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) + } + + // Create response with pagination info + response := map[string]any{ + "projects": minimalProjects, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func getProjectField(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, fieldID int) (*mcp.CallToolResult, error) { + var url string + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) + } + + var projectField projectV2Field - return mcp.NewToolResultText(string(r)), nil + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, &projectField) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + resp, + err, + ), nil + } + 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 field: %s", string(body))), nil + } + + r, err := json.Marshal(projectField) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil } -func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_project_item", - mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - 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.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithNumber("item_id", - mcp.Required(), - mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."), - ), - ), 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 - } - itemID, err := RequiredInt(req, "item_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func listProjectFields(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, pagination paginationOptions) (*mcp.CallToolResult, error) { + var url string + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber) + } - var projectsURL string - if ownerType == "org" { - projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } else { - projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } + type listProjectFieldsOptions struct { + paginationOptions + } - httpRequest, err := client.NewRequest("DELETE", projectsURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } + opts := listProjectFieldsOptions{ + paginationOptions: pagination, + } - resp, err := client.Do(ctx, httpRequest, nil) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + url, err := addOptions(url, opts) + if err != nil { + return nil, fmt.Errorf("failed to add options to request: %w", err) + } - if resp.StatusCode != http.StatusNoContent { - 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", ProjectDeleteFailedError, string(body))), nil - } - return mcp.NewToolResultText("project item successfully deleted"), nil + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var projectFields []*projectV2Field + resp, err := client.Do(ctx, httpRequest, &projectFields) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project fields", + resp, + err, + ), nil + } + 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 + } + + // Create response with pagination info + response := map[string]any{ + "fields": projectFields, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func getProjectItem(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int, fields []string) (*mcp.CallToolResult, error) { + var url string + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + + opts := fieldSelectionOptions{} + + if len(fields) > 0 { + opts.Fields = strings.Join(fields, ",") + } + + url, err := addOptions(url, opts) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + projectItem := projectV2Item{} + + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, &projectItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + resp, + err, + ), nil + } + 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) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func listProjectItems(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, queryStr string, pagination paginationOptions, fields []string) (*mcp.CallToolResult, error) { + var url string + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) + } + projectItems := []projectV2Item{} + + opts := listProjectItemsOptions{ + paginationOptions: pagination, + filterQueryOptions: filterQueryOptions{Query: queryStr}, + fieldSelectionOptions: fieldSelectionOptions{Fields: strings.Join(fields, ",")}, + } + + url, err := addOptions(url, opts) + if err != nil { + return nil, fmt.Errorf("failed to add options to request: %w", err) + } + + fmt.Println("URL for listProjectItems:") + fmt.Println(url) + + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, &projectItems) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectListFailedError, + resp, + err, + ), nil + } + 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 + } + + // Create response with pagination info + response := map[string]any{ + "items": projectItems, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// Helper functions for ProjectWrite + +func addProjectItem(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int, itemType string) (*mcp.CallToolResult, error) { + var projectsURL string + if ownerType == "org" { + projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber) + } else { + projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) + } + + newItem := &newProjectItem{ + ID: int64(itemID), + Type: toNewProjectType(itemType), + } + httpRequest, err := client.NewRequest("POST", projectsURL, newItem) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addedItem := projectV2Item{} + + resp, err := client.Do(ctx, httpRequest, &addedItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectAddFailedError, + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + 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", ProjectAddFailedError, string(body))), nil + } + r, err := json.Marshal(addedItem) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func updateProjectItemHelper(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int, updatePayload *updateProjectItem) (*mcp.CallToolResult, error) { + var projectsURL string + if ownerType == "org" { + projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{ + Fields: []updateProjectItem{*updatePayload}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + updatedItem := projectV2Item{} + + resp, err := client.Do(ctx, httpRequest, &updatedItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + resp, + err, + ), nil + } + 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", ProjectUpdateFailedError, string(body))), nil + } + r, err := json.Marshal(updatedItem) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func deleteProjectItem(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int) (*mcp.CallToolResult, error) { + var projectsURL string + if ownerType == "org" { + projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + + httpRequest, err := client.NewRequest("DELETE", projectsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + 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", ProjectDeleteFailedError, string(body))), nil + } + return mcp.NewToolResultText("project item successfully deleted"), nil } type newProjectItem struct { @@ -879,21 +877,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"` // 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 any `json:"value,omitempty"` // The value of the field for a specific project item. } type projectV2Item struct { @@ -926,8 +925,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 { @@ -937,7 +945,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 +989,43 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { return payload, nil } +// buildPageInfo creates a pageInfo struct from the GitHub API response +func buildPageInfo(resp *github.Response) pageInfo { + return pageInfo{ + HasNextPage: resp.After != "", + HasPreviousPage: resp.Before != "", + NextCursor: resp.After, + PrevCursor: resp.Before, + } +} + +// extractPaginationOptions extracts and validates pagination parameters from a tool request +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) { @@ -999,16 +1044,18 @@ func addOptions(s string, opts any) (string, error) { return s, err } + fmt.Println("URL for listProjectItems:") + fmt.Println(qs.Encode()) + u.RawQuery = qs.Encode() return u.String(), nil } 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 +1068,315 @@ 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(`You are an assistant for GitHub Projects V2. +PRIMARY GOAL: Select correct method and produce COMPLETE results (no pagination truncation). + +METHOD DECISION FLOW: +1. Need list of projects? → project_read list_projects +2. Have project_number → need its metadata? → project_read get_project +3. Need field definitions (IDs/types)? → project_read list_project_fields +4. Have field_id → single field details? → project_read get_project_field +5. Need issues/PRs inside one project? → project_read list_project_items +6. Have item_id → full item wrapper? → project_read get_project_item +7. Need to modify items? → project_write (add/update/delete) +8. Need deep issue/PR details beyond wrapper? → issue_read / pull_request_read + +CORE RULES (NON-NEGOTIABLE): +- Call list_project_fields BEFORE querying items for field values. +- ALWAYS include 'query' when calling list_project_items. +- ALWAYS include 'fields' (IDs) on EVERY paginated call if field values matter. +- ALWAYS paginate until pageInfo.hasNextPage=false. +- NEVER use item-level qualifiers with list_projects. +- STRONGLY prefer is:issue or is:pr in item queries when scope is clear. +- DO NOT summarize or count until all pages fetched. + +QUERY BUILDING (ITEMS): +Translate user intent → structured filters: +- "open sprint issues assigned to me" → state:open is:issue assignee:@me sprint-name:@current +- "recent merged PRs backend team" → state:merged is:pr team-name:"Backend Team" updated:>@today-7d +- "exclude wontfix high priority bugs" → is:issue label:bug priority:high -label:wontfix state:open + +SYNTAX: +AND: space. OR: comma (label:bug,critical). NOT: -label:wontfix. +Quote multi-word values. Hyphenate multi-word field names (sprint-name). +Ranges: points:1..3, updated:<@today-14d. +Temporal shortcuts: @today @today-7d @today-30d. +Iteration shortcuts: @current @next @previous. +Comparison Operators: (For number, date, and iteration field types) +- field:>VALUE priority:>1 will show items with a priority greater than 1. +- field:>=VALUE date:>=2022-06-01 will show items with a date of "2022-06-01" or later. +- field::<"Iteration 5" will show items with an iteration before "Iteration 5." +- field:<=VALUE points:<=10 will show items with 10 or less points. + +PAGINATION LOOP: +1. Call list_project_items +2. If pageInfo.hasNextPage=true → repeat with after=nextCursor (same query, fields, per_page) +3. Aggregate until hasNextPage=false. + +RECOVERY / AMBIGUITY: +- If user says “show items” with no type: omit is:issue/is:pr OR ask clarifying question. +- If unknown field name appears: fetch list_project_fields, match by normalized lower-case; never guess. + +DO / DON'T: +DO normalize user phrases → filters. +DO preserve parameter consistency when paginating. +DON'T invent field IDs or option IDs. +DON'T halt early on pagination. +DON'T mix discovery (list_projects) with item filtering. + +HANDOFF: +For deeper issue/PR details (comments, diff, reviews) → use issue_read / pull_request_read after enumerating items. + +QUALITY GUARANTEE: +Return COMPLETE data sets or explicitly state what's missing (e.g., user withheld pagination).`), }, { 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("I'll help manage GitHub Projects. First, let's list projects using **project_read** with method=\"list_projects\"."), + }, + { + 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(`**Working with Fields & Items (Field Name Resolution Is Critical)** + +1. Enumerate fields first: + Use project_read method="list_project_fields" project_number=. + Build a lookup: lowercased field name -> original name + data_type. + +2. Resolve user phrases to actual field names: + - Normalize user phrase: lowercase, trim, replace spaces with hyphens for matching. + - Only use EXACT existing field names returned by list_project_fields. + - Never invent synonyms; if user says "sprint" but only "Iteration" exists, use iteration:@current (NOT sprint:@current). + +3. Iteration field synonyms: + User may say: "sprint", "iteration", "cycle", "current sprint", "this cycle". + VALID substitution depends on actual field: + - If field list contains "Sprint": sprint:@current + - If field list contains "Cycle": cycle:@current + - If field list contains "Iteration": iteration:@current + INVALID examples: + - iteration:@current when only "Sprint" exists + - sprint:@current when only "Cycle" exists + +4. Other custom fields (examples): + - "dev phase" → dev-phase: (if field name is "Dev Phase" or "dev-phase") + - "story points" → story-points: + - Preserve hyphenation EXACTLY as in the original field name. + +5. If ambiguous or multiple candidates (e.g., both "Sprint" and "Iteration"): + - Prefer the one that matches the user's wording. + - Ask for clarification if intent is unclear. + +6. Build the query AFTER resolving field names: + Example request: "Analyze last week's activity for issues in the current sprint" + - Temporal phrase "last week's activity" → updated:>@today-7d + - Content type "issues" → is:issue + - "current sprint" (field name?): + sprint:@current (if 'Sprint') + cycle:@current (if 'Cycle') + iteration:@current (if 'Iteration') + Final VALID queries depend entirely on actual field names. + +7. Always paginate: + Check pageInfo.hasNextPage. If true, repeat with after= (same query, fields, per_page). + +8. Include fields parameter: + Pass fields=["", "", ...] on EVERY page if you want field values. + +Remember: Field presence governs filter legality. If a field doesn’t exist, either omit that filter or ask for clarification.`), }, { 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(`**Update Item Field Values:** project_write method="update_project_item" with updated_field: + +Text: {"id": 123456, "value": "text"} +Single-select: {"id": 198354254, "value": 18498754} (value = option ID) +Iteration: {"id": 198354254, "value": 18498754} (value = configuration's iteration ID) +Date: {"id": 789012, "value": "2025-03-15"} +Number: {"id": 345678, "value": 5} +Clear: {"id": 123456, "value": null} + +Requirements: +- Use the project item_id (wrapper), NOT the issue/PR number. +- Confirm field ID from list_project_fields before updating. +- Single-select requires the numeric option ID (do not pass the name). +- Iteration requires the iteration ID from the field configuration (do not pass the name).`), }, { 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 (Including Field Name Resolution):** + +1. Discover projects: + project_read method="list_projects" owner= owner_type= + +2. Get fields (MUST before filtering on custom fields): + project_read method="list_project_fields" project_number= + → Build map: lowercased field name -> {originalName, type, id} + +3. Resolve user intent → query: + - User phrase: "current sprint high priority issues updated this week" + - Fields: has 'Sprint' (iteration type), has 'Priority' + - Query: is:issue sprint:@current priority:high updated:>@today-7d + +4. List items (FIRST page): + project_read method="list_project_items" + project_number= + query="is:issue sprint:@current priority:high updated:>@today-7d" + fields=["", "", ""] + +5. Pagination: + If pageInfo.hasNextPage=true → repeat step 4 with after= (same query, fields, per_page). + Continue until hasNextPage=false. Aggregate all pages. + +6. Deeper inspection: + Only when needed: for each item → extract repository + issue/PR number → call issue_read or pull_request_read for comments, reviews, etc. + +7. Update a field: + project_write method="update_project_item" project_number= item_id= + updated_field={"id": , "value": "high"} + +**CRITICAL REMINDERS:** +- Never use iteration:@current when only 'Sprint' exists. +- Fields parameter MUST be identical across pagination calls. +- Don't summarize until all pages are collected.`), }, { 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 Is Mandatory** + +Rules: +1. Inspect pageInfo.hasNextPage on EVERY response. +2. If true → call again with after=pageInfo.nextCursor. +3. Keep query, fields, per_page EXACTLY the same. +4. Loop until hasNextPage=false. +5. Aggregate all items BEFORE analysis or summarization. + +Example: +Page 1: hasNextPage=true → after="abc123" +Page 2: hasNextPage=true → after="def456" +Page 3: hasNextPage=false → DONE + +Field Value Integrity: +- If you include fields=["123","456"] on page 1, you MUST include them on subsequent pages. +- Omitting fields mid-pagination yields inconsistent item data. + +Never: +- Stop early. +- Change filters mid-sequence. +- Drop fields array after the first page.`), }, { 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(`**Deep Details Beyond Project Items** + +Project item wrapper gives: title, item URL, basic content state, and selected field values. +For full issue/PR context (comments, reviews, diff, labels): + +Issues: + issue_read method="get" + issue_read method="get_comments" + issue_read method="get_labels" + issue_read method="get_sub_issues" + +Pull Requests: + pull_request_read method="get" + pull_request_read method="get_reviews" + pull_request_read method="get_review_comments" + pull_request_read method="get_files" + pull_request_read method="get_diff" + pull_request_read method="get_status" + +Workflow: +1. Enumerate with list_project_items (capture repository + number). +2. Use repository.owner.login + repository.name + content.number for deeper calls. +3. Combine field-derived status + external discussions for a richer report. + +Always confirm item type (is:issue vs is:pr) before selecting downstream method.`), }, { 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 Building for Reports (Field Name Integrity)** + +Preparation: +- Run list_project_fields first. +- Normalize user terms to actual field names (lowercase match). +- Use returned names; preserve hyphens. + +Patterns: +- "blocked issues" → is:issue (label:blocked OR status:"Blocked" OR dev-phase:"Blocked" depending on existing fields) +- "overdue tasks" (field 'due-date') → is:issue due-date:<@today state:open +- "PRs ready for review" (field 'review-status') → 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 PRs current sprint" (fields: 'team-name', 'Sprint') → is:pr team-name:"Backend Team" sprint:@current +- "iteration tracking last week" (field 'Iteration') → is:issue updated:>@today-7d iteration:@current state:open + +Rules: +- Content type first: is:issue or is:pr unless mixed set requested. +- Temporal: "last week" → updated:>@today-7d; "last 30 days" → updated:>@today-30d +- Multi-word values must be quoted: team-name:"Backend Team" +- OR logic: label:bug,critical +- NOT logic: -label:wontfix +- Comparisons: + - Greater than: + - number-field:>5 + - date-field:>2024-06-01 + - iteration-field:>"iteration 2" + - Less than: + - number-field:<3 + - date-field:<2024-12-31 + - iteration-field:<"iteration 2" + - Greater than or equal to: + - number-field:>=4 + - date-field:>=2024-05-15 + - iteration-field:>="iteration 1" + - Less than or equal to: + - number-field:<=8 + - date-field:<=2024-11-30 + - iteration-field:<="iteration 3" +- Range: + - number-field:1..10 + - date-field:2024-01-01..2024-12-31 + - iteration-field:"iteration 1..iteration 3" + +INVALID examples: +- sprint:@current when only 'Iteration' exists +- iteration:@current when only 'Sprint' exists +- Using dev-phase:"In Progress" when no 'dev-phase' field exists (must clarify) + +Golden Rule: +Never invent field names or IDs. Always source from list_project_fields.`), }, } return &mcp.GetPromptResult{ diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 6cfbda0fe..f427aaf8a 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -2,8 +2,6 @@ package github import ( "context" - "encoding/json" - "io" "net/http" "testing" @@ -15,32 +13,40 @@ import ( "github.com/stretchr/testify/require" ) -func Test_ListProjects(t *testing.T) { +func Test_ProjectRead(t *testing.T) { mockClient := gh.NewClient(nil) - tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ProjectRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_projects", tool.Name) + assert.Equal(t, "project_read", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "field_id") + assert.Contains(t, tool.InputSchema.Properties, "item_id") assert.Contains(t, tool.InputSchema.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) + assert.Contains(t, tool.InputSchema.Properties, "fields") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "owner_type"}) orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} - userProjects := []map[string]any{{"id": 2, "title": "User Project"}} + orgProject := map[string]any{"id": 1, "title": "Org Project"} + projectFields := []map[string]any{{"id": 100, "name": "Status"}} + projectField := map[string]any{"id": 100, "name": "Status"} + projectItems := []map[string]any{{"id": 1000, "title": "Item 1"}} + projectItem := map[string]any{"id": 1000, "title": "Item 1"} tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool - expectedLength int expectedErrMsg string }{ { - name: "success organization", + name: "list_projects success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, @@ -48,1602 +54,297 @@ func Test_ListProjects(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "list_projects", "owner": "octo-org", "owner_type": "org", }, - expectError: false, - expectedLength: 1, - }, - { - name: "success user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userProjects), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - }, - expectError: false, - expectedLength: 1, + expectError: false, }, { - name: "success organization with pagination & query", + name: "get_project success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgProject), ), ), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "per_page": float64(50), - "query": "roadmap", + "method": "get_project", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), }, - expectError: false, - expectedLength: 1, + expectError: false, }, { - name: "api error", + name: "list_project_fields success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/fields", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, projectFields), ), ), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - expectedErrMsg: "failed to list projects", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner_type": "org", - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), }, - expectError: true, + expectError: false, }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var arr []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &arr) - require.NoError(t, err) - assert.Equal(t, tc.expectedLength, len(arr)) - }) - } -} - -func Test_GetProject(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"}) - - project := map[string]any{"id": 123, "title": "Project Title"} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ { - name: "success organization project fetch", + name: "get_project_field success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/fields/{field_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, projectField), ), ), requestArgs: map[string]interface{}{ - "project_number": float64(123), + "method": "get_project_field", "owner": "octo-org", "owner_type": "org", + "project_number": float64(1), + "field_id": float64(100), }, expectError: false, }, { - name: "success user project fetch", + name: "list_project_items success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, projectItems), ), ), requestArgs: map[string]interface{}{ - "project_number": float64(456), - "owner": "octocat", - "owner_type": "user", + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), }, expectError: false, }, { - name: "api error", + name: "get_project_item success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, projectItem), ), ), requestArgs: map[string]interface{}{ - "project_number": float64(999), + "method": "get_project_item", "owner": "octo-org", "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1000), }, - expectError: true, - expectedErrMsg: "failed to get project", + expectError: false, }, { - name: "missing project_number", + name: "missing method", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", }, - expectError: true, + expectError: true, + expectedErrMsg: "missing required parameter: method", }, { name: "missing owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner_type": "org", + "method": "list_projects", + "owner_type": "org", }, - expectError: true, + expectError: true, + expectedErrMsg: "missing required parameter: owner", }, { - name: "missing owner_type", + name: "invalid method", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner": "octo-org", + "method": "invalid_method", + "owner": "octo-org", + "owner_type": "org", }, - expectError: true, + expectError: true, + expectedErrMsg: "unknown method: invalid_method", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) + ctx := context.Background() + mockClient := gh.NewClient(tc.mockedClient) + _, handler := ProjectRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + result, err := handler(ctx, createMCPRequest(tc.requestArgs)) require.NoError(t, err) + if tc.expectError { require.True(t, result.IsError) text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } + assert.Contains(t, text, tc.expectedErrMsg) return } require.False(t, result.IsError) - textContent := getTextResult(t, result) - var arr map[string]any - err = json.Unmarshal([]byte(textContent.Text), &arr) - require.NoError(t, err) }) } } -func Test_ListProjectFields(t *testing.T) { +func Test_ProjectWrite(t *testing.T) { mockClient := gh.NewClient(nil) - tool, _ := ListProjectFields(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ProjectWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_project_fields", tool.Name) + assert.Equal(t, "project_write", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "owner_type") assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.Contains(t, tool.InputSchema.Properties, "item_type") + assert.Contains(t, tool.InputSchema.Properties, "updated_field") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "owner_type", "project_number", "item_id"}) - orgFields := []map[string]any{ - {"id": 101, "name": "Status", "dataType": "single_select"}, - } - userFields := []map[string]any{ - {"id": 201, "name": "Priority", "dataType": "single_select"}, - } + addedItem := map[string]any{"id": 1000, "title": "Added Item"} + updatedItem := map[string]any{"id": 1000, "title": "Updated Item"} tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool - expectedLength int expectedErrMsg string }{ { - name: "success organization fields", + name: "add_project_item success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgFields), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items", Method: http.MethodPost}, + mockResponse(t, http.StatusCreated, addedItem), ), ), requestArgs: map[string]interface{}{ + "method": "add_project_item", "owner": "octo-org", "owner_type": "org", - "project_number": float64(123), + "project_number": float64(1), + "item_id": float64(123), + "item_type": "issue", }, - expectedLength: 1, + expectError: false, }, { - name: "success user fields with per_page override", + name: "update_project_item success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userFields)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodPatch}, + mockResponse(t, http.StatusOK, updatedItem), ), ), requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "per_page": float64(50), + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1000), + "updated_field": map[string]any{"id": float64(100), "value": "New Value"}, }, - expectedLength: 1, + expectError: false, }, { - name: "api error", + name: "delete_project_item success", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodDelete}, + mockResponse(t, http.StatusNoContent, nil), ), ), requestArgs: map[string]interface{}{ + "method": "delete_project_item", "owner": "octo-org", "owner_type": "org", - "project_number": float64(789), + "project_number": float64(1), + "item_id": float64(1000), }, - expectError: true, - expectedErrMsg: "failed to list project fields", + expectError: false, }, { - name: "missing owner", + name: "missing method", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ + "owner": "octo-org", "owner_type": "org", - "project_number": 10, + "project_number": float64(1), + "item_id": float64(123), }, - expectError: true, + expectError: true, + expectedErrMsg: "missing required parameter: method", }, { - name: "missing owner_type", + name: "add_project_item missing item_type", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ + "method": "add_project_item", "owner": "octo-org", - "project_number": 10, + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), }, - expectError: true, + expectError: true, + expectedErrMsg: "missing required parameter: item_type", }, { - name: "missing project_number", + name: "update_project_item missing updated_field", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := ListProjectFields(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var fields []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &fields) - require.NoError(t, err) - assert.Equal(t, tc.expectedLength, len(fields)) - }) - } -} - -func Test_GetProjectField(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project_field", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "field_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) - - orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} - userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgField), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "field_id": float64(101), - }, - expectedID: 101, - }, - { - name: "success user field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userField), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "field_id": float64(202), - }, - expectedID: 202, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ + "method": "update_project_item", "owner": "octo-org", "owner_type": "org", - "project_number": float64(789), - "field_id": float64(303), + "project_number": float64(1), + "item_id": float64(1000), }, expectError: true, - expectedErrMsg: "failed to get project field", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(10), - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(10), - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "field_id": float64(1), - }, - expectError: true, + expectedErrMsg: "missing required parameter: updated_field", }, { - name: "missing field_id", + name: "invalid method", mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ + requestArgs: map[string]interface{}{ + "method": "invalid_method", "owner": "octo-org", "owner_type": "org", - "project_number": float64(10), + "project_number": float64(1), + "item_id": float64(123), }, - expectError: true, + expectError: true, + expectedErrMsg: "unknown method: invalid_method", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing field_id" { - assert.Contains(t, text, "missing required parameter: field_id") - } - return - } + ctx := context.Background() + mockClient := gh.NewClient(tc.mockedClient) - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var field map[string]any - err = json.Unmarshal([]byte(textContent.Text), &field) + _, handler := ProjectWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) + result, err := handler(ctx, createMCPRequest(tc.requestArgs)) require.NoError(t, err) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), field["id"]) - } - }) - } -} -func Test_ListProjectItems(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_project_items", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.Contains(t, tool.InputSchema.Properties, "fields") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) - - orgItems := []map[string]any{ - {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{ - {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"}, - {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"}, - }}, - } - userItems := []map[string]any{ - {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, - {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedLength int - expectedErrMsg string - }{ - { - name: "success organization items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItems), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - }, - expectedLength: 1, - }, - { - name: "success organization items with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - 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" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "fields": []interface{}{"123", "456", "789"}, - }, - expectedLength: 1, - }, - { - name: "success user items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItems), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - }, - expectedLength: 2, - }, - { - name: "success with pagination and query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "bug" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "per_page": float64(50), - "query": "bug", - }, - expectedLength: 1, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - }, - expectError: true, - expectedErrMsg: ProjectListFailedError, - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "project_number": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := ListProjectItems(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var items []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &items) - require.NoError(t, err) - assert.Equal(t, tc.expectedLength, len(items)) - }) - } -} - -func Test_GetProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.Contains(t, tool.InputSchema.Properties, "fields") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - - orgItem := map[string]any{ - "id": 301, - "content_type": "Issue", - "project_node_id": "PR_1", - "creator": map[string]any{"login": "octocat"}, - } - userItem := map[string]any{ - "id": 501, - "content_type": "PullRequest", - "project_node_id": "PR_2", - "creator": map[string]any{"login": "jane"}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItem), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(301), - }, - expectedID: 301, - }, - { - name: "success organization item with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - 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" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItem)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(301), - "fields": []interface{}{"123", "456"}, - }, - expectedID: 301, - }, - { - name: "success user item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItem), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "item_id": float64(501), - }, - expectedID: 501, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - "item_id": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get project item", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(10), - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(10), - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := GetProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing item_id" { - assert.Contains(t, text, "missing required parameter: item_id") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - err = json.Unmarshal([]byte(textContent.Text), &item) - require.NoError(t, err) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - }) - } -} - -func Test_AddProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "add_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_type") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"}) - - orgItem := map[string]any{ - "id": 601, - "content_type": "Issue", - "creator": map[string]any{ - "login": "octocat", - "id": 1, - "html_url": "https://github.com/octocat", - "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", - }, - } - - userItem := map[string]any{ - "id": 701, - "content_type": "PullRequest", - "creator": map[string]any{ - "login": "hubot", - "id": 2, - "html_url": "https://github.com/hubot", - "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4", - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - expectedContentType string - expectedCreatorLogin string - }{ - { - name: "success organization issue", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "Issue", payload.Type) - assert.Equal(t, 9876, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(orgItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(321), - "item_type": "issue", - "item_id": float64(9876), - }, - expectedID: 601, - expectedContentType: "Issue", - expectedCreatorLogin: "octocat", - }, - { - name: "success user pull request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "PullRequest", payload.Type) - assert.Equal(t, 7654, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(userItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(222), - "item_type": "pull_request", - "item_id": float64(7654), - }, - expectedID: 701, - expectedContentType: "PullRequest", - expectedCreatorLogin: "hubot", - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(999), - "item_type": "issue", - "item_id": float64(8888), - }, - expectError: true, - expectedErrMsg: ProjectAddFailedError, - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_type": "Issue", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := AddProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - require.NoError(t, err) - - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_type": - assert.Contains(t, text, "missing required parameter: item_type") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - // case "api error": - // assert.Contains(t, text, ProjectAddFailedError) - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - if tc.expectedContentType != "" { - assert.Equal(t, tc.expectedContentType, item["content_type"]) - } - if tc.expectedCreatorLogin != "" { - creator, ok := item["creator"].(map[string]any) - require.True(t, ok) - assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) - } - }) - } -} - -func Test_UpdateProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "update_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.Contains(t, tool.InputSchema.Properties, "updated_field") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}) - - orgUpdatedItem := map[string]any{ - "id": 801, - "content_type": "Issue", - } - userUpdatedItem := map[string]any{ - "id": 802, - "content_type": "PullRequest", - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 101, payload.Fields[0].ID) - assert.Equal(t, "Done", payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1001), - "item_id": float64(5555), - "updated_field": map[string]any{ - "id": float64(101), - "value": "Done", - }, - }, - expectedID: 801, - }, - { - name: "success user update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 202, payload.Fields[0].ID) - assert.Equal(t, 42.0, payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2002), - "item_id": float64(6666), - "updated_field": map[string]any{ - "id": float64(202), - "value": float64(42), - }, - }, - expectedID: 802, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(3003), - "item_id": float64(7777), - "updated_field": map[string]any{ - "id": float64(303), - "value": "In Progress", - }, - }, - expectError: true, - expectedErrMsg: "failed to update a project item", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "field_id": float64(1), - "new_field": map[string]any{ - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_id": float64(2), - "new_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(2), - "new_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "new_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing field_value", - 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", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": "not-an-object", - }, - expectError: true, - }, - { - name: "new_field missing id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{}, - }, - expectError: true, - }, - { - name: "new_field missing value", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{ - "id": float64(9), - }, - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := UpdateProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - 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": - assert.Contains(t, text, "missing required parameter: updated_field") - case "field_value 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") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - }) - } -} - -func Test_DeleteProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedText string - }{ - { - name: "success organization delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(555), - }, - expectedText: "project item successfully deleted", - }, - { - name: "success user delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "item_id": float64(777), - }, - expectedText: "project item successfully deleted", - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(321), - "item_id": float64(999), - }, - expectError: true, - expectedErrMsg: ProjectDeleteFailedError, - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := DeleteProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) if tc.expectError { require.True(t, result.IsError) text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - } + assert.Contains(t, text, tc.expectedErrMsg) return } require.False(t, result.IsError) - text := getTextResult(t, result).Text - assert.Contains(t, text, tc.expectedText) }) } } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 4296aaa72..8489109da 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -317,20 +317,13 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG projects := toolsets.NewToolset(ToolsetMetadataProjects.ID, ToolsetMetadataProjects.Description). AddReadTools( - toolsets.NewServerTool(ListProjects(getClient, t)), - toolsets.NewServerTool(GetProject(getClient, t)), - toolsets.NewServerTool(ListProjectFields(getClient, t)), - toolsets.NewServerTool(GetProjectField(getClient, t)), - toolsets.NewServerTool(ListProjectItems(getClient, t)), - toolsets.NewServerTool(GetProjectItem(getClient, t)), + toolsets.NewServerTool(ProjectRead(getClient, t)), ). AddWriteTools( - toolsets.NewServerTool(AddProjectItem(getClient, t)), - toolsets.NewServerTool(DeleteProjectItem(getClient, t)), - toolsets.NewServerTool(UpdateProjectItem(getClient, t)), + toolsets.NewServerTool(ProjectWrite(getClient, t)), ).AddPrompts( toolsets.NewServerPrompt(ManageProjectItemsPrompt(t)), - ) + ).AddResourceTemplates() stargazers := toolsets.NewToolset(ToolsetMetadataStargazers.ID, ToolsetMetadataStargazers.Description). AddReadTools( toolsets.NewServerTool(ListStarredRepositories(getClient, t)), From c7b8125906ad3e5e91989cfc9a6a26395d8651f1 Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Sun, 2 Nov 2025 07:37:26 -0500 Subject: [PATCH 2/2] more updates --- README.md | 9 +- pkg/github/__toolsnaps__/project_read.snap | 4 +- pkg/github/projects.go | 630 ++++++++------------- pkg/github/projects_test.go | 137 +++++ 4 files changed, 371 insertions(+), 409 deletions(-) diff --git a/README.md b/README.md index 86debf4cf..f3160577a 100644 --- a/README.md +++ b/README.md @@ -827,19 +827,20 @@ Pattern Split: - "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 speciifies "items") + - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user speciifies "issues" or "PRs") 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. Map blocking relations: "blocked by 123" → parent-issue:"owner/repo#123" Syntax Essentials (items): - AND: space-separated. + AND: space-separated. (label:bug priority:high). OR: comma inside one qualifier (label:bug,critical). NOT: leading '-' (-label:wontfix). - Hyphenate multi-word field names. - Quote multi-word values. + 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*. diff --git a/pkg/github/__toolsnaps__/project_read.snap b/pkg/github/__toolsnaps__/project_read.snap index 126e03016..949d537cb 100644 --- a/pkg/github/__toolsnaps__/project_read.snap +++ b/pkg/github/__toolsnaps__/project_read.snap @@ -3,7 +3,7 @@ "title": "Read project information", "readOnlyHint": true }, - "description": "Read operations for GitHub Projects.\n\nDECISION GUIDE (choose method):\n- get_project: You have a project number; you need its metadata.\n- list_projects: User wants to discover or filter projects (TITLE / OPEN STATE ONLY).\n- get_project_field: You know a field_id and need its definition.\n- list_project_fields: MUST call before fetching item field values (get IDs \u0026 types).\n- get_project_item: You have an item_id (project item) and want its details.\n- list_project_items: User wants issues/PRs inside a project filtered by criteria.\n\nINTENT TOKENS (map user phrasing → method):\n[INTENT:DISCOVER_PROJECTS] → list_projects\n[INTENT:INSPECT_PROJECT] → get_project\n[INTENT:ENUM_FIELDS] → list_project_fields\n[INTENT:FIELD_DETAILS] → get_project_field\n[INTENT:LIST_ITEMS] → list_project_items\n[INTENT:ITEM_DETAILS] → get_project_item\n\nCRITICAL DISTINCTION:\nProjects ≠ Project Items.\n- list_projects filters ONLY project metadata (title, open/closed).\n DO NOT use item-level qualifiers (is:issue, is:pr, assignee:, label:, status:, parent-issue:, sprint-name:, etc).\n- list_project_items filters ISSUES or PRs inside ONE project. Strongly prefer explicit type: is:issue OR is:pr unless user requests a mixed set.\n\nFAILURE MODES TO AVOID:\n1. Missing pagination (stops early) → ALWAYS loop while pageInfo.hasNextPage=true.\n2. Missing 'fields' when listing items → only title returned; no field values.\n3. Using item filters in list_projects → returns zero or irrelevant results.\n4. Ambiguous item type (issues vs PRs) → default to clarifying OR supply both (omit type only if user truly wants both).\n5. Inventing field IDs → fetch via list_project_fields first.\n6. INVENTING FIELD NAMES (NEW) → MUST use exact names returned by list_project_fields (case-insensitive match, preserve original spelling/hyphenation).\n\nFIELD NAME RESOLUTION (CRITICAL – ALWAYS DO BEFORE BUILDING QUERY WITH CUSTOM FIELDS):\n1. Call list_project_fields → build a map of lowercased field name → original field name + type.\n2. When user mentions a concept (e.g. \"current sprint\", \"this iteration\", \"in the cycle\"):\n - Identify iteration-type fields (type == iteration).\n - Accept synonyms in user phrasing: sprint, iteration, cycle.\n - If user uses a generic phrase (\"current sprint\") and the existing iteration field is named \"Sprint\" → use sprint:@current.\n - If the field is named \"Cycle\" → cycle:@current.\n - If the field is named \"Iteration\" → iteration:@current.\n - NEVER substitute a synonym that does not exist among field names.\n3. For any other custom fields (e.g. \"dev phase\", \"story points\", \"team name\"):\n - Normalize user phrase → lower-case, replace spaces with hyphens.\n - Match against available field names in lower-case.\n - Use the ORIGINAL field name in the query exactly (including hyphenation and case if needed).\n4. If multiple iteration-type fields exist and the user intent is ambiguous → ask for clarification OR pick the one whose name best matches the user phrase.\n5. INVALID if you use a field name not present in list_project_fields.\n\nVALID vs INVALID (Iteration Example):\nUser request: \"Analyze the last week's activity ... for issues in the current sprint\"\nFields contain iteration field named \"sprint\":\n VALID: is:issue updated:\u003e@today-7d sprint:@current\n INVALID: is:issue updated:\u003e@today-7d iteration:@current\nFields contain iteration field named \"cycle\":\n VALID: is:issue updated:\u003e@today-7d cycle:@current\n INVALID: is:issue updated:\u003e@today-7d iteration:@current\nFields contain iteration field named \"iteration\":\n VALID: is:issue updated:\u003e@today-7d iteration:@current\n INVALID: is:issue updated:\u003e@today-7d sprint:@current (if 'sprint' not defined)\n\nIf NO iteration-type field exists → omit that qualifier OR clarify with user (\"No iteration field found; continue without sprint filter?\").\n\nQUERY TRANSLATION (items):\nUser: \"Open sprint issues assigned to me\" →\n state:open is:issue assignee:@me sprint:@current\nUser: \"PRs waiting for review\" →\n is:pr status:\"Ready for Review\"\nUser: \"High priority bugs updated this week\" →\n is:issue label:bug priority:high updated:\u003e@today-7d\n\nSYNTAX RULES (items):\n- AND: space-separated qualifiers.\n- OR: comma inside one qualifier (label:bug,critical).\n- NOT: prefix qualifier with '-' (-label:wontfix).\n- Hyphenate multi-word field names: sprint-name, team-name, parent-issue.\n- Quote multi-word values: status:\"In Review\".\n- Comparison \u0026 ranges: priority:1..3 updated:\u003c@today-14d.\n- Wildcards: title:*search*, label:bug*.\n- Presence: has:assignee, no:label, -no:assignee (force presence).\n\nGOOD PROJECT QUERIES (list_projects):\n roadmap is:open\n is:open feature planning\nBAD (reject for list_projects — item filters present):\n is:issue state:open\n assignee:@me sprint-name:\"Q3\"\n label:bug priority:high\n\nVALID ITEM QUERIES (list_project_items):\n state:open is:issue priority:high sprint:@current\n is:pr status:\"In Review\" team-name:\"Backend Team\"\n is:issue -label:wontfix updated:\u003e@today-30d\n is:issue parent-issue:\"github/repo#123\"\n\nPAGINATION LOOP (ALL list_*):\n1. Call list_*.\n2. Read pageInfo.hasNextPage.\n3. If true → call again with after=pageInfo.nextCursor (same query, fields, per_page).\n4. Repeat until hasNextPage=false.\n5. Aggregate ALL pages BEFORE summarizing.\n\nDATA COMPLETENESS RULE:\nNever summarize, infer trends, or perform counts until all pages are retrieved.\n\nDEEP DETAILS:\nProject item = lightweight wrapper. For full issue/PR inspection use issue_read or pull_request_read after enumerating items.\n\nDO:\n- Normalize user intent → precise filters.\n- Fetch fields first → pass IDs every page.\n- Preserve consistency across pagination.\n- Resolve and validate field names from list_project_fields BEFORE using them.\n\nDON'T:\n- Mix project-only and item-only filters.\n- Omit type when user scope is explicit.\n- Invent field IDs or option IDs.\n- Invent field names (e.g. use iteration:@current when only sprint exists).\n- Stop early on pagination.", + "description": "GitHub Projects V2 read operations.\n\nMethods: get_project | list_projects | list_project_fields | get_project_field | list_project_items | get_project_item\n\nKey distinctions:\n- list_projects: ONLY project metadata (title, open/closed). Never use item filters.\n- list_project_items: Issues/PRs inside ONE project. Prefer explicit is:issue or is:pr.\n\nField usage:\n- Call list_project_fields first to get IDs/types.\n- Use EXACT returned field names (case-insensitive match). Don't invent names or IDs.\n- Iteration synonyms (sprint/cycle/iteration) only if that field exists; map to the actual name (e.g. sprint:@current).\n- Only include filters for fields that exist and are relevant.\n\nItem query syntax:\nAND = space | OR = comma (label:bug,critical) | NOT = prefix - ( -label:wontfix )\nQuote multi-word values: status:\"In Review\" team-name:\"Backend Team\"\nHyphenate multi-word field names (story-points).\nRanges: points:1..3 dates:2025-01-01..2025-12-31\nComparisons: updated:\u003e@today-7d priority:\u003e1 points:\u003c=10\nWildcards: title:*crash* label:bug*\nTemporal shortcuts: @today @today-7d @today-30d\nIteration shortcuts: @current @next @previous\n\nPagination (mandatory):\nLoop while pageInfo.hasNextPage=true using after=nextCursor. Keep query, fields, per_page IDENTICAL each page.\n\nFields parameter:\nInclude field IDs on EVERY paginated list_project_items call if you need values. Omit → title only.\n\nCounting rules:\n- Count items array length after full pagination.\n- If multi-page: collect all pages, dedupe by item.id (fallback node_id) before totals.\n- Never count field objects, content, or nested arrays as separate items.\n- item.id = project item ID (for updates/deletes). item.content.id = underlying issue/PR ID.\n\nSummary vs list:\n- Summaries ONLY if user uses verbs: analyze | summarize | summary | report | overview | insights.\n- Listing verbs (list/show/get/fetch/display/enumerate) → just enumerate + total.\n\nExamples:\nlist_projects: \"roadmap is:open\"\nlist_project_items: state:open is:issue sprint:@current priority:high updated:\u003e@today-7d\n\nSelf-check before returning:\n☑ Paginated fully ☑ Dedupe by id/node_id ☑ Correct IDs used ☑ Field names valid ☑ Summary only if requested.\n\nReturn COMPLETE data or state what's missing (e.g. pages skipped).", "inputSchema": { "properties": { "after": { @@ -62,7 +62,7 @@ "type": "number" }, "query": { - "description": "Query string (used ONLY with list_projects and list_project_items). \n\nPattern Split:\n\n1. list_projects (project metadata only):\n Scope: title text + open/closed state.\n PERMITTED qualifiers: is:open, is:closed (state), simple title terms.\n FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc.\n Examples:\n - roadmap is:open\n - is:open feature planning\n Reject \u0026 switch method if user intends items.\n\n2. list_project_items (issues / PRs inside ONE project):\n MUST reflect user intent; strongly prefer explicit content type if narrowed:\n - \"open issues\" → state:open is:issue\n - \"merged PRs\" → state:merged is:pr\n - \"items updated this week\" → updated:\u003e@today-7d (omit type only if mixed desired)\n Query Construction Heuristics:\n a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)\n b. Map temporal phrases: \"this week\" → updated:\u003e@today-7d\n c. Map negations: \"excluding wontfix\" → -label:wontfix\n d. Map priority adjectives: \"high/sev1/p1\" → priority:high OR priority:p1 (choose based on field presence)\n e. Map blocking relations: \"blocked by 123\" → parent-issue:\"owner/repo#123\"\n\nSyntax Essentials (items):\n AND: space-separated.\n OR: comma inside one qualifier (label:bug,critical).\n NOT: leading '-' (-label:wontfix).\n Hyphenate multi-word field names.\n Quote multi-word values.\n Ranges: points:1..3, updated:\u003c@today-30d.\n Wildcards: title:*crash*, label:bug*.\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 (used ONLY with list_projects and list_project_items). \n\nPattern Split:\n\n1. list_projects (project metadata only):\n Scope: title text + open/closed state.\n PERMITTED qualifiers: is:open, is:closed (state), simple title terms.\n FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc.\n Examples:\n - roadmap is:open\n - is:open feature planning\n Reject \u0026 switch method if user intends items.\n\n2. list_project_items (issues / PRs inside ONE project):\n MUST reflect user intent; strongly prefer explicit content type if narrowed:\n - \"open issues\" → state:open is:issue\n - \"merged PRs\" → state:merged is:pr\n - \"items updated this week\" → updated:\u003e@today-7d (omit type only if mixed desired)\n - \"list all P1 priority items\" → priority:p1 (omit state if user wants all, omit type if user speciifies \"items\")\n - \"list all open P2 issues\" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user speciifies \"issues\" or \"PRs\")\n Query Construction Heuristics:\n a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)\n b. Map temporal phrases: \"this week\" → updated:\u003e@today-7d\n c. Map negations: \"excluding wontfix\" → -label:wontfix\n d. Map priority adjectives: \"high/sev1/p1\" → priority:high OR priority:p1 (choose based on field presence)\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\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/projects.go b/pkg/github/projects.go index e02648c21..b04fd4ff7 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -30,125 +30,54 @@ const ( // Supports getting and listing projects, project fields, and project items. func ProjectRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("project_read", - mcp.WithDescription(t("TOOL_PROJECT_READ_DESCRIPTION", `Read operations for GitHub Projects. - -DECISION GUIDE (choose method): -- get_project: You have a project number; you need its metadata. -- list_projects: User wants to discover or filter projects (TITLE / OPEN STATE ONLY). -- get_project_field: You know a field_id and need its definition. -- list_project_fields: MUST call before fetching item field values (get IDs & types). -- get_project_item: You have an item_id (project item) and want its details. -- list_project_items: User wants issues/PRs inside a project filtered by criteria. - -INTENT TOKENS (map user phrasing → method): -[INTENT:DISCOVER_PROJECTS] → list_projects -[INTENT:INSPECT_PROJECT] → get_project -[INTENT:ENUM_FIELDS] → list_project_fields -[INTENT:FIELD_DETAILS] → get_project_field -[INTENT:LIST_ITEMS] → list_project_items -[INTENT:ITEM_DETAILS] → get_project_item - -CRITICAL DISTINCTION: -Projects ≠ Project Items. -- list_projects filters ONLY project metadata (title, open/closed). - DO NOT use item-level qualifiers (is:issue, is:pr, assignee:, label:, status:, parent-issue:, sprint-name:, etc). -- list_project_items filters ISSUES or PRs inside ONE project. Strongly prefer explicit type: is:issue OR is:pr unless user requests a mixed set. - -FAILURE MODES TO AVOID: -1. Missing pagination (stops early) → ALWAYS loop while pageInfo.hasNextPage=true. -2. Missing 'fields' when listing items → only title returned; no field values. -3. Using item filters in list_projects → returns zero or irrelevant results. -4. Ambiguous item type (issues vs PRs) → default to clarifying OR supply both (omit type only if user truly wants both). -5. Inventing field IDs → fetch via list_project_fields first. -6. INVENTING FIELD NAMES (NEW) → MUST use exact names returned by list_project_fields (case-insensitive match, preserve original spelling/hyphenation). - -FIELD NAME RESOLUTION (CRITICAL – ALWAYS DO BEFORE BUILDING QUERY WITH CUSTOM FIELDS): -1. Call list_project_fields → build a map of lowercased field name → original field name + type. -2. When user mentions a concept (e.g. "current sprint", "this iteration", "in the cycle"): - - Identify iteration-type fields (type == iteration). - - Accept synonyms in user phrasing: sprint, iteration, cycle. - - If user uses a generic phrase ("current sprint") and the existing iteration field is named "Sprint" → use sprint:@current. - - If the field is named "Cycle" → cycle:@current. - - If the field is named "Iteration" → iteration:@current. - - NEVER substitute a synonym that does not exist among field names. -3. For any other custom fields (e.g. "dev phase", "story points", "team name"): - - Normalize user phrase → lower-case, replace spaces with hyphens. - - Match against available field names in lower-case. - - Use the ORIGINAL field name in the query exactly (including hyphenation and case if needed). -4. If multiple iteration-type fields exist and the user intent is ambiguous → ask for clarification OR pick the one whose name best matches the user phrase. -5. INVALID if you use a field name not present in list_project_fields. - -VALID vs INVALID (Iteration Example): -User request: "Analyze the last week's activity ... for issues in the current sprint" -Fields contain iteration field named "sprint": - VALID: is:issue updated:>@today-7d sprint:@current - INVALID: is:issue updated:>@today-7d iteration:@current -Fields contain iteration field named "cycle": - VALID: is:issue updated:>@today-7d cycle:@current - INVALID: is:issue updated:>@today-7d iteration:@current -Fields contain iteration field named "iteration": - VALID: is:issue updated:>@today-7d iteration:@current - INVALID: is:issue updated:>@today-7d sprint:@current (if 'sprint' not defined) - -If NO iteration-type field exists → omit that qualifier OR clarify with user ("No iteration field found; continue without sprint filter?"). - -QUERY TRANSLATION (items): -User: "Open sprint issues assigned to me" → - state:open is:issue assignee:@me sprint:@current -User: "PRs waiting for review" → - is:pr status:"Ready for Review" -User: "High priority bugs updated this week" → - is:issue label:bug priority:high updated:>@today-7d - -SYNTAX RULES (items): -- AND: space-separated qualifiers. -- OR: comma inside one qualifier (label:bug,critical). -- NOT: prefix qualifier with '-' (-label:wontfix). -- Hyphenate multi-word field names: sprint-name, team-name, parent-issue. -- Quote multi-word values: status:"In Review". -- Comparison & ranges: priority:1..3 updated:<@today-14d. -- Wildcards: title:*search*, label:bug*. -- Presence: has:assignee, no:label, -no:assignee (force presence). - -GOOD PROJECT QUERIES (list_projects): - roadmap is:open - is:open feature planning -BAD (reject for list_projects — item filters present): - is:issue state:open - assignee:@me sprint-name:"Q3" - label:bug priority:high - -VALID ITEM QUERIES (list_project_items): - state:open is:issue priority:high sprint:@current - is:pr status:"In Review" team-name:"Backend Team" - is:issue -label:wontfix updated:>@today-30d - is:issue parent-issue:"github/repo#123" - -PAGINATION LOOP (ALL list_*): -1. Call list_*. -2. Read pageInfo.hasNextPage. -3. If true → call again with after=pageInfo.nextCursor (same query, fields, per_page). -4. Repeat until hasNextPage=false. -5. Aggregate ALL pages BEFORE summarizing. - -DATA COMPLETENESS RULE: -Never summarize, infer trends, or perform counts until all pages are retrieved. - -DEEP DETAILS: -Project item = lightweight wrapper. For full issue/PR inspection use issue_read or pull_request_read after enumerating items. - -DO: -- Normalize user intent → precise filters. -- Fetch fields first → pass IDs every page. -- Preserve consistency across pagination. -- Resolve and validate field names from list_project_fields BEFORE using them. - -DON'T: -- Mix project-only and item-only filters. -- Omit type when user scope is explicit. -- Invent field IDs or option IDs. -- Invent field names (e.g. use iteration:@current when only sprint exists). -- Stop early on pagination.`)), + mcp.WithDescription(t("TOOL_PROJECT_READ_DESCRIPTION", `GitHub Projects V2 read operations. + +Methods: get_project | list_projects | list_project_fields | get_project_field | list_project_items | get_project_item + +Key distinctions: +- list_projects: ONLY project metadata (title, open/closed). Never use item filters. +- list_project_items: Issues/PRs inside ONE project. Prefer explicit is:issue or is:pr. + +Field usage: +- Call list_project_fields first to get IDs/types. +- 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. + +Item query syntax: +AND = space | OR = comma (label:bug,critical) | NOT = prefix - ( -label:wontfix ) +Quote multi-word values: status:"In Review" team-name:"Backend Team" +Hyphenate multi-word field names (story-points). +Ranges: points:1..3 dates:2025-01-01..2025-12-31 +Comparisons: updated:>@today-7d priority:>1 points:<=10 +Wildcards: title:*crash* label:bug* +Temporal shortcuts: @today @today-7d @today-30d +Iteration shortcuts: @current @next @previous + +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).`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_PROJECT_READ_USER_TITLE", "Read project information"), ReadOnlyHint: ToBoolPtr(true), @@ -195,19 +124,20 @@ Pattern Split: - "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 speciifies "items") + - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user speciifies "issues" or "PRs") 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. Map blocking relations: "blocked by 123" → parent-issue:"owner/repo#123" Syntax Essentials (items): - AND: space-separated. + AND: space-separated. (label:bug priority:high). OR: comma inside one qualifier (label:bug,critical). NOT: leading '-' (-label:wontfix). - Hyphenate multi-word field names. - Quote multi-word values. + 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*. @@ -519,7 +449,6 @@ func listProjects(ctx context.Context, client *github.Client, owner string, owne minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) } - // Create response with pagination info response := map[string]any{ "projects": minimalProjects, "pageInfo": buildPageInfo(resp), @@ -609,6 +538,7 @@ func listProjectFields(ctx context.Context, client *github.Client, owner string, err, ), nil } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { @@ -619,9 +549,10 @@ func listProjectFields(ctx context.Context, client *github.Client, owner string, return mcp.NewToolResultError(fmt.Sprintf("failed to list project fields: %s", string(body))), nil } - // Create response with pagination info + filteredFields := filterSpecialTypes(projectFields) + response := map[string]any{ - "fields": projectFields, + "fields": filteredFields, "pageInfo": buildPageInfo(resp), } @@ -676,6 +607,11 @@ func getProjectItem(ctx context.Context, client *github.Client, owner string, ow } return mcp.NewToolResultError(fmt.Sprintf("failed to get project item: %s", string(body))), nil } + + if len(projectItem.Fields) > 0 { + projectItem.Fields = filterSpecialTypes(projectItem.Fields) + } + r, err := json.Marshal(projectItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) @@ -730,7 +666,14 @@ func listProjectItems(ctx context.Context, client *github.Client, owner string, return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectListFailedError, string(body))), nil } - // Create response with pagination info + if len(projectItems) > 0 { + for i := range projectItems { + if len(projectItems[i].Fields) > 0 { + projectItems[i].Fields = filterSpecialTypes(projectItems[i].Fields) + } + } + } + response := map[string]any{ "items": projectItems, "pageInfo": buildPageInfo(resp), @@ -883,16 +826,30 @@ type projectV2Field struct { 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 + Configuration *any `json:"configuration,omitempty"` // For iteration fields CreatedAt *github.Timestamp `json:"created_at,omitempty"` UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` } +func (f *projectV2Field) getDataType() string { + if f == nil { + return "" + } + return strings.ToLower(f.DataType) +} + 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 any `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"` +} + +func (v *projectV2ItemFieldValue) getDataType() string { + if v == nil { + return "" + } + return strings.ToLower(v.DataType) } type projectV2Item struct { @@ -912,17 +869,29 @@ type projectV2Item struct { } type projectV2ItemContent struct { - Body *string `json:"body,omitempty"` - ClosedAt *github.Timestamp `json:"closed_at,omitempty"` - 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"` - UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` - URL *string `json:"url,omitempty"` + Body *string `json:"body,omitempty"` + ClosedAt *github.Timestamp `json:"closed_at,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + ID *int64 `json:"id,omitempty"` + Number *int `json:"number,omitempty"` + Repository *projectV2ItemContentRepository `json:"repository,omitempty"` + State *string `json:"state,omitempty"` + StateReason *string `json:"stateReason,omitempty"` + Title *string `json:"title,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + URL *string `json:"url,omitempty"` + Type *any `json:"type,omitempty"` + Labels []*any `json:"labels,omitempty"` + Assignees []*MinimalUser `json:"assignees,omitempty"` + Milestone *any `json:"milestone,omitempty"` +} + +type projectV2ItemContentRepository struct { + ID *int64 `json:"id"` + Name *string `json:"name"` + FullName *string `json:"full_name"` + Description *string `json:"description,omitempty"` + HTMLURL *string `json:"html_url"` } type pageInfo struct { @@ -943,8 +912,6 @@ 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"` } @@ -989,7 +956,6 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { return payload, nil } -// buildPageInfo creates a pageInfo struct from the GitHub API response func buildPageInfo(resp *github.Response) pageInfo { return pageInfo{ HasNextPage: resp.After != "", @@ -999,7 +965,6 @@ func buildPageInfo(resp *github.Response) pageInfo { } } -// extractPaginationOptions extracts and validates pagination parameters from a tool request func extractPaginationOptions(request mcp.CallToolRequest) (paginationOptions, error) { perPage, err := OptionalIntParamWithDefault(request, "per_page", MaxProjectsPerPage) if err != nil { @@ -1026,6 +991,46 @@ func extractPaginationOptions(request mcp.CallToolRequest) (paginationOptions, e }, nil } +// "special" data types that are present in the project item's content object. +var specialFieldDataTypes = map[string]struct{}{ + "assignees": {}, + "labels": {}, + "linked_pull_requests": {}, + "milestone": {}, + "parent_issue": {}, + "repository": {}, + "reviewers": {}, + "sub_issues_progress": {}, + "title": {}, +} + +// filterSpecialTypes returns a new slice containing only those field definitions +// or field values whose DataType is NOT in the specialFieldDataTypes set. The +// input must be a slice whose element type implements getDataType() string. +// +// Applicable to: +// +// []*projectV2Field +// []*projectV2ItemFieldValue +// +// Example: +// +// filtered := filterSpecialTypes(fields) +func filterSpecialTypes[T interface{ getDataType() string }](fields []T) []T { + if len(fields) == 0 { + return fields + } + out := make([]T, 0, len(fields)) + for _, f := range fields { + dt := f.getDataType() + if _, isSpecial := specialFieldDataTypes[dt]; isSpecial { + continue + } + out = append(out, f) + } + return out +} + // 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) { @@ -1068,67 +1073,43 @@ func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr messages := []mcp.PromptMessage{ { Role: "system", - Content: mcp.NewTextContent(`You are an assistant for GitHub Projects V2. -PRIMARY GOAL: Select correct method and produce COMPLETE results (no pagination truncation). - -METHOD DECISION FLOW: -1. Need list of projects? → project_read list_projects -2. Have project_number → need its metadata? → project_read get_project -3. Need field definitions (IDs/types)? → project_read list_project_fields -4. Have field_id → single field details? → project_read get_project_field -5. Need issues/PRs inside one project? → project_read list_project_items -6. Have item_id → full item wrapper? → project_read get_project_item -7. Need to modify items? → project_write (add/update/delete) -8. Need deep issue/PR details beyond wrapper? → issue_read / pull_request_read - -CORE RULES (NON-NEGOTIABLE): -- Call list_project_fields BEFORE querying items for field values. -- ALWAYS include 'query' when calling list_project_items. -- ALWAYS include 'fields' (IDs) on EVERY paginated call if field values matter. -- ALWAYS paginate until pageInfo.hasNextPage=false. -- NEVER use item-level qualifiers with list_projects. -- STRONGLY prefer is:issue or is:pr in item queries when scope is clear. -- DO NOT summarize or count until all pages fetched. - -QUERY BUILDING (ITEMS): -Translate user intent → structured filters: -- "open sprint issues assigned to me" → state:open is:issue assignee:@me sprint-name:@current -- "recent merged PRs backend team" → state:merged is:pr team-name:"Backend Team" updated:>@today-7d -- "exclude wontfix high priority bugs" → is:issue label:bug priority:high -label:wontfix state:open - -SYNTAX: -AND: space. OR: comma (label:bug,critical). NOT: -label:wontfix. -Quote multi-word values. Hyphenate multi-word field names (sprint-name). -Ranges: points:1..3, updated:<@today-14d. -Temporal shortcuts: @today @today-7d @today-30d. -Iteration shortcuts: @current @next @previous. -Comparison Operators: (For number, date, and iteration field types) -- field:>VALUE priority:>1 will show items with a priority greater than 1. -- field:>=VALUE date:>=2022-06-01 will show items with a date of "2022-06-01" or later. -- field::<"Iteration 5" will show items with an iteration before "Iteration 5." -- field:<=VALUE points:<=10 will show items with 10 or less points. - -PAGINATION LOOP: -1. Call list_project_items -2. If pageInfo.hasNextPage=true → repeat with after=nextCursor (same query, fields, per_page) -3. Aggregate until hasNextPage=false. - -RECOVERY / AMBIGUITY: -- If user says “show items” with no type: omit is:issue/is:pr OR ask clarifying question. -- If unknown field name appears: fetch list_project_fields, match by normalized lower-case; never guess. - -DO / DON'T: -DO normalize user phrases → filters. -DO preserve parameter consistency when paginating. -DON'T invent field IDs or option IDs. -DON'T halt early on pagination. -DON'T mix discovery (list_projects) with item filtering. - -HANDOFF: -For deeper issue/PR details (comments, diff, reviews) → use issue_read / pull_request_read after enumerating items. - -QUALITY GUARANTEE: -Return COMPLETE data sets or explicitly state what's missing (e.g., user withheld pagination).`), + Content: mcp.NewTextContent(`System guide: GitHub Projects V2. +Goal: Pick correct method, fetch COMPLETE data (no early pagination stop), apply accurate filters, and count items correctly. + +Method quick map: +list_projects (metadata only) | get_project (single) | list_project_fields (define IDs) | get_project_field | list_project_items (issues/PRs) | get_project_item | project_write (mutations) | issue_read / pull_request_read (deep details) + +Core rules: +- list_projects: NEVER include item-level filters. +- Before filtering on custom fields call list_project_fields. +- Always paginate until pageInfo.hasNextPage=false. +- Keep query, fields, per_page identical across pages. +- Include fields 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", @@ -1144,7 +1125,7 @@ Return COMPLETE data sets or explicitly state what's missing (e.g., user withhel }, { Role: "assistant", - Content: mcp.NewTextContent("I'll help manage GitHub Projects. First, let's list projects using **project_read** with method=\"list_projects\"."), + Content: mcp.NewTextContent("Start by listing projects: project_read method=\"list_projects\"."), }, { Role: "user", @@ -1152,53 +1133,15 @@ Return COMPLETE data sets or explicitly state what's missing (e.g., user withhel }, { Role: "assistant", - Content: mcp.NewTextContent(`**Working with Fields & Items (Field Name Resolution Is Critical)** - -1. Enumerate fields first: - Use project_read method="list_project_fields" project_number=. - Build a lookup: lowercased field name -> original name + data_type. - -2. Resolve user phrases to actual field names: - - Normalize user phrase: lowercase, trim, replace spaces with hyphens for matching. - - Only use EXACT existing field names returned by list_project_fields. - - Never invent synonyms; if user says "sprint" but only "Iteration" exists, use iteration:@current (NOT sprint:@current). - -3. Iteration field synonyms: - User may say: "sprint", "iteration", "cycle", "current sprint", "this cycle". - VALID substitution depends on actual field: - - If field list contains "Sprint": sprint:@current - - If field list contains "Cycle": cycle:@current - - If field list contains "Iteration": iteration:@current - INVALID examples: - - iteration:@current when only "Sprint" exists - - sprint:@current when only "Cycle" exists - -4. Other custom fields (examples): - - "dev phase" → dev-phase: (if field name is "Dev Phase" or "dev-phase") - - "story points" → story-points: - - Preserve hyphenation EXACTLY as in the original field name. - -5. If ambiguous or multiple candidates (e.g., both "Sprint" and "Iteration"): - - Prefer the one that matches the user's wording. - - Ask for clarification if intent is unclear. - -6. Build the query AFTER resolving field names: - Example request: "Analyze last week's activity for issues in the current sprint" - - Temporal phrase "last week's activity" → updated:>@today-7d - - Content type "issues" → is:issue - - "current sprint" (field name?): - sprint:@current (if 'Sprint') - cycle:@current (if 'Cycle') - iteration:@current (if 'Iteration') - Final VALID queries depend entirely on actual field names. - -7. Always paginate: - Check pageInfo.hasNextPage. If true, repeat with after= (same query, fields, per_page). - -8. Include fields parameter: - Pass fields=["", "", ...] on EVERY page if you want field values. - -Remember: Field presence governs filter legality. If a field doesn’t exist, either omit that filter or ask for clarification.`), + Content: mcp.NewTextContent(`Fields & items: +1. list_project_fields first → 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. Paginate until hasNextPage=false; keep query/fields/per_page stable. +7. Include fields IDs every page when you need their values. +Missing field? Omit or clarify—never guess.`), }, { Role: "user", @@ -1206,20 +1149,9 @@ Remember: Field presence governs filter legality. If a field doesn’t exist, ei }, { Role: "assistant", - Content: mcp.NewTextContent(`**Update Item Field Values:** project_write method="update_project_item" with updated_field: - -Text: {"id": 123456, "value": "text"} -Single-select: {"id": 198354254, "value": 18498754} (value = option ID) -Iteration: {"id": 198354254, "value": 18498754} (value = configuration's iteration ID) -Date: {"id": 789012, "value": "2025-03-15"} -Number: {"id": 345678, "value": 5} -Clear: {"id": 123456, "value": null} - -Requirements: -- Use the project item_id (wrapper), NOT the issue/PR number. -- Confirm field ID from list_project_fields before updating. -- Single-select requires the numeric option ID (do not pass the name). -- Iteration requires the iteration ID from the field configuration (do not pass the name).`), + Content: mcp.NewTextContent(`Updating fields (project_write update_project_item): +Examples: text {"id":123,"value":"hello"} | select {"id":456,"value":789} (option ID) | number {"id":321,"value":5} | date {"id":654,"value":"2025-03-15"} | clear {"id":123,"value":null} +Rules: item_id = project item wrapper ID; confirm field IDs via list_project_fields; select/iteration = pass option/iteration ID (not name).`), }, { Role: "user", @@ -1227,41 +1159,14 @@ Requirements: }, { Role: "assistant", - Content: mcp.NewTextContent(`**Workflow (Including Field Name Resolution):** - -1. Discover projects: - project_read method="list_projects" owner= owner_type= - -2. Get fields (MUST before filtering on custom fields): - project_read method="list_project_fields" project_number= - → Build map: lowercased field name -> {originalName, type, id} - -3. Resolve user intent → query: - - User phrase: "current sprint high priority issues updated this week" - - Fields: has 'Sprint' (iteration type), has 'Priority' - - Query: is:issue sprint:@current priority:high updated:>@today-7d - -4. List items (FIRST page): - project_read method="list_project_items" - project_number= - query="is:issue sprint:@current priority:high updated:>@today-7d" - fields=["", "", ""] - -5. Pagination: - If pageInfo.hasNextPage=true → repeat step 4 with after= (same query, fields, per_page). - Continue until hasNextPage=false. Aggregate all pages. - -6. Deeper inspection: - Only when needed: for each item → extract repository + issue/PR number → call issue_read or pull_request_read for comments, reviews, etc. - -7. Update a field: - project_write method="update_project_item" project_number= item_id= - updated_field={"id": , "value": "high"} - -**CRITICAL REMINDERS:** -- Never use iteration:@current when only 'Sprint' exists. -- Fields parameter MUST be identical across pagination calls. -- Don't summarize until all pages are collected.`), + Content: mcp.NewTextContent(`Workflow quick path: +1 list_projects → pick project_number. +2 list_project_fields → build field map. +3 Build query (e.g. is:issue sprint:@current priority:high updated:>@today-7d). +4 list_project_items (include field IDs) → paginate fully. +5 Optional deep dive: issue_read / pull_request_read per item. +6 Optional update: project_write update_project_item. +Reminders: iteration filter must match existing field; keep fields consistent; summarize only if asked.`), }, { Role: "user", @@ -1269,28 +1174,11 @@ Requirements: }, { Role: "assistant", - Content: mcp.NewTextContent(`**⚠️ Pagination Is Mandatory** - -Rules: -1. Inspect pageInfo.hasNextPage on EVERY response. -2. If true → call again with after=pageInfo.nextCursor. -3. Keep query, fields, per_page EXACTLY the same. -4. Loop until hasNextPage=false. -5. Aggregate all items BEFORE analysis or summarization. - -Example: -Page 1: hasNextPage=true → after="abc123" -Page 2: hasNextPage=true → after="def456" -Page 3: hasNextPage=false → DONE - -Field Value Integrity: -- If you include fields=["123","456"] on page 1, you MUST include them on subsequent pages. -- Omitting fields mid-pagination yields inconsistent item data. - -Never: -- Stop early. -- Change filters mid-sequence. -- Drop fields array after the first page.`), + Content: mcp.NewTextContent(`Pagination: +Loop while hasNextPage=true using after=nextCursor. +Do NOT change query/fields/per_page. +Include same fields IDs every page. +Only count/summarize after final page.`), }, { Role: "user", @@ -1298,85 +1186,21 @@ Never: }, { Role: "assistant", - Content: mcp.NewTextContent(`**Deep Details Beyond Project Items** - -Project item wrapper gives: title, item URL, basic content state, and selected field values. -For full issue/PR context (comments, reviews, diff, labels): - -Issues: - issue_read method="get" - issue_read method="get_comments" - issue_read method="get_labels" - issue_read method="get_sub_issues" - -Pull Requests: - pull_request_read method="get" - pull_request_read method="get_reviews" - pull_request_read method="get_review_comments" - pull_request_read method="get_files" - pull_request_read method="get_diff" - pull_request_read method="get_status" - -Workflow: -1. Enumerate with list_project_items (capture repository + number). -2. Use repository.owner.login + repository.name + content.number for deeper calls. -3. Combine field-derived status + external discussions for a richer report. - -Always confirm item type (is:issue vs is:pr) before selecting downstream method.`), + Content: mcp.NewTextContent(`Deep details: +Use issue_read or pull_request_read for comments/reviews/diffs after enumeration. +Inputs: repository + item content.number. +Confirm type (is:issue vs is:pr) before choosing which tool.`), }, { Role: "assistant", - Content: mcp.NewTextContent(`**Query Building for Reports (Field Name Integrity)** - -Preparation: -- Run list_project_fields first. -- Normalize user terms to actual field names (lowercase match). -- Use returned names; preserve hyphens. - -Patterns: -- "blocked issues" → is:issue (label:blocked OR status:"Blocked" OR dev-phase:"Blocked" depending on existing fields) -- "overdue tasks" (field 'due-date') → is:issue due-date:<@today state:open -- "PRs ready for review" (field 'review-status') → 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 PRs current sprint" (fields: 'team-name', 'Sprint') → is:pr team-name:"Backend Team" sprint:@current -- "iteration tracking last week" (field 'Iteration') → is:issue updated:>@today-7d iteration:@current state:open - -Rules: -- Content type first: is:issue or is:pr unless mixed set requested. -- Temporal: "last week" → updated:>@today-7d; "last 30 days" → updated:>@today-30d -- Multi-word values must be quoted: team-name:"Backend Team" -- OR logic: label:bug,critical -- NOT logic: -label:wontfix -- Comparisons: - - Greater than: - - number-field:>5 - - date-field:>2024-06-01 - - iteration-field:>"iteration 2" - - Less than: - - number-field:<3 - - date-field:<2024-12-31 - - iteration-field:<"iteration 2" - - Greater than or equal to: - - number-field:>=4 - - date-field:>=2024-05-15 - - iteration-field:>="iteration 1" - - Less than or equal to: - - number-field:<=8 - - date-field:<=2024-11-30 - - iteration-field:<="iteration 3" -- Range: - - number-field:1..10 - - date-field:2024-01-01..2024-12-31 - - iteration-field:"iteration 1..iteration 3" - -INVALID examples: -- sprint:@current when only 'Iteration' exists -- iteration:@current when only 'Sprint' exists -- Using dev-phase:"In Progress" when no 'dev-phase' field exists (must clarify) - -Golden Rule: -Never invent field names or IDs. Always source from list_project_fields.`), + Content: mcp.NewTextContent(`Query patterns: +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 asked; dedupe before counts; quote multi-word values; never invent field names or IDs.`), }, } return &mcp.GetPromptResult{ diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index f427aaf8a..5b7f96dc4 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/json" "net/http" "testing" @@ -196,6 +197,142 @@ func Test_ProjectRead(t *testing.T) { } } +// Test_ProjectRead_FilterSpecialFieldTypes_ListProjectFields ensures that special intrinsic +// field data types (e.g., labels, repository, title) are filtered out of the response +// by list_project_fields. Only non-special field data types should remain. +func Test_ProjectRead_FilterSpecialFieldTypes_ListProjectFields(t *testing.T) { + // Mock fields returned by the API including special types. + projectFields := []map[string]any{ + {"id": 1, "name": "Title", "data_type": "title"}, + {"id": 2, "name": "Labels", "data_type": "labels"}, + {"id": 3, "name": "Status", "data_type": "single_select"}, + {"id": 4, "name": "Repository", "data_type": "repository"}, + } + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/fields", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, projectFields), + ), + ) + + ctx := context.Background() + client := gh.NewClient(mockedClient) + _, handler := ProjectRead(stubGetClientFn(client), translations.NullTranslationHelper) + result, err := handler(ctx, createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + })) + require.NoError(t, err) + require.False(t, result.IsError) + + text := getTextResult(t, result).Text + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &parsed)) + + fieldsAny, ok := parsed["fields"].([]any) + require.True(t, ok, "expected fields array in response") + + // Only the non-special field (single_select) should remain. + assert.Equal(t, 1, len(fieldsAny)) + fieldObj := fieldsAny[0].(map[string]any) + assert.Equal(t, float64(3), fieldObj["id"], "expected Status field to remain") + assert.Equal(t, "single_select", fieldObj["data_type"]) +} + +// helper returning a project item seeded with special intrinsic field types. +func testProjectItemWithSpecialFields() map[string]any { + return map[string]any{ + "id": 1000, + "title": "Item 1", + "fields": []map[string]any{ + {"id": 1, "name": "Title", "data_type": "title", "value": map[string]any{"raw": "Item 1"}}, + {"id": 2, "name": "Labels", "data_type": "labels", "value": []string{"bug"}}, + {"id": 3, "name": "Status", "data_type": "single_select", "value": map[string]any{"name": map[string]any{"raw": "In Progress"}}}, + }, + } +} + +// Test_ProjectRead_FilterSpecialFieldTypes_GetProjectItem validates filtering for get_project_item +// so that special field data types are removed from each item's field values slice. +func Test_ProjectRead_FilterSpecialFieldTypes_GetProjectItem(t *testing.T) { + projectItem := testProjectItemWithSpecialFields() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, projectItem), + ), + ) + + ctx := context.Background() + client := gh.NewClient(mockedClient) + _, handler := ProjectRead(stubGetClientFn(client), translations.NullTranslationHelper) + + resGet, err := handler(ctx, createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1000), + })) + require.NoError(t, err) + require.False(t, resGet.IsError) + + textGet := getTextResult(t, resGet).Text + var parsedGet map[string]any + require.NoError(t, json.Unmarshal([]byte(textGet), &parsedGet)) + fieldsAny, ok := parsedGet["fields"].([]any) + require.True(t, ok) + assert.Equal(t, 1, len(fieldsAny), "expected only non-special field to remain for get_project_item") + fieldObj := fieldsAny[0].(map[string]any) + assert.Equal(t, float64(3), fieldObj["id"]) + assert.Equal(t, "single_select", fieldObj["data_type"]) +} + +// Test_ProjectRead_FilterSpecialFieldTypes_ListProjectItems validates filtering for list_project_items +// so that special field data types are removed from each item's field values slice. +func Test_ProjectRead_FilterSpecialFieldTypes_ListProjectItems(t *testing.T) { + projectItem := testProjectItemWithSpecialFields() + projectItems := []map[string]any{projectItem} + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, projectItems), + ), + ) + + ctx := context.Background() + client := gh.NewClient(mockedClient) + _, handler := ProjectRead(stubGetClientFn(client), translations.NullTranslationHelper) + + resList, err := handler(ctx, createMCPRequest(map[string]any{ + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + })) + require.NoError(t, err) + require.False(t, resList.IsError) + + textList := getTextResult(t, resList).Text + var parsedList map[string]any + require.NoError(t, json.Unmarshal([]byte(textList), &parsedList)) + itemsAny, ok := parsedList["items"].([]any) + require.True(t, ok) + require.Equal(t, 1, len(itemsAny)) + itemObj := itemsAny[0].(map[string]any) + itemFields, ok := itemObj["fields"].([]any) + require.True(t, ok) + assert.Equal(t, 1, len(itemFields), "expected only non-special field to remain for list_project_items") + itemFieldObj := itemFields[0].(map[string]any) + assert.Equal(t, float64(3), itemFieldObj["id"]) + assert.Equal(t, "single_select", itemFieldObj["data_type"]) +} + func Test_ProjectWrite(t *testing.T) { mockClient := gh.NewClient(nil) tool, _ := ProjectWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper)