diff --git a/README.md b/README.md index 2e896cea8..f3160577a 100644 --- a/README.md +++ b/README.md @@ -798,63 +798,75 @@ 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) + - "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) + +Syntax Essentials (items): + AND: space-separated. (label:bug priority:high). + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). + Quote multi-word values. (status:"In Review" team-name:"Backend Team"). + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + +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..949d537cb --- /dev/null +++ b/pkg/github/__toolsnaps__/project_read.snap @@ -0,0 +1,77 @@ +{ + "annotations": { + "title": "Read project information", + "readOnlyHint": true + }, + "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": { + "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 - \"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" + } + }, + "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..b04fd4ff7 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -23,845 +23,786 @@ 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", `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_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.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), + mcp.Description("Project number (required for most methods)"), ), - ), 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."), + mcp.Description("Field ID (required for get_project_field)"), ), - ), 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.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) + - "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) + +Syntax Essentials (items): + AND: space-separated. (label:bug priority:high). + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). + Quote multi-word values. (status:"In Review" team-name:"Backend Team"). + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + +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") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - itemID, err := RequiredInt(req, "item_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - fields, err := OptionalStringArrayParam(req, "fields") + ), + 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 } - client, err := getClient(ctx) + owner, err := RequiredParam[string](request, "owner") if err != nil { 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) + ownerType, err := RequiredParam[string](request, "owner_type") 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 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 - } - - 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 - } - client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - 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{} + 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) - resp, err := client.Do(ctx, httpRequest, &addedItem) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectAddFailedError, - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + case "list_projects": + queryStr, err := OptionalParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) + pagination, err := extractPaginationOptions(request) 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 listProjects(ctx, client, owner, ownerType, queryStr, pagination) -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")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update 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 unique identifier of the project item. This is not the issue or pull request ID."), - ), - 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\"}"), - ), - ), 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 - } + case "get_project_field": + projectNumber, err := RequiredInt(request, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fieldID, err := RequiredInt(request, "field_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) - rawUpdatedField, exists := req.GetArguments()["updated_field"] - if !exists { - return mcp.NewToolResultError("missing required parameter: updated_field"), nil - } + case "list_project_fields": + projectNumber, err := RequiredInt(request, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - fieldValue, ok := rawUpdatedField.(map[string]any) - if !ok || fieldValue == nil { - return mcp.NewToolResultError("field_value must be an object"), nil - } + pagination, err := extractPaginationOptions(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - updatePayload, err := buildUpdateProjectItem(fieldValue) - if err != nil { - return mcp.NewToolResultError(err.Error()), 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/%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{} + case "list_project_items": + projectNumber, err := RequiredInt(request, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - resp, err := client.Do(ctx, httpRequest, &updatedItem) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + queryStr, err := OptionalParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) + pagination, err := extractPaginationOptions(request) 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 mcp.NewToolResultText(string(r)), nil + fields, err := OptionalStringArrayParam(request, "fields") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + return listProjectItems(ctx, client, owner, ownerType, projectNumber, queryStr, pagination, fields) + + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), 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")), +// 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_DELETE_PROJECT_ITEM_USER_TITLE", "Delete 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.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 internal project item ID to delete from the project (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.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 } - client, err := getClient(ctx) + + itemID, err := RequiredInt(request, "item_id") if err != nil { 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("DELETE", projectsURL, nil) + client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - resp, err := client.Do(ctx, httpRequest, nil) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - 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.StatusNoContent { - 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", ProjectDeleteFailedError, string(body))), nil + 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)) + } + + 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 + + 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 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) + } + + type listProjectFieldsOptions struct { + paginationOptions + } + + opts := listProjectFieldsOptions{ + paginationOptions: pagination, + } + + 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) + } + + 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 + } + + filteredFields := filterSpecialTypes(projectFields) + + response := map[string]any{ + "fields": filteredFields, + "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 + } + + 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) + } + + 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 + } + + if len(projectItems) > 0 { + for i := range projectItems { + if len(projectItems[i].Fields) > 0 { + projectItems[i].Fields = filterSpecialTypes(projectItems[i].Fields) } - return mcp.NewToolResultText("project item successfully deleted"), nil } + } + + 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 +820,36 @@ 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"` +} + +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 interface{} `json:"value,omitempty"` // The value of the field for a specific project item. + ID *int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` + Value any `json:"value,omitempty"` +} + +func (v *projectV2ItemFieldValue) getDataType() string { + if v == nil { + return "" + } + return strings.ToLower(v.DataType) } type projectV2Item struct { @@ -913,21 +869,42 @@ 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 { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor,omitempty"` + PrevCursor string `json:"prevCursor,omitempty"` } type paginationOptions struct { - PerPage int `url:"per_page,omitempty"` + PerPage int `url:"per_page,omitempty"` + After string `url:"after,omitempty"` + Before string `url:"before,omitempty"` } type filterQueryOptions struct { @@ -935,9 +912,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 +956,81 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { return payload, nil } +func buildPageInfo(resp *github.Response) pageInfo { + return pageInfo{ + HasNextPage: resp.After != "", + HasPreviousPage: resp.Before != "", + NextCursor: resp.After, + PrevCursor: resp.Before, + } +} + +func extractPaginationOptions(request mcp.CallToolRequest) (paginationOptions, error) { + perPage, err := OptionalIntParamWithDefault(request, "per_page", MaxProjectsPerPage) + if err != nil { + return paginationOptions{}, err + } + if perPage > MaxProjectsPerPage { + perPage = MaxProjectsPerPage + } + + after, err := OptionalParam[string](request, "after") + if err != nil { + return paginationOptions{}, err + } + + before, err := OptionalParam[string](request, "before") + if err != nil { + return paginationOptions{}, err + } + + return paginationOptions{ + PerPage: perPage, + After: after, + Before: before, + }, nil +} + +// "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) { @@ -999,16 +1049,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 +1073,134 @@ func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr messages := []mcp.PromptMessage{ { Role: "system", - Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" + - "**Core Capabilities:**\n" + - "- Project discovery and field analysis\n" + - "- Item querying with advanced filters\n" + - "- Field value updates and management\n" + - "- Progress reporting and insights\n\n" + - "**Key Rules:**\n" + - "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" + - "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" + - "- Use proper field IDs (not names) when updating items\n" + - "- Provide step-by-step workflows with concrete examples\n\n" + - "**Understanding Project Items:**\n" + - "- Project items reference underlying content (issues or pull requests)\n" + - "- Project tools provide: project fields, item metadata, and basic content info\n" + - "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" + - "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" + - "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" + - "**Available Tools:**\n" + - "- **list_projects**: Discover available projects\n" + - "- **get_project**: Get detailed project information\n" + - "- **list_project_fields**: Get field definitions and IDs\n" + - "- **list_project_items**: Query items with filters and field selection\n" + - "- **get_project_item**: Get specific item details\n" + - "- **add_project_item**: Add issues/PRs to projects\n" + - "- **update_project_item**: Update field values\n" + - "- **delete_project_item**: Remove items from projects"), + Content: mcp.NewTextContent(`System guide: GitHub Projects V2. +Goal: Pick correct 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", - Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+ - "Help me get started with project management tasks.", + Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s", owner, ownerType, func() string { if task != "" { - return fmt.Sprintf(" I'm specifically interested in: %s.", task) + return fmt.Sprintf(" Focus: %s.", task) } return "" }())), }, + { + Role: "assistant", + Content: mcp.NewTextContent("Start by listing projects: project_read 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(`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", - Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."), + Content: mcp.NewTextContent("How do I update item field values?"), }, { Role: "assistant", - Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" + - "Once you select a project, I'll help you:\n\n" + - "1. **Get field information** using **list_project_fields**\n" + - " - Find field IDs, names, and data types\n" + - " - Understand available options for select fields\n" + - " - Identify required vs. optional fields\n\n" + - "2. **Query project items** using **list_project_items**\n" + - " - Filter by assignees: query=\"assignee:@me\"\n" + - " - Filter by status: query=\"status:In Progress\"\n" + - " - Filter by labels: query=\"label:bug\"\n" + - " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" + - "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"), + Content: mcp.NewTextContent(`Updating fields (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", - 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 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", - 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: +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", - 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: +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("**📝 Accessing Underlying Issue/PR Details**\n\n" + - "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" + - "**From project items, extract:**\n" + - "- content.repository.name and content.repository.owner.login\n" + - "- content.number (the issue/PR number)\n" + - "- content_type (\"Issue\" or \"PullRequest\")\n\n" + - "**Then use these tools for details:**\n\n" + - "1. **Get full issue/PR details:**\n" + - " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Returns: full body, labels, assignees, milestone, etc.\n\n" + - "2. **Get recent comments:**\n" + - " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Add since parameter to filter recent comments\n\n" + - "3. **Get issue events:**\n" + - " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Shows timeline: assignments, label changes, status updates\n\n" + - "4. **For pull requests specifically:**\n" + - " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" + - " - **list_pull_request_reviews** for review status\n\n" + - "**💡 Example:** To check for blockers in comments:\n" + - "1. Get project items with query=\"assignee:@me is:open\"\n" + - "2. For each item, extract repository and issue number from content\n" + - "3. Use **list_comments** to get recent comments\n" + - "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""), + Content: mcp.NewTextContent(`Query patterns: +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 6cfbda0fe..5b7f96dc4 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -3,7 +3,6 @@ package github import ( "context" "encoding/json" - "io" "net/http" "testing" @@ -15,32 +14,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 +55,433 @@ 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) { +// 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, _ := 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) + ctx := context.Background() + mockClient := gh.NewClient(tc.mockedClient) + _, handler := ProjectWrite(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 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 - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var field map[string]any - err = json.Unmarshal([]byte(textContent.Text), &field) - 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)),