diff --git a/README.md b/README.md index c9e724227..67056bf51 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,13 @@ The following sets of tools are available: - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) +- **issue_graph** - Get issue relationship graph + - `focus`: Which node type to focus on: 'provided' (default) uses the specified issue/PR, 'epic' shifts focus to the nearest epic in the hierarchy, 'batch' shifts focus to the nearest batch/parent issue (string, optional) + - `issue_number`: Issue or pull request number to build the graph from (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `verbose`: Include crawl statistics showing how the graph was traversed (nodes fetched, depth reached, repos accessed, etc.) (boolean, optional) + - **issue_read** - Get issue details - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. diff --git a/docs/remote-server.md b/docs/remote-server.md index 1030911ef..5ee6aea64 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Default | ["Default" toolset](../README.md#default-toolset) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | @@ -53,14 +53,11 @@ These toolsets are only available in the remote GitHub MCP Server and are not in ### Optional Headers -The Remote GitHub MCP server has optional headers equivalent to the Local server env vars or flags: +The Remote GitHub MCP server has optional headers equivalent to the Local server env vars: - `X-MCP-Toolsets`: Comma-separated list of toolsets to enable. E.g. "repos,issues". - - Equivalent to `GITHUB_TOOLSETS` env var or `--toolsets` flag for Local server. + - Equivalent to `GITHUB_TOOLSETS` env var for Local server. - If the list is empty, default toolsets will be used. Invalid or unknown toolsets are silently ignored without error and will not prevent the server from starting. Whitespace is ignored. -- `X-MCP-Tools`: Comma-separated list of tools to enable. E.g. "get_file_contents,issue_read,pull_request_read". - - Equivalent to `GITHUB_TOOLS` env var or `--tools` flag for Local server. - - Invalid tools will throw an error and prevent the server from starting. Whitespace is ignored. - `X-MCP-Readonly`: Enables only "read" tools. - Equivalent to `GITHUB_READ_ONLY` env var for Local server. - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true. @@ -68,8 +65,6 @@ The Remote GitHub MCP server has optional headers equivalent to the Local server - Equivalent to `GITHUB_LOCKDOWN_MODE` env var for Local server. - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true. -> **Looking for examples?** See the [Server Configuration Guide](./server-configuration.md) for common recipes like minimal setups, read-only mode, and combining tools with toolsets. - Example: ```json @@ -104,4 +99,4 @@ Example: "type": "http", "url": "https://api.githubcopilot.com/mcp/x/issues/readonly" } -``` +``` \ No newline at end of file diff --git a/e2e-repos-to-delete.txt b/e2e-repos-to-delete.txt new file mode 100644 index 000000000..16ff4d511 --- /dev/null +++ b/e2e-repos-to-delete.txt @@ -0,0 +1,28 @@ +github-mcp-server-e2e-TestPullRequestReviewCommentSubmit-1763999268062 +github-mcp-server-e2e-TestPullRequestReviewDeletion-1763997844489 +github-mcp-server-e2e-TestRequestCopilotReview-1763997844386 +github-mcp-server-e2e-TestPullRequestAtomicCreateAndSubmit-1763997844435 +github-mcp-server-e2e-TestDirectoryDeletion-1763997844380 +github-mcp-server-e2e-TestPullRequestReviewCommentSubmit-1763997844451 +github-mcp-server-e2e-TestTags-1763997844521 +github-mcp-server-e2e-TestFileDeletion-1763997844358 +github-mcp-server-e2e-TestAssignCopilotToIssue-1763997844406 +github-mcp-server-e2e-TestFileDeletion-1747747995640 +github-mcp-server-e2e-TestDirectoryDeletion-1747747995296 +github-mcp-server-e2e-TestPullRequestAtomicCreateAndSubmit-1747747995307 +github-mcp-server-e2e-TestPullRequestReviewCommentSubmit-1747747995463 +github-mcp-server-e2e-TestPullRequestReviewDeletion-1747747995436 +github-mcp-server-e2e-TestTags-1747747995443 +github-mcp-server-e2e-TestDirectoryDeletion-1747747769514 +github-mcp-server-e2e-TestFileDeletion-1747747769467 +github-mcp-server-e2e-TestPullRequestReviewDeletion-1747747769267 +github-mcp-server-e2e-TestPullRequestReviewCommentSubmit-1747747769413 +github-mcp-server-e2e-TestPullRequestAtomicCreateAndSubmit-1747747769265 +github-mcp-server-e2e-TestTags-1747747769255 +github-mcp-server-e2e-TestFileDeletion-1747747690008 +github-mcp-server-e2e-TestDirectoryDeletion-1747747690012 +github-mcp-server-e2e-TestPullRequestAtomicCreateAndSubmit-1747747689930 +github-mcp-server-e2e-TestPullRequestReviewCommentSubmit-1747747690146 +github-mcp-server-e2e-TestRequestCopilotReview-1747747689966 +github-mcp-server-e2e-TestPullRequestReviewDeletion-1747747689922 +github-mcp-server-e2e-TestTags-1747747690217 diff --git a/pkg/github/__toolsnaps__/issue_graph.snap b/pkg/github/__toolsnaps__/issue_graph.snap new file mode 100644 index 000000000..507d09c5e --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_graph.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "title": "Get issue relationship graph", + "readOnlyHint": true + }, + "description": "**PRIMARY TOOL FOR ISSUE/PR/PROJECT STATUS** - Returns the entire work hierarchy in ONE call. Use this FIRST before issue_read or other tools.\n\nFASTEST PATH TO PROJECT STATUS: One call returns all related issues, PRs, and their states - no need for multiple API calls.\n\nMUST USE THIS TOOL FIRST WHEN USER ASKS:\n- \"update on...\" / \"status of...\" / \"progress on...\" / \"tell me about...\"\n- \"what's happening with...\" / \"how is... going\" / \"state of...\"\n- \"project status\" / \"overall progress\" / \"how is the epic going\"\n- \"epic\" / \"parent issue\" / \"sub-issues\" / \"blocking\" / \"depends on\"\n- ANY request for status on work that might span multiple issues\n\nDO NOT use issue_read or list_issues first for status questions - issue_graph returns everything in one call.\n\nReturns a comprehensive view including:\n- Node types: epic (large initiatives), batch (parent issues), task (regular issues), pr (pull requests)\n- Full hierarchy: epic → batch → task → PR relationships across the ENTIRE project\n- Sub-issues and \"closes/fixes\" references\n- Cross-references and related work\n- Status updates extracted from issue bodies and comments\n- Open/closed/merged state of ALL related items\n\nUse focus=\"epic\" to automatically find and focus on the parent epic of any issue.", + "inputSchema": { + "properties": { + "focus": { + "description": "Which node type to focus on: 'provided' (default) uses the specified issue/PR, 'epic' shifts focus to the nearest epic in the hierarchy, 'batch' shifts focus to the nearest batch/parent issue", + "enum": [ + "provided", + "epic", + "batch" + ], + "type": "string" + }, + "issue_number": { + "description": "Issue or pull request number to build the graph from", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "verbose": { + "description": "Include crawl statistics showing how the graph was traversed (nodes fetched, depth reached, repos accessed, etc.)", + "type": "boolean" + } + }, + "required": [ + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "issue_graph" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index 9e9462df6..beaa7289e 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -3,7 +3,7 @@ "title": "Get issue details", "readOnlyHint": true }, - "description": "Get information about a specific issue in a GitHub repository.", + "description": "Get detailed information about a single issue: full body, comments, sub-issues, or labels.\n\n**USE issue_graph INSTEAD when user asks about:**\n- Status, updates, or progress on issues/PRs/epics\n- Project overview or how work is going\n- Parent issues, sub-issues, or work hierarchy\n- Related or blocking issues\n\nissue_read is for: reading the full issue body, fetching comments, listing sub-issues of a known parent, or getting label details - AFTER you already know which specific issue you need.", "inputSchema": { "properties": { "issue_number": { diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go index 3a5fb54bb..47448eb5e 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/instructions.go @@ -63,7 +63,37 @@ Before creating a pull request, search for pull request templates in the reposit case "issues": return `## Issues -Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` +**CRITICAL: For ANY question about issue/PR/epic status, updates, or progress - ALWAYS call 'issue_graph' FIRST before any other tool.** + +'issue_graph' is the FASTEST PATH to project status - one call returns the entire work hierarchy (epic → batches → tasks → PRs) instead of multiple separate API calls. + +Trigger phrases that REQUIRE 'issue_graph' as the FIRST tool call: +- "update on..." / "status of..." / "progress on..." / "tell me about..." +- "what's happening with..." / "how is... going" / "state of..." +- "project status" / "how is the project going" / "overall progress" +- "epic" / "parent issue" / "sub-issues" / "blocking" / "depends on" +- ANY issue/PR number reference when asking for status or updates + +Example: User asks "give me an update on issue #123" → Call issue_graph(owner, repo, 123) FIRST. +Example: User asks "what's the status of the project" → Call issue_graph with the epic/tracking issue number. + +The 'issue_graph' tool returns in ONE call: +- Full hierarchy: epic → batch → task → PR relationships across the entire project +- All sub-issues and "closes/fixes" references +- Status updates extracted from issue bodies/comments +- Cross-references and related work +- Open/closed state of all related items + +Use focus="epic" to automatically find and focus on the parent epic of any issue. + +ONLY AFTER calling 'issue_graph', use other tools: +- 'issue_read' for full details of specific issues identified in the graph +- 'search_issues' to find related issues not in the graph + +For creating/modifying issues: +- Check 'list_issue_types' first for organizations to use proper issue types +- Use 'search_issues' before creating new issues to avoid duplicates +- Always set 'state_reason' when closing issues` case "discussions": return `## Discussions diff --git a/pkg/github/issue_graph.go b/pkg/github/issue_graph.go new file mode 100644 index 000000000..b0a1c835d --- /dev/null +++ b/pkg/github/issue_graph.go @@ -0,0 +1,2114 @@ +package github + +import ( + "container/heap" + "context" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v79/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +const ( + // MaxGraphDepth is the maximum depth to crawl for related issues + MaxGraphDepth = 4 + // MaxConcurrentFetches is the maximum number of concurrent API calls + MaxConcurrentFetches = 5 + // RateLimitBackoff is the base backoff duration when rate limited + RateLimitBackoff = 100 * time.Millisecond +) + +// Crawl priority levels (lower = higher priority) +const ( + PriorityParent = 0 // Parents are highest priority (must traverse up for context) + PriorityChild = 1 // Direct children are next (sub-issues, tasklist items) + PriorityCrossRef = 2 // Cross-references are lowest priority +) + +// crawlItem represents an item to crawl with priority +type crawlItem struct { + owner string + repo string + number int + depth int + priority int // Lower = higher priority + isAncestor bool // true if this is an ancestor of the focus + isCrossRef bool // true if reached via cross-reference (don't crawl further) +} + +// crawlQueue implements heap.Interface for priority queue +type crawlQueue []*crawlItem + +func (q crawlQueue) Len() int { return len(q) } + +func (q crawlQueue) Less(i, j int) bool { + // Lower priority number = higher priority + // If same priority, prefer lower depth (closer to focus) + if q[i].priority != q[j].priority { + return q[i].priority < q[j].priority + } + return q[i].depth < q[j].depth +} + +func (q crawlQueue) Swap(i, j int) { + q[i], q[j] = q[j], q[i] +} + +func (q *crawlQueue) Push(x any) { + *q = append(*q, x.(*crawlItem)) +} + +func (q *crawlQueue) Pop() any { + old := *q + n := len(old) + item := old[n-1] + old[n-1] = nil // avoid memory leak + *q = old[:n-1] + return item +} + +// NodeType represents the type of a graph node +type NodeType string + +const ( + NodeTypeEpic NodeType = "epic" + NodeTypeBatch NodeType = "batch" + NodeTypeTask NodeType = "task" + NodeTypePR NodeType = "pr" +) + +// RelationType represents the relationship between nodes +type RelationType string + +const ( + RelationTypeParent RelationType = "parent" + RelationTypeChild RelationType = "child" + RelationTypeRelated RelationType = "related" +) + +// GraphNode represents a node in the issue graph +type GraphNode struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + NodeType NodeType `json:"nodeType"` + State string `json:"state"` // "open", "closed", or "merged" (for PRs) + StateReason string `json:"stateReason"` // For issues: "completed", "not_planned", "duplicate", "reopened"; for PRs: empty or "merged" + StatusUpdate string `json:"statusUpdate"` // For epics/batches: extracted status from body/comments (on-track, delayed, etc.) + Title string `json:"title"` + BodyPreview string `json:"bodyPreview"` + TasklistItems []TasklistItem `json:"tasklistItems"` // Legacy tasklist items from issue body (for batches/epics) + Depth int `json:"depth"` + IsFocus bool `json:"isFocus"` +} + +// GraphEdge represents an edge in the issue graph +type GraphEdge struct { + FromOwner string `json:"fromOwner"` + FromRepo string `json:"fromRepo"` + FromNumber int `json:"fromNumber"` + ToOwner string `json:"toOwner"` + ToRepo string `json:"toRepo"` + ToNumber int `json:"toNumber"` + Relation RelationType `json:"relation"` +} + +// IssueGraph represents the complete graph structure +type IssueGraph struct { + FocusOwner string `json:"focusOwner"` + FocusRepo string `json:"focusRepo"` + FocusNumber int `json:"focusNumber"` + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` + Summary string `json:"summary"` + FocusProject []ProjectInfo `json:"focusProject,omitempty"` // Project info for the focus node + CrawlSummary string `json:"crawlSummary,omitempty"` // Verbose crawl statistics (when verbose=true) +} + +// ProjectInfo represents project name and status for an issue +type ProjectInfo struct { + ProjectTitle string `json:"projectTitle"` + Status string `json:"status,omitempty"` +} + +// nodeKey creates a unique key for a node +func nodeKey(owner, repo string, number int) string { + return fmt.Sprintf("%s/%s#%d", strings.ToLower(owner), strings.ToLower(repo), number) +} + +// repoKey creates a unique key for a repository +func repoKey(owner, repo string) string { + return fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo)) +} + +// IssueReference represents a reference to an issue/PR extracted from text +type IssueReference struct { + Owner string + Repo string + Number int + IsParent bool // true if this appears to be a parent (e.g., "closes #X") +} + +// TasklistItem represents a single item from a legacy markdown tasklist +type TasklistItem struct { + Text string `json:"text"` // The text content of the item (cleaned) + Completed bool `json:"completed"` // Whether the checkbox is checked + LinkedRef *IssueReference `json:"linkedRef"` // Issue/PR reference if the item links to one + LinkedNode *GraphNode `json:"linkedNode"` // Resolved node info if available (not serialized) +} + +// Regular expressions for extracting issue references +var ( + // Matches #123 style references (same repo) + sameRepoRefRegex = regexp.MustCompile(`(?:^|[^\w])#(\d+)`) + // Matches owner/repo#123 style references (cross-repo) + crossRepoRefRegex = regexp.MustCompile(`([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/([a-zA-Z0-9._-]+)#(\d+)`) + // Matches full GitHub URLs like https://github.com/owner/repo/issues/123 or /pull/123 + githubURLRefRegex = regexp.MustCompile(`https?://(?:www\.)?github\.com/([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/([a-zA-Z0-9._-]+)/(?:issues|pull)/(\d+)`) + // Matches "closes #123", "fixes #123", "resolves #123" patterns (PR linking to issue) + closesRefRegex = regexp.MustCompile(`(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:(?:([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/([a-zA-Z0-9._-]+))?#(\d+))`) + // URL pattern to remove + urlRegex = regexp.MustCompile(`https?://[^\s<>\[\]]+`) + // Markdown image pattern to remove + imageRegex = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`) + // Multiple whitespace to collapse + whitespaceRegex = regexp.MustCompile(`\s+`) + // HTML tags to remove + htmlTagRegex = regexp.MustCompile(`<[^>]*>`) + // Code block patterns to remove before extracting references + fencedCodeBlockRegex = regexp.MustCompile("(?s)```[^`]*```") + inlineCodeRegex = regexp.MustCompile("`[^`]+`") + // Status patterns for epic/batch tracking (case-insensitive) + statusPatterns = regexp.MustCompile(`(?i)(?:^|\W)(status|on[- ]?track|delayed|at[- ]?risk|blocked|behind|ahead|eta|target|due|deadline)[:\s]+([^\n]{3,80})`) + // Markdown tasklist checkbox pattern: - [ ] unchecked, - [x] or - [X] checked + // Also matches * [ ] and * [x] variants + tasklistCheckboxRegex = regexp.MustCompile(`(?m)^[\t ]*[-*][\t ]+\[([ xX])\][\t ]+(.+?)$`) +) + +// stripCodeBlocks removes fenced code blocks and inline code from text +// This prevents extracting issue references from example code +func stripCodeBlocks(text string) string { + // Remove fenced code blocks first (```...```) + text = fencedCodeBlockRegex.ReplaceAllString(text, "") + // Remove inline code (`...`) + text = inlineCodeRegex.ReplaceAllString(text, "") + return text +} + +// extractIssueReferences extracts all issue/PR references from text +// It strips code blocks first to avoid picking up example references +func extractIssueReferences(text, defaultOwner, defaultRepo string) []IssueReference { + // Strip code blocks to avoid extracting references from examples + text = stripCodeBlocks(text) + + refs := make([]IssueReference, 0) + seen := make(map[string]bool) + + // Extract "closes/fixes/resolves" references (these indicate parent relationship) + for _, match := range closesRefRegex.FindAllStringSubmatch(text, -1) { + owner := defaultOwner + repo := defaultRepo + if match[1] != "" && match[2] != "" { + owner = match[1] + repo = match[2] + } + number := 0 + if _, err := fmt.Sscanf(match[3], "%d", &number); err == nil && number > 0 { + key := nodeKey(owner, repo, number) + if !seen[key] { + seen[key] = true + refs = append(refs, IssueReference{ + Owner: owner, + Repo: repo, + Number: number, + IsParent: true, // This issue/PR closes another, meaning the other is the parent + }) + } + } + } + + // Extract cross-repo references + for _, match := range crossRepoRefRegex.FindAllStringSubmatch(text, -1) { + owner := match[1] + repo := match[2] + number := 0 + if _, err := fmt.Sscanf(match[3], "%d", &number); err == nil && number > 0 { + key := nodeKey(owner, repo, number) + if !seen[key] { + seen[key] = true + refs = append(refs, IssueReference{ + Owner: owner, + Repo: repo, + Number: number, + }) + } + } + } + + // Extract full GitHub URL references (https://github.com/owner/repo/issues/123) + for _, match := range githubURLRefRegex.FindAllStringSubmatch(text, -1) { + owner := match[1] + repo := match[2] + number := 0 + if _, err := fmt.Sscanf(match[3], "%d", &number); err == nil && number > 0 { + key := nodeKey(owner, repo, number) + if !seen[key] { + seen[key] = true + refs = append(refs, IssueReference{ + Owner: owner, + Repo: repo, + Number: number, + }) + } + } + } + + // Extract same-repo references + for _, match := range sameRepoRefRegex.FindAllStringSubmatch(text, -1) { + number := 0 + if _, err := fmt.Sscanf(match[1], "%d", &number); err == nil && number > 0 { + key := nodeKey(defaultOwner, defaultRepo, number) + if !seen[key] { + seen[key] = true + refs = append(refs, IssueReference{ + Owner: defaultOwner, + Repo: defaultRepo, + Number: number, + }) + } + } + } + + return refs +} + +// extractTasklistItems extracts markdown checkbox tasklist items from issue body text +// This handles legacy tasklists (plain text checkboxes) that are not GitHub sub-issues +func extractTasklistItems(body, defaultOwner, defaultRepo string) []TasklistItem { + if body == "" { + return nil + } + + matches := tasklistCheckboxRegex.FindAllStringSubmatch(body, -1) + if len(matches) == 0 { + return nil + } + + items := make([]TasklistItem, 0, len(matches)) + for _, match := range matches { + if len(match) < 3 { + continue + } + + checkbox := match[1] + text := strings.TrimSpace(match[2]) + + // Skip empty items + if text == "" { + continue + } + + completed := checkbox == "x" || checkbox == "X" + + item := TasklistItem{ + Text: text, + Completed: completed, + } + + // Check if this item references an issue/PR + refs := extractIssueReferences(text, defaultOwner, defaultRepo) + if len(refs) > 0 { + // Use the first reference found in the item + item.LinkedRef = &refs[0] + } + + items = append(items, item) + } + + return items +} + +// sanitizeBodyForGraph sanitizes and truncates the body text for graph display +func sanitizeBodyForGraph(body string, maxLines, maxLineLen int) string { + if body == "" { + return "" + } + + // Remove markdown images first (before URL removal) + body = imageRegex.ReplaceAllString(body, "[image]") + // Remove URLs + body = urlRegex.ReplaceAllString(body, "[link]") + // Remove HTML tags + body = htmlTagRegex.ReplaceAllString(body, "") + + // Split into lines first, before collapsing whitespace + lines := strings.Split(body, "\n") + result := make([]string, 0, maxLines) + + for _, line := range lines { + // Collapse multiple whitespace within each line + line = whitespaceRegex.ReplaceAllString(line, " ") + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Truncate line if too long + if len(line) > maxLineLen { + line = line[:maxLineLen-3] + "..." + } + result = append(result, line) + if len(result) >= maxLines { + break + } + } + + return strings.Join(result, " | ") +} + +// getBodyLinesForDepth returns the number of body lines based on depth from focus node +func getBodyLinesForDepth(depth int) int { + switch depth { + case 0: + return 8 + case 1: + return 5 + case 2: + return 4 + default: + return 3 + } +} + +// getMaxLineLenForDepth returns the max line length based on depth from focus node +func getMaxLineLenForDepth(depth int) int { + switch depth { + case 0: + return 120 + case 1: + return 100 + case 2: + return 80 + default: + return 60 + } +} + +// classifyNode determines the type of a node based on its properties +func classifyNode(isPR bool, labels []string, title string, issueType string, hasSubIssues bool) NodeType { + if isPR { + return NodeTypePR + } + + // Check for epic in issue type (GitHub's issue type feature) + issueTypeLower := strings.ToLower(issueType) + if strings.Contains(issueTypeLower, "epic") { + return NodeTypeEpic + } + + // Check for epic label or title + titleLower := strings.ToLower(title) + for _, label := range labels { + if strings.Contains(strings.ToLower(label), "epic") { + return NodeTypeEpic + } + } + if strings.Contains(titleLower, "epic") { + return NodeTypeEpic + } + + // If it has sub-issues but is not an epic, it's a batch issue + if hasSubIssues { + return NodeTypeBatch + } + + return NodeTypeTask +} + +// extractStatusUpdate extracts status information from issue body and milestone +// This is a lightweight "if lucky" check - returns empty string if no clear status found +func extractStatusUpdate(body string, milestone *github.Milestone) string { + var statusParts []string + + // Check milestone due date first (most reliable) + if milestone != nil && milestone.DueOn != nil { + dueDate := milestone.DueOn.Time + now := time.Now() + milestoneName := milestone.GetTitle() + + if dueDate.Before(now) { + daysOverdue := int(now.Sub(dueDate).Hours() / 24) + if milestoneName != "" { + statusParts = append(statusParts, fmt.Sprintf("Milestone '%s' overdue by %d days", milestoneName, daysOverdue)) + } else { + statusParts = append(statusParts, fmt.Sprintf("Milestone overdue by %d days", daysOverdue)) + } + } else { + daysUntil := int(dueDate.Sub(now).Hours() / 24) + if milestoneName != "" { + statusParts = append(statusParts, fmt.Sprintf("Milestone '%s' due in %d days", milestoneName, daysUntil)) + } else { + statusParts = append(statusParts, fmt.Sprintf("Milestone due in %d days", daysUntil)) + } + } + } + + // Quick scan of body for status keywords + if body != "" { + // Look for status patterns in body + matches := statusPatterns.FindAllStringSubmatch(body, 3) // limit to 3 matches + for _, match := range matches { + if len(match) >= 3 { + keyword := strings.ToLower(match[1]) + value := strings.TrimSpace(match[2]) + // Truncate long values + if len(value) > 60 { + value = value[:57] + "..." + } + // Normalize keyword + switch { + case keyword == "status": + statusParts = append(statusParts, fmt.Sprintf("Status: %s", value)) + case strings.Contains(keyword, "track"): + statusParts = append(statusParts, fmt.Sprintf("On-track: %s", value)) + case strings.Contains(keyword, "delay") || strings.Contains(keyword, "behind"): + statusParts = append(statusParts, fmt.Sprintf("Delayed: %s", value)) + case strings.Contains(keyword, "risk"): + statusParts = append(statusParts, fmt.Sprintf("At-risk: %s", value)) + case strings.Contains(keyword, "block"): + statusParts = append(statusParts, fmt.Sprintf("Blocked: %s", value)) + case strings.Contains(keyword, "eta") || strings.Contains(keyword, "target") || + strings.Contains(keyword, "due") || strings.Contains(keyword, "deadline"): + statusParts = append(statusParts, fmt.Sprintf("Target: %s", value)) + } + } + } + } + + if len(statusParts) == 0 { + return "" + } + + // Limit to 2 status parts to keep it concise + if len(statusParts) > 2 { + statusParts = statusParts[:2] + } + + return strings.Join(statusParts, "; ") +} + +// extractStatusFromComments fetches recent comments and extracts status (for epics/batches only) +// Only fetches 3 most recent comments to minimize API overhead +func (gc *graphCrawler) extractStatusFromComments(ctx context.Context, owner, repo string, number int, issueBody string, milestone *github.Milestone) string { + // First try to get status from issue body and milestone + bodyStatus := extractStatusUpdate(issueBody, milestone) + + // For epics/batches, also check recent comments (if context allows) + select { + case <-ctx.Done(): + return bodyStatus // Context cancelled, return what we have + default: + } + + // Fetch only the 3 most recent comments (sorted by created desc) + comments, resp, err := gc.client.Issues.ListComments(ctx, owner, repo, number, &github.IssueListCommentsOptions{ + Sort: github.Ptr("created"), + Direction: github.Ptr("desc"), + ListOptions: github.ListOptions{ + PerPage: 3, + }, + }) + if resp != nil { + _ = resp.Body.Close() + } + if err != nil || len(comments) == 0 { + return bodyStatus + } + + // Check recent comments for status updates + for _, comment := range comments { + if comment.Body == nil { + continue + } + commentStatus := extractStatusUpdate(*comment.Body, nil) + if commentStatus != "" { + // Found status in comment - prepend to body status if different + if bodyStatus == "" { + return commentStatus + } + if commentStatus != bodyStatus { + return commentStatus + " | " + bodyStatus + } + return bodyStatus + } + } + + return bodyStatus +} + +// FocusSource describes how the focus node was determined +type FocusSource string + +const ( + FocusSourceProvided FocusSource = "provided" // User-specified issue/PR + FocusSourceHierarchy FocusSource = "hierarchy" // Found via sub-issues/closes chain + FocusSourceCrossRef FocusSource = "cross-reference" // Found via mention/cross-reference +) + +// graphCrawler manages the concurrent crawling of the issue graph +type graphCrawler struct { + client *github.Client + gqlClient *githubv4.Client // GraphQL client for parent queries + cache *lockdown.RepoAccessCache + flags FeatureFlags + focusOwner string + focusRepo string + focusNumber int + focusSource FocusSource // how the focus was determined + focusRequested string // what focus type was requested ("epic", "batch", or "") + originalOwner string // original user-provided owner + originalRepo string // original user-provided repo + originalNumber int // original user-provided number + nodes map[string]*GraphNode + edges []GraphEdge + parentMap map[string]string // maps child -> parent + inaccessibleRepo map[string]bool // repos we don't have access to + mu sync.RWMutex + sem chan struct{} // semaphore for concurrency control + // Crawl statistics for verbose mode + verbose bool + crawlStats crawlStatistics +} + +// crawlStatistics tracks crawl metrics for verbose output +type crawlStatistics struct { + nodesVisited int + nodesFetched int + subIssuesCrawled int + tasklistRefsCrawled int + timelinesCrawled int + crossRefsCrawled int + depthReached int + reposAccessed map[string]bool + timedOut bool + rateLimitHits int +} + +func newGraphCrawler(client *github.Client, gqlClient *githubv4.Client, cache *lockdown.RepoAccessCache, flags FeatureFlags, owner, repo string, number int, verbose bool) *graphCrawler { + return &graphCrawler{ + client: client, + gqlClient: gqlClient, + cache: cache, + flags: flags, + focusOwner: owner, + focusRepo: repo, + focusNumber: number, + focusSource: FocusSourceProvided, + originalOwner: owner, + originalRepo: repo, + originalNumber: number, + nodes: make(map[string]*GraphNode), + edges: make([]GraphEdge, 0), + parentMap: make(map[string]string), + inaccessibleRepo: make(map[string]bool), + sem: make(chan struct{}, MaxConcurrentFetches), + verbose: verbose, + crawlStats: crawlStatistics{reposAccessed: make(map[string]bool)}, + } +} + +// isRepoInaccessible checks if a repo is known to be inaccessible +func (gc *graphCrawler) isRepoInaccessible(owner, repo string) bool { + gc.mu.RLock() + defer gc.mu.RUnlock() + return gc.inaccessibleRepo[repoKey(owner, repo)] +} + +// markRepoInaccessible marks a repo as inaccessible +func (gc *graphCrawler) markRepoInaccessible(owner, repo string) { + gc.mu.Lock() + defer gc.mu.Unlock() + gc.inaccessibleRepo[repoKey(owner, repo)] = true +} + +// fetchNode fetches a single issue or PR and adds it to the graph +// Returns both the node and the raw issue for further processing +func (gc *graphCrawler) fetchNode(ctx context.Context, owner, repo string, number, depth int) (*GraphNode, *github.Issue, error) { + key := nodeKey(owner, repo, number) + + // Check if already visited + gc.mu.RLock() + if node, exists := gc.nodes[key]; exists { + gc.mu.RUnlock() + return node, nil, nil // Already visited, no issue to return + } + gc.mu.RUnlock() + + // Check if repo is known to be inaccessible + if gc.isRepoInaccessible(owner, repo) { + return nil, nil, nil + } + + // Acquire semaphore + select { + case gc.sem <- struct{}{}: + defer func() { <-gc.sem }() + case <-ctx.Done(): + return nil, nil, ctx.Err() + } + + // Fetch issue/PR details with retry on rate limit + var issue *github.Issue + var resp *github.Response + var err error + for attempt := 0; attempt < 3; attempt++ { + issue, resp, err = gc.client.Issues.Get(ctx, owner, repo, number) + if err == nil { + break + } + if resp != nil { + _ = resp.Body.Close() + // Handle rate limiting with backoff + if resp.StatusCode == 429 || resp.StatusCode == 403 && resp.Rate.Remaining == 0 { + gc.crawlStats.rateLimitHits++ + backoff := RateLimitBackoff * time.Duration(1< 0 { + gc.markRepoInaccessible(owner, repo) + } + return nil, nil, nil + } + } + // For other errors, don't retry - just skip this node + return nil, nil, nil + } + if err != nil { + return nil, nil, nil // exhausted retries, skip node + } + defer func() { _ = resp.Body.Close() }() + + // Check lockdown mode + if gc.flags.LockdownMode && gc.cache != nil { + login := issue.GetUser().GetLogin() + if login != "" { + isSafeContent, err := gc.cache.IsSafeContent(ctx, login, owner, repo) + if err != nil { + // Skip this node if we can't verify safety + return nil, nil, nil + } + if !isSafeContent { + // Content is restricted, skip but don't fail + return nil, nil, nil + } + } + } + + isPR := issue.IsPullRequest() + + // Get labels + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + if label.Name != nil { + labels = append(labels, *label.Name) + } + } + + // Check for sub-issues (only for issues, not PRs) + hasSubIssues := false + if !isPR { + subIssues, subResp, subErr := gc.client.SubIssue.ListByIssue(ctx, owner, repo, int64(number), &github.IssueListOptions{ + ListOptions: github.ListOptions{PerPage: 1}, + }) + if subErr == nil && len(subIssues) > 0 { + hasSubIssues = true + } + if subResp != nil { + _ = subResp.Body.Close() + } + } + + // Get issue type name if available + issueTypeName := "" + if issue.Type != nil { + issueTypeName = issue.Type.GetName() + } + + // Determine node type + nodeType := classifyNode(isPR, labels, issue.GetTitle(), issueTypeName, hasSubIssues) + + // Get state and state reason + // For PRs: check if merged (via PullRequestLinks.MergedAt) + // For Issues: use StateReason (completed, not_planned, duplicate, reopened) + state := issue.GetState() + stateReason := "" + + if isPR { + // Check if PR was merged + prLinks := issue.GetPullRequestLinks() + if prLinks != nil && !prLinks.GetMergedAt().IsZero() { + state = "merged" + stateReason = "merged" + } + } else if issue.StateReason != nil { + // For issues, get the state reason if available + stateReason = *issue.StateReason + } + + // Extract status update for epics and batches (lightweight check) + var statusUpdate string + var tasklistItems []TasklistItem + if nodeType == NodeTypeEpic || nodeType == NodeTypeBatch { + statusUpdate = gc.extractStatusFromComments(ctx, owner, repo, number, issue.GetBody(), issue.Milestone) + // Extract legacy tasklist items from issue body + tasklistItems = extractTasklistItems(issue.GetBody(), owner, repo) + } + + // Create node + node := &GraphNode{ + Owner: owner, + Repo: repo, + Number: number, + NodeType: nodeType, + State: state, + StateReason: stateReason, + StatusUpdate: statusUpdate, + Title: issue.GetTitle(), + BodyPreview: sanitizeBodyForGraph(issue.GetBody(), getBodyLinesForDepth(depth), getMaxLineLenForDepth(depth)), + TasklistItems: tasklistItems, + Depth: depth, + IsFocus: strings.EqualFold(owner, gc.focusOwner) && strings.EqualFold(repo, gc.focusRepo) && number == gc.focusNumber, + } + + // Add to graph + gc.mu.Lock() + gc.nodes[key] = node + gc.mu.Unlock() + + return node, issue, nil +} + +// crawlResult represents the result of processing a single node +type crawlResult struct { + key string + node *GraphNode + newItems []*crawlItem // New items discovered from this node + err error +} + +// crawl performs a concurrent BFS crawl from the focus node using a priority queue +func (gc *graphCrawler) crawl(ctx context.Context) error { + // Initialize priority queue with focus node + queue := &crawlQueue{} + heap.Init(queue) + heap.Push(queue, &crawlItem{ + owner: gc.focusOwner, + repo: gc.focusRepo, + number: gc.focusNumber, + depth: 0, + priority: PriorityChild, + isAncestor: false, + }) + + // Track what's been queued to avoid duplicates + queued := make(map[string]bool) + queued[nodeKey(gc.focusOwner, gc.focusRepo, gc.focusNumber)] = true + + // Worker pool for concurrent fetching + const numWorkers = MaxConcurrentFetches + jobs := make(chan *crawlItem, numWorkers*2) + results := make(chan *crawlResult, numWorkers*2) + + // Start workers + var wg sync.WaitGroup + for i := 0; i < numWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for item := range jobs { + result := gc.processNode(ctx, item) + select { + case results <- result: + case <-ctx.Done(): + return + } + } + }() + } + + // Close results channel when all workers are done + go func() { + wg.Wait() + close(results) + }() + + // Track in-flight jobs + inFlight := 0 + + // Main dispatch loop + for { + // Check context cancellation + select { + case <-ctx.Done(): + gc.crawlStats.timedOut = true + close(jobs) + // Drain results to let workers exit + for range results { //nolint:revive // intentionally empty - draining channel + } + return ctx.Err() + default: + } + + // If queue has items and we can dispatch more, do so + for queue.Len() > 0 && inFlight < numWorkers { + item := heap.Pop(queue).(*crawlItem) + key := nodeKey(item.owner, item.repo, item.number) + + // Skip if already visited + gc.mu.RLock() + _, visited := gc.nodes[key] + gc.mu.RUnlock() + if visited { + gc.crawlStats.nodesVisited++ + continue + } + + // Skip if repo is inaccessible + if gc.isRepoInaccessible(item.owner, item.repo) { + continue + } + + // Skip if beyond max depth + if item.depth > MaxGraphDepth { + continue + } + + // Track max depth reached + if item.depth > gc.crawlStats.depthReached { + gc.crawlStats.depthReached = item.depth + } + + // Track repo access + gc.crawlStats.reposAccessed[repoKey(item.owner, item.repo)] = true + + // Dispatch to worker + select { + case jobs <- item: + inFlight++ + case <-ctx.Done(): + gc.crawlStats.timedOut = true + close(jobs) + for range results { //nolint:revive // intentionally empty - draining channel + } + return ctx.Err() + } + } + + // If nothing in queue and nothing in flight, we're done + if queue.Len() == 0 && inFlight == 0 { + close(jobs) + // Drain any remaining results + for range results { //nolint:revive // intentionally empty - draining channel + } + return nil + } + + // Wait for a result + select { + case result, ok := <-results: + if !ok { + // Results channel closed, we're done + return nil + } + inFlight-- + + if result.err != nil || result.node == nil { + continue + } + gc.crawlStats.nodesFetched++ + + // Add discovered items to queue + for _, newItem := range result.newItems { + newKey := nodeKey(newItem.owner, newItem.repo, newItem.number) + if !queued[newKey] { + queued[newKey] = true + heap.Push(queue, newItem) + } + } + + case <-ctx.Done(): + gc.crawlStats.timedOut = true + close(jobs) + for range results { //nolint:revive // intentionally empty - draining channel + } + return ctx.Err() + } + } +} + +// processNode fetches a single node and discovers related items to crawl +func (gc *graphCrawler) processNode(ctx context.Context, item *crawlItem) *crawlResult { + result := &crawlResult{ + key: nodeKey(item.owner, item.repo, item.number), + newItems: []*crawlItem{}, + } + + // Fetch the node + node, issue, err := gc.fetchNode(ctx, item.owner, item.repo, item.number, item.depth) + if err != nil { + result.err = err + return result + } + result.node = node + + if node == nil || issue == nil { + return result + } + + // Don't discover more items from nodes at max depth + // Also stop crawling from cross-referenced nodes (one hop only) + if item.depth >= MaxGraphDepth || item.isCrossRef { + return result + } + + key := result.key + + // For issues (not PRs), fetch parent via GraphQL and sub-issues via REST + if !issue.IsPullRequest() { + // Fetch parent via GraphQL (lightweight query) + if gc.gqlClient != nil { + if info := fetchIssueGraphQLInfo(ctx, gc.gqlClient, item.owner, item.repo, item.number); info != nil { + // Process parent + if info.Parent != nil { + parentKey := nodeKey(info.Parent.Owner, info.Parent.Repo, info.Parent.Number) + + gc.mu.Lock() + gc.parentMap[key] = parentKey + gc.edges = append(gc.edges, GraphEdge{ + FromOwner: item.owner, + FromRepo: item.repo, + FromNumber: item.number, + ToOwner: info.Parent.Owner, + ToRepo: info.Parent.Repo, + ToNumber: info.Parent.Number, + Relation: RelationTypeParent, + }) + gc.mu.Unlock() + + // Parents get highest priority, same depth (they're at same level in hierarchy) + // Mark as ancestor so we don't crawl their other children + result.newItems = append(result.newItems, &crawlItem{ + owner: info.Parent.Owner, + repo: info.Parent.Repo, + number: info.Parent.Number, + depth: item.depth, // Same depth - parents are at same level + priority: PriorityParent, + isAncestor: true, + }) + } + } + } + + // Fetch sub-issues via REST API (handles cross-repo) + // Skip for ancestors - we don't want to crawl siblings of our path to focus + if !item.isAncestor { + subIssues, subResp, subErr := gc.client.SubIssue.ListByIssue(ctx, item.owner, item.repo, int64(item.number), &github.IssueListOptions{ + ListOptions: github.ListOptions{PerPage: 50}, + }) + if subErr == nil { + for _, sub := range subIssues { + subOwner := item.owner + subRepo := item.repo + if sub.Repository != nil { + if sub.Repository.Owner != nil && sub.Repository.Owner.Login != nil { + subOwner = *sub.Repository.Owner.Login + } + if sub.Repository.Name != nil { + subRepo = *sub.Repository.Name + } + } + if sub.Number == nil { + continue + } + subNumber := *sub.Number + subKey := nodeKey(subOwner, subRepo, subNumber) + + gc.mu.Lock() + gc.parentMap[subKey] = key + gc.edges = append(gc.edges, GraphEdge{ + FromOwner: item.owner, + FromRepo: item.repo, + FromNumber: item.number, + ToOwner: subOwner, + ToRepo: subRepo, + ToNumber: subNumber, + Relation: RelationTypeChild, + }) + gc.mu.Unlock() + + gc.crawlStats.subIssuesCrawled++ + result.newItems = append(result.newItems, &crawlItem{ + owner: subOwner, + repo: subRepo, + number: subNumber, + depth: item.depth + 1, + priority: PriorityChild, + isAncestor: false, + }) + } + } + if subResp != nil { + _ = subResp.Body.Close() + } + } + } + + // Crawl legacy tasklist linked refs (markdown checkbox items that link to issues/PRs) + // Skip for ancestors - we don't want to crawl siblings of our path to focus + if !item.isAncestor && node.TasklistItems != nil { + for _, taskItem := range node.TasklistItems { + if taskItem.LinkedRef != nil { + ref := taskItem.LinkedRef + if gc.isRepoInaccessible(ref.Owner, ref.Repo) { + continue + } + + refKey := nodeKey(ref.Owner, ref.Repo, ref.Number) + if refKey == key { + continue + } + + gc.mu.RLock() + _, alreadyVisited := gc.nodes[refKey] + gc.mu.RUnlock() + if alreadyVisited { + continue + } + + gc.mu.Lock() + gc.parentMap[refKey] = key + gc.edges = append(gc.edges, GraphEdge{ + FromOwner: item.owner, + FromRepo: item.repo, + FromNumber: item.number, + ToOwner: ref.Owner, + ToRepo: ref.Repo, + ToNumber: ref.Number, + Relation: RelationTypeChild, + }) + gc.mu.Unlock() + + gc.crawlStats.tasklistRefsCrawled++ + result.newItems = append(result.newItems, &crawlItem{ + owner: ref.Owner, + repo: ref.Repo, + number: ref.Number, + depth: item.depth + 1, + priority: PriorityChild, + isAncestor: false, + }) + } + } + } + + // Process body references + bodyRefs := extractIssueReferences(issue.GetBody(), item.owner, item.repo) + for _, ref := range bodyRefs { + if gc.isRepoInaccessible(ref.Owner, ref.Repo) { + continue + } + + refKey := nodeKey(ref.Owner, ref.Repo, ref.Number) + if refKey == key { + continue + } + + relType := RelationTypeRelated + priority := PriorityCrossRef + if ref.IsParent { + relType = RelationTypeParent + priority = PriorityParent + gc.mu.Lock() + gc.parentMap[key] = refKey + gc.mu.Unlock() + } + + gc.mu.Lock() + gc.edges = append(gc.edges, GraphEdge{ + FromOwner: item.owner, + FromRepo: item.repo, + FromNumber: item.number, + ToOwner: ref.Owner, + ToRepo: ref.Repo, + ToNumber: ref.Number, + Relation: relType, + }) + gc.mu.Unlock() + + result.newItems = append(result.newItems, &crawlItem{ + owner: ref.Owner, + repo: ref.Repo, + number: ref.Number, + depth: item.depth + 1, + priority: priority, + isAncestor: false, + isCrossRef: !ref.IsParent, // Only parent refs (closes/fixes) continue crawling + }) + } + + // Get cross-referenced issues from timeline - only for focus node to avoid timeout + if node.IsFocus { + timelineEvents, timelineResp, err := gc.client.Issues.ListIssueTimeline(ctx, item.owner, item.repo, item.number, &github.ListOptions{ + PerPage: 100, + }) + if err == nil { + gc.crawlStats.timelinesCrawled++ + for _, event := range timelineEvents { + if event.GetEvent() != "cross-referenced" { + continue + } + + source := event.GetSource() + if source == nil { + continue + } + + sourceIssue := source.GetIssue() + if sourceIssue == nil || sourceIssue.Number == nil { + continue + } + + refOwner, refRepo := item.owner, item.repo + if sourceIssue.RepositoryURL != nil { + parts := strings.Split(*sourceIssue.RepositoryURL, "/") + if len(parts) >= 2 { + refOwner = parts[len(parts)-2] + refRepo = parts[len(parts)-1] + } + } + + if gc.isRepoInaccessible(refOwner, refRepo) { + continue + } + + refNumber := *sourceIssue.Number + refKey := nodeKey(refOwner, refRepo, refNumber) + if refKey == key { + continue + } + + gc.mu.Lock() + gc.edges = append(gc.edges, GraphEdge{ + FromOwner: refOwner, + FromRepo: refRepo, + FromNumber: refNumber, + ToOwner: item.owner, + ToRepo: item.repo, + ToNumber: item.number, + Relation: RelationTypeRelated, + }) + gc.mu.Unlock() + + gc.crawlStats.crossRefsCrawled++ + result.newItems = append(result.newItems, &crawlItem{ + owner: refOwner, + repo: refRepo, + number: refNumber, + depth: item.depth + 1, + priority: PriorityCrossRef, + isAncestor: false, + isCrossRef: true, // Cross-refs only get one hop + }) + } + } + if timelineResp != nil { + _ = timelineResp.Body.Close() + } + } + + return result +} + +// edgeKey creates a unique key for an edge to enable deduplication +func edgeKey(e GraphEdge) string { + return fmt.Sprintf("%s/%s#%d->%s/%s#%d:%s", + strings.ToLower(e.FromOwner), strings.ToLower(e.FromRepo), e.FromNumber, + strings.ToLower(e.ToOwner), strings.ToLower(e.ToRepo), e.ToNumber, + e.Relation) +} + +// refocusTo changes the focus node after crawling has completed +// This allows shifting focus to an epic or batch that was discovered +func (gc *graphCrawler) refocusTo(owner, repo string, number int, source FocusSource) { + gc.mu.Lock() + defer gc.mu.Unlock() + + // Update focus + gc.focusOwner = owner + gc.focusRepo = repo + gc.focusNumber = number + gc.focusSource = source + + // Update IsFocus on nodes + for key, node := range gc.nodes { + node.IsFocus = key == nodeKey(owner, repo, number) + } +} + +// findBestFocus finds the best node to focus on based on the requested focus type. +// Priority order: +// 1. If original node is already the target type, use it +// 2. Walk up explicit parent hierarchy (sub-issues, closes/fixes) for target type +// 3. If looking for epic but only found batch in hierarchy, use the batch +// 4. Fallback: scan cross-referenced nodes for the target type (best effort) +// Returns owner, repo, number, and source of the best focus node. +func (gc *graphCrawler) findBestFocus(focusType string) (string, string, int, FocusSource) { + gc.mu.RLock() + defer gc.mu.RUnlock() + + originalKey := nodeKey(gc.focusOwner, gc.focusRepo, gc.focusNumber) + + // Determine target node type + var targetType NodeType + switch focusType { + case "epic": + targetType = NodeTypeEpic + case "batch": + targetType = NodeTypeBatch + default: + return gc.focusOwner, gc.focusRepo, gc.focusNumber, FocusSourceProvided + } + + // First, check if the original focus is already the target type + if node, exists := gc.nodes[originalKey]; exists && node.NodeType == targetType { + return gc.focusOwner, gc.focusRepo, gc.focusNumber, FocusSourceProvided + } + + // Walk up the ancestor chain (explicit hierarchy) to find the nearest target type + ancestors := gc.findAncestorsUnlocked(originalKey) + for _, ancestorKey := range ancestors { + if node, exists := gc.nodes[ancestorKey]; exists && node.NodeType == targetType { + return node.Owner, node.Repo, node.Number, FocusSourceHierarchy + } + } + + // If looking for epic and didn't find one in hierarchy, check for batch + if targetType == NodeTypeEpic { + for _, ancestorKey := range ancestors { + if node, exists := gc.nodes[ancestorKey]; exists && node.NodeType == NodeTypeBatch { + return node.Owner, node.Repo, node.Number, FocusSourceHierarchy + } + } + } + + // Fallback: scan cross-referenced nodes for the target type + // This handles cases where an epic is linked via mention but not sub-issue/closes + crossRefTarget := gc.findCrossReferencedNode(originalKey, targetType) + if crossRefTarget != nil { + return crossRefTarget.Owner, crossRefTarget.Repo, crossRefTarget.Number, FocusSourceCrossRef + } + + // If looking for epic via cross-ref, also accept a batch as fallback + if targetType == NodeTypeEpic { + crossRefBatch := gc.findCrossReferencedNode(originalKey, NodeTypeBatch) + if crossRefBatch != nil { + return crossRefBatch.Owner, crossRefBatch.Repo, crossRefBatch.Number, FocusSourceCrossRef + } + } + + // No suitable focus found, keep original + return gc.focusOwner, gc.focusRepo, gc.focusNumber, FocusSourceProvided +} + +// findCrossReferencedNode finds a node of the target type that is cross-referenced +// from the original node (via RelationTypeRelated edges), including checking the +// ancestors of cross-referenced nodes to find parent epics/batches. +func (gc *graphCrawler) findCrossReferencedNode(fromKey string, targetType NodeType) *GraphNode { + // Parse the fromKey to get owner/repo/number + fromNode := gc.nodes[fromKey] + if fromNode == nil { + return nil + } + + // Collect all cross-referenced nodes first + crossRefKeys := make([]string, 0) + for _, edge := range gc.edges { + // Check edges where this node is involved in a related (cross-ref) relationship + if edge.Relation != RelationTypeRelated { + continue + } + + // Determine if this edge connects to our node and get the other end + var refKey string + isFrom := strings.EqualFold(edge.FromOwner, fromNode.Owner) && + strings.EqualFold(edge.FromRepo, fromNode.Repo) && + edge.FromNumber == fromNode.Number + isTo := strings.EqualFold(edge.ToOwner, fromNode.Owner) && + strings.EqualFold(edge.ToRepo, fromNode.Repo) && + edge.ToNumber == fromNode.Number + + switch { + case isFrom: + refKey = nodeKey(edge.ToOwner, edge.ToRepo, edge.ToNumber) + case isTo: + refKey = nodeKey(edge.FromOwner, edge.FromRepo, edge.FromNumber) + default: + continue + } + + crossRefKeys = append(crossRefKeys, refKey) + } + + // First pass: check if any directly cross-referenced node is the target type + for _, refKey := range crossRefKeys { + if node, exists := gc.nodes[refKey]; exists && node.NodeType == targetType { + return node + } + } + + // Second pass: check ancestors of cross-referenced nodes for the target type + // This handles the case where e.g., PR #461 is cross-ref'd by task #886, + // and #886's parent batch #871 is what we're looking for + for _, refKey := range crossRefKeys { + ancestors := gc.findAncestorsUnlocked(refKey) + for _, ancestorKey := range ancestors { + if node, exists := gc.nodes[ancestorKey]; exists && node.NodeType == targetType { + return node + } + } + } + + return nil +} + +// findAncestorsUnlocked finds all ancestors of a node (caller must hold lock) +func (gc *graphCrawler) findAncestorsUnlocked(key string) []string { + ancestors := make([]string, 0) + seen := make(map[string]bool) + current := key + + for { + parentKey, exists := gc.parentMap[current] + if !exists || seen[parentKey] { + break + } + seen[parentKey] = true + ancestors = append(ancestors, parentKey) + current = parentKey + } + + return ancestors +} + +// buildGraph constructs the final IssueGraph +func (gc *graphCrawler) buildGraph() *IssueGraph { + gc.mu.RLock() + defer gc.mu.RUnlock() + + // Convert nodes map to slice + nodes := make([]GraphNode, 0, len(gc.nodes)) + for _, node := range gc.nodes { + nodes = append(nodes, *node) + } + + // Sort nodes by depth, then by number + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].Depth != nodes[j].Depth { + return nodes[i].Depth < nodes[j].Depth + } + return nodes[i].Number < nodes[j].Number + }) + + // Deduplicate edges + seenEdges := make(map[string]bool) + uniqueEdges := make([]GraphEdge, 0, len(gc.edges)) + for _, edge := range gc.edges { + key := edgeKey(edge) + if !seenEdges[key] { + seenEdges[key] = true + uniqueEdges = append(uniqueEdges, edge) + } + } + + graph := &IssueGraph{ + FocusOwner: gc.focusOwner, + FocusRepo: gc.focusRepo, + FocusNumber: gc.focusNumber, + Nodes: nodes, + Edges: uniqueEdges, + Summary: gc.generateSummary(), + } + + // Add crawl summary if verbose mode + if gc.verbose { + graph.CrawlSummary = gc.formatCrawlStats() + } + + return graph +} + +// formatCrawlStats formats crawl statistics for verbose output +func (gc *graphCrawler) formatCrawlStats() string { + var sb strings.Builder + sb.WriteString("CRAWL STATISTICS\n") + sb.WriteString("================\n") + fmt.Fprintf(&sb, "Nodes fetched: %d\n", gc.crawlStats.nodesFetched) + fmt.Fprintf(&sb, "Nodes skipped (already visited): %d\n", gc.crawlStats.nodesVisited) + fmt.Fprintf(&sb, "Max depth reached: %d (limit: %d)\n", gc.crawlStats.depthReached, MaxGraphDepth) + fmt.Fprintf(&sb, "Sub-issues crawled: %d\n", gc.crawlStats.subIssuesCrawled) + fmt.Fprintf(&sb, "Tasklist refs crawled: %d\n", gc.crawlStats.tasklistRefsCrawled) + fmt.Fprintf(&sb, "Timelines checked: %d\n", gc.crawlStats.timelinesCrawled) + fmt.Fprintf(&sb, "Cross-refs found: %d\n", gc.crawlStats.crossRefsCrawled) + fmt.Fprintf(&sb, "Repos accessed: %d\n", len(gc.crawlStats.reposAccessed)) + for repo := range gc.crawlStats.reposAccessed { + fmt.Fprintf(&sb, " - %s\n", repo) + } + if gc.crawlStats.rateLimitHits > 0 { + fmt.Fprintf(&sb, "⚠️ Rate limit backoffs: %d\n", gc.crawlStats.rateLimitHits) + } + if gc.crawlStats.timedOut { + sb.WriteString("⚠️ Crawl timed out - results may be incomplete\n") + } + return sb.String() +} + +// writeFocusShiftInfo writes information about focus shifting to the summary +func (gc *graphCrawler) writeFocusShiftInfo(sb *strings.Builder, focusNode *GraphNode) { + originalRef := formatNodeRef(gc.originalOwner, gc.originalRepo, gc.originalNumber, gc.focusOwner, gc.focusRepo) + + // Case 1: Focus was successfully shifted + if gc.focusSource != FocusSourceProvided { + switch gc.focusSource { + case FocusSourceHierarchy: + fmt.Fprintf(sb, "Focus shifted: from %s via sub-issue/closes hierarchy\n", originalRef) + case FocusSourceCrossRef: + fmt.Fprintf(sb, "Focus shifted: from %s via cross-reference (found closest matching %s - verify this is the correct parent)\n", + originalRef, focusNode.NodeType) + } + return + } + + // Case 2: Focus shift was requested but no suitable target found + if gc.focusRequested != "" { + // Check if the current focus already matches what was requested + requestedType := NodeType(gc.focusRequested) + if focusNode.NodeType == requestedType { + return // Already the right type, no message needed + } + + // Focus shift failed - provide helpful suggestions + fmt.Fprintf(sb, "No %s found: searched hierarchy and cross-references from %s\n", + gc.focusRequested, originalRef) + sb.WriteString("Suggestions:\n") + fmt.Fprintf(sb, " 1. Provide a link: if you know the %s, share owner/repo#number\n", gc.focusRequested) + fmt.Fprintf(sb, " 2. Add a link: reference the %s in the issue body using 'Part of owner/repo#N'\n", gc.focusRequested) + fmt.Fprintf(sb, " 3. Create an %s: use issue_write to create a new tracking issue\n", gc.focusRequested) + } +} + +// generateSummary creates a natural language summary of the graph +func (gc *graphCrawler) generateSummary() string { + focusKey := nodeKey(gc.focusOwner, gc.focusRepo, gc.focusNumber) + focusNode := gc.nodes[focusKey] + if focusNode == nil { + return "Unable to fetch the requested issue or pull request." + } + + var sb strings.Builder + + // Focus node info - include cross-repo reference if different from original + focusRef := fmt.Sprintf("#%d", gc.focusNumber) + if gc.focusOwner != gc.originalOwner || gc.focusRepo != gc.originalRepo { + focusRef = fmt.Sprintf("%s/%s#%d", gc.focusOwner, gc.focusRepo, gc.focusNumber) + } + sb.WriteString(fmt.Sprintf("Focus: %s (%s) \"%s\"\n", + focusRef, focusNode.NodeType, focusNode.Title)) + + // Show state with reason if available + stateStr := focusNode.State + if focusNode.StateReason != "" && focusNode.StateReason != focusNode.State { + stateStr = fmt.Sprintf("%s (%s)", focusNode.State, focusNode.StateReason) + } + sb.WriteString(fmt.Sprintf("State: %s\n", stateStr)) + + // Handle focus shift messaging + gc.writeFocusShiftInfo(&sb, focusNode) + + // Find hierarchy path (ancestors) + ancestors := gc.findAncestors(focusKey) + if len(ancestors) > 0 { + sb.WriteString("Hierarchy: ") + for i := len(ancestors) - 1; i >= 0; i-- { + node := gc.nodes[ancestors[i]] + if node != nil { + if strings.EqualFold(node.Owner, gc.focusOwner) && strings.EqualFold(node.Repo, gc.focusRepo) { + sb.WriteString(fmt.Sprintf("#%d (%s)", node.Number, node.NodeType)) + } else { + sb.WriteString(fmt.Sprintf("%s/%s#%d (%s)", node.Owner, node.Repo, node.Number, node.NodeType)) + } + sb.WriteString(" → ") + } + } + sb.WriteString(fmt.Sprintf("#%d (%s)\n", + gc.focusNumber, focusNode.NodeType)) + } + + // Find children of focus node + childCount := 0 + for _, edge := range gc.edges { + if strings.EqualFold(edge.FromOwner, gc.focusOwner) && strings.EqualFold(edge.FromRepo, gc.focusRepo) && + edge.FromNumber == gc.focusNumber && edge.Relation == RelationTypeChild { + childCount++ + } + } + if childCount > 0 { + sb.WriteString(fmt.Sprintf("Direct children: %d\n", childCount)) + } + + // Count siblings (same parent) + if parentKey, exists := gc.parentMap[focusKey]; exists { + siblingCount := 0 + for childKey, pKey := range gc.parentMap { + if pKey == parentKey && childKey != focusKey { + siblingCount++ + } + } + if siblingCount > 0 { + sb.WriteString(fmt.Sprintf("Siblings (same parent): %d\n", siblingCount)) + } + } + + sb.WriteString("\n") + + // Count nodes by type + epicCount, batchCount, taskCount, prCount := 0, 0, 0, 0 + for _, node := range gc.nodes { + switch node.NodeType { + case NodeTypeEpic: + epicCount++ + case NodeTypeBatch: + batchCount++ + case NodeTypeTask: + taskCount++ + case NodeTypePR: + prCount++ + } + } + + sb.WriteString(fmt.Sprintf("Graph contains %d nodes: ", len(gc.nodes))) + parts := make([]string, 0) + if epicCount > 0 { + parts = append(parts, fmt.Sprintf("%d epic(s)", epicCount)) + } + if batchCount > 0 { + parts = append(parts, fmt.Sprintf("%d batch issue(s)", batchCount)) + } + if taskCount > 0 { + parts = append(parts, fmt.Sprintf("%d task(s)", taskCount)) + } + if prCount > 0 { + parts = append(parts, fmt.Sprintf("%d PR(s)", prCount)) + } + sb.WriteString(strings.Join(parts, ", ")) + sb.WriteString("\n") + + return sb.String() +} + +// findAncestors returns all ancestors (parents, grandparents, etc.) of a node. +// Called after crawling is complete, so parentMap is stable and no lock needed. +func (gc *graphCrawler) findAncestors(key string) []string { + return gc.findAncestorsUnlocked(key) +} + +// formatNodeRef formats a node reference, using short form (#123) for same-repo +func formatNodeRef(owner, repo string, number int, focusOwner, focusRepo string) string { + if strings.EqualFold(owner, focusOwner) && strings.EqualFold(repo, focusRepo) { + return fmt.Sprintf("#%d", number) + } + return fmt.Sprintf("%s/%s#%d", owner, repo, number) +} + +// formatGraphOutput formats the graph in a human-readable format optimized for LLMs +func formatGraphOutput(graph *IssueGraph) string { + var sb strings.Builder + + // Summary section + sb.WriteString("GRAPH SUMMARY\n") + sb.WriteString("=============\n") + sb.WriteString(graph.Summary) + + // Project info for focus node (if available) + if len(graph.FocusProject) > 0 { + sb.WriteString("Projects: ") + projectParts := make([]string, 0, len(graph.FocusProject)) + for _, p := range graph.FocusProject { + if p.Status != "" { + projectParts = append(projectParts, fmt.Sprintf("%s [%s]", p.ProjectTitle, p.Status)) + } else { + projectParts = append(projectParts, p.ProjectTitle) + } + } + sb.WriteString(strings.Join(projectParts, ", ")) + sb.WriteString("\n") + } + + sb.WriteString("\n") + + // Legend for node types + sb.WriteString("Node types: epic (large initiative), batch (has sub-issues), task (regular issue), pr (pull request)\n\n") + + // Nodes section + sb.WriteString(fmt.Sprintf("NODES (%d total)\n", len(graph.Nodes))) + sb.WriteString("===============\n") + for _, node := range graph.Nodes { + focusMarker := "" + if node.IsFocus { + focusMarker = " [FOCUS]" + } + nodeRef := formatNodeRef(node.Owner, node.Repo, node.Number, graph.FocusOwner, graph.FocusRepo) + // Format state with reason if available (e.g., "closed (completed)" or "merged") + stateStr := node.State + if node.StateReason != "" && node.StateReason != node.State { + stateStr = fmt.Sprintf("%s (%s)", node.State, node.StateReason) + } + sb.WriteString(fmt.Sprintf("%s|%s|%s|%s%s\n", + nodeRef, node.NodeType, stateStr, node.Title, focusMarker)) + if node.BodyPreview != "" { + sb.WriteString(fmt.Sprintf(" Preview: %s\n", node.BodyPreview)) + } + if node.StatusUpdate != "" { + sb.WriteString(fmt.Sprintf(" Status: %s\n", node.StatusUpdate)) + } + // Display tasklist items for batch/epic issues + if len(node.TasklistItems) > 0 { + completedCount := 0 + for _, item := range node.TasklistItems { + if item.Completed { + completedCount++ + } + } + sb.WriteString(fmt.Sprintf(" Tasklist (%d/%d completed):\n", completedCount, len(node.TasklistItems))) + for _, item := range node.TasklistItems { + checkbox := "[ ]" + if item.Completed { + checkbox = "[x]" + } + // Format linked reference if present + linkedInfo := "" + if item.LinkedRef != nil { + linkedRef := formatNodeRef(item.LinkedRef.Owner, item.LinkedRef.Repo, item.LinkedRef.Number, graph.FocusOwner, graph.FocusRepo) + linkedInfo = fmt.Sprintf(" → %s", linkedRef) + } + // Truncate long text + text := item.Text + if len(text) > 80 { + text = text[:77] + "..." + } + sb.WriteString(fmt.Sprintf(" %s %s%s\n", checkbox, text, linkedInfo)) + } + } + } + + // Edges section - parent/child relationships (sub-issues, closes/fixes) + sb.WriteString("\nSUB-ISSUES (parent → child)\n") + sb.WriteString("===========================\n") + parentChildEdges := make([]GraphEdge, 0) + relatedEdges := make([]GraphEdge, 0) + for _, edge := range graph.Edges { + switch edge.Relation { + case RelationTypeChild: + parentChildEdges = append(parentChildEdges, edge) + case RelationTypeParent: + // Parent edges: from closes ref, so ref is parent of from + // Reverse the direction for display: parent → child + parentChildEdges = append(parentChildEdges, GraphEdge{ + FromOwner: edge.ToOwner, + FromRepo: edge.ToRepo, + FromNumber: edge.ToNumber, + ToOwner: edge.FromOwner, + ToRepo: edge.FromRepo, + ToNumber: edge.FromNumber, + Relation: RelationTypeChild, + }) + case RelationTypeRelated: + relatedEdges = append(relatedEdges, edge) + } + } + + if len(parentChildEdges) == 0 { + sb.WriteString("(none)\n") + } else { + for _, edge := range parentChildEdges { + fromRef := formatNodeRef(edge.FromOwner, edge.FromRepo, edge.FromNumber, graph.FocusOwner, graph.FocusRepo) + toRef := formatNodeRef(edge.ToOwner, edge.ToRepo, edge.ToNumber, graph.FocusOwner, graph.FocusRepo) + sb.WriteString(fmt.Sprintf("%s → %s\n", fromRef, toRef)) + } + } + + // Related section (cross-references from timeline, body mentions) + sb.WriteString("\nCROSS-REFERENCES (mentioned/referenced)\n") + sb.WriteString("=======================================\n") + if len(relatedEdges) == 0 { + sb.WriteString("(none)\n") + } else { + // Build a lookup map for nodes + nodeMap := make(map[string]*GraphNode) + for i := range graph.Nodes { + key := nodeKey(graph.Nodes[i].Owner, graph.Nodes[i].Repo, graph.Nodes[i].Number) + nodeMap[key] = &graph.Nodes[i] + } + + for _, edge := range relatedEdges { + fromRef := formatNodeRef(edge.FromOwner, edge.FromRepo, edge.FromNumber, graph.FocusOwner, graph.FocusRepo) + toRef := formatNodeRef(edge.ToOwner, edge.ToRepo, edge.ToNumber, graph.FocusOwner, graph.FocusRepo) + + // Check if from node is a PR and include its status + fromKey := nodeKey(edge.FromOwner, edge.FromRepo, edge.FromNumber) + if fromNode, ok := nodeMap[fromKey]; ok && fromNode.NodeType == NodeTypePR { + status := fromNode.State + if fromNode.StateReason != "" && fromNode.StateReason != fromNode.State { + status = fromNode.StateReason + } + sb.WriteString(fmt.Sprintf("%s (%s) ↔ %s\n", fromRef, strings.ToUpper(status), toRef)) + } else { + sb.WriteString(fmt.Sprintf("%s ↔ %s\n", fromRef, toRef)) + } + } + } + + // Crawl summary (verbose mode only) + if graph.CrawlSummary != "" { + sb.WriteString("\n") + sb.WriteString(graph.CrawlSummary) + } + + return sb.String() +} + +// IssueRef contains owner/repo/number for an issue reference +type IssueRef struct { + Owner string + Repo string + Number int +} + +// IssueGraphQLInfo contains parent issue info fetched via GraphQL +type IssueGraphQLInfo struct { + Parent *IssueRef // Parent issue (if any) +} + +// fetchIssueGraphQLInfo fetches parent issue info via GraphQL +// This is lightweight - only fetches parent, not sub-issues or projects +func fetchIssueGraphQLInfo(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, number int) *IssueGraphQLInfo { + if gqlClient == nil { + return nil + } + + // Lightweight GraphQL query for parent only + var query struct { + Repository struct { + Issue struct { + // Parent issue (can be cross-repo) + Parent *struct { + Number githubv4.Int + Repository struct { + Owner struct { + Login githubv4.String + } + Name githubv4.String + } + } + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "number": githubv4.Int(int32(number)), //nolint:gosec // issue numbers are always small positive integers + } + + // Execute query with a short timeout + queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + if err := gqlClient.Query(queryCtx, &query, vars); err != nil { + // Silently ignore errors - this info is optional + return nil + } + + result := &IssueGraphQLInfo{} + + // Extract parent + if query.Repository.Issue.Parent != nil { + result.Parent = &IssueRef{ + Owner: string(query.Repository.Issue.Parent.Repository.Owner.Login), + Repo: string(query.Repository.Issue.Parent.Repository.Name), + Number: int(query.Repository.Issue.Parent.Number), + } + } + + return result +} + +// fetchProjectInfo fetches project info for an issue via GraphQL +// This is a separate, heavier query - only use for focus node +func fetchProjectInfo(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, number int) []ProjectInfo { + if gqlClient == nil { + return nil + } + + var query struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + Project struct { + Title githubv4.String + } + FieldValueByName struct { + SingleSelectValue struct { + Name githubv4.String + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + } `graphql:"fieldValueByName(name: \"Status\")"` + } + } `graphql:"projectItems(first: 10)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "number": githubv4.Int(int32(number)), //nolint:gosec // issue numbers are always small positive integers + } + + queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + if err := gqlClient.Query(queryCtx, &query, vars); err != nil { + return nil + } + + var projects []ProjectInfo + for _, node := range query.Repository.Issue.ProjectItems.Nodes { + title := string(node.Project.Title) + if title == "" { + continue + } + status := string(node.FieldValueByName.SingleSelectValue.Name) + projects = append(projects, ProjectInfo{ + ProjectTitle: title, + Status: status, + }) + } + + return projects +} + +// fetchPRProjects fetches project info for a PR (PRs don't have parent/sub-issues) +func fetchPRProjects(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, number int) []ProjectInfo { + if gqlClient == nil { + return nil + } + + var query struct { + Repository struct { + PullRequest struct { + ProjectItems struct { + Nodes []struct { + Project struct { + Title githubv4.String + } + FieldValueByName struct { + SingleSelectValue struct { + Name githubv4.String + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + } `graphql:"fieldValueByName(name: \"Status\")"` + } + } `graphql:"projectItems(first: 10)"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "number": githubv4.Int(int32(number)), //nolint:gosec // issue numbers are always small positive integers + } + + queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + if err := gqlClient.Query(queryCtx, &query, vars); err != nil { + return nil + } + + var projects []ProjectInfo + for _, node := range query.Repository.PullRequest.ProjectItems.Nodes { + title := string(node.Project.Title) + if title == "" { + continue + } + status := string(node.FieldValueByName.SingleSelectValue.Name) + projects = append(projects, ProjectInfo{ + ProjectTitle: title, + Status: status, + }) + } + + return projects +} + +// GetIssueGraph creates a tool to get a graph representation of issue/PR relationships +func GetIssueGraph(getClient GetClientFn, getGQLClient GetGQLClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("issue_graph", + mcp.WithDescription(t("TOOL_ISSUE_GRAPH_DESCRIPTION", `**PRIMARY TOOL FOR ISSUE/PR/PROJECT STATUS** - Returns the entire work hierarchy in ONE call. Use this FIRST before issue_read or other tools. + +FASTEST PATH TO PROJECT STATUS: One call returns all related issues, PRs, and their states - no need for multiple API calls. + +MUST USE THIS TOOL FIRST WHEN USER ASKS: +- "update on..." / "status of..." / "progress on..." / "tell me about..." +- "what's happening with..." / "how is... going" / "state of..." +- "project status" / "overall progress" / "how is the epic going" +- "epic" / "parent issue" / "sub-issues" / "blocking" / "depends on" +- ANY request for status on work that might span multiple issues + +DO NOT use issue_read or list_issues first for status questions - issue_graph returns everything in one call. + +Returns a comprehensive view including: +- Node types: epic (large initiatives), batch (parent issues), task (regular issues), pr (pull requests) +- Full hierarchy: epic → batch → task → PR relationships across the ENTIRE project +- Sub-issues and "closes/fixes" references +- Cross-references and related work +- Status updates extracted from issue bodies and comments +- Open/closed/merged state of ALL related items + +Use focus="epic" to automatically find and focus on the parent epic of any issue.`)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ISSUE_GRAPH_USER_TITLE", "Get issue relationship graph"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue or pull request number to build the graph from"), + ), + mcp.WithString("focus", + mcp.Description("Which node type to focus on: 'provided' (default) uses the specified issue/PR, 'epic' shifts focus to the nearest epic in the hierarchy, 'batch' shifts focus to the nearest batch/parent issue"), + mcp.Enum("provided", "epic", "batch"), + ), + mcp.WithBoolean("verbose", + mcp.Description("Include crawl statistics showing how the graph was traversed (nodes fetched, depth reached, repos accessed, etc.)"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + focusType, err := OptionalParam[string](request, "focus") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if focusType == "" { + focusType = "provided" + } + verbose, err := OptionalParam[bool](request, "verbose") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get GQL client for parent queries (optional, nil is ok) + var gqlClient *githubv4.Client + if getGQLClient != nil { + gqlClient, _ = getGQLClient(ctx) // ignore error, gqlClient will be nil + } + + // Add timeout to prevent runaway crawling + crawlCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // Create crawler and build graph + crawler := newGraphCrawler(client, gqlClient, cache, flags, owner, repo, issueNumber, verbose) + if err := crawler.crawl(crawlCtx); err != nil { + // If timeout, continue with partial results; otherwise fail + if crawlCtx.Err() != context.DeadlineExceeded { + return nil, fmt.Errorf("failed to crawl issue graph: %w", err) + } + } + + // Refocus if requested + if focusType != "provided" { + crawler.focusRequested = focusType + newOwner, newRepo, newNumber, source := crawler.findBestFocus(focusType) + if newOwner != owner || newRepo != repo || newNumber != issueNumber { + crawler.refocusTo(newOwner, newRepo, newNumber, source) + } + } + + graph := crawler.buildGraph() + + // Fetch project info for the focus node (optional, best-effort) + if gqlClient != nil { + // Determine if focus node is a PR + focusKey := nodeKey(graph.FocusOwner, graph.FocusRepo, graph.FocusNumber) + isPR := false + crawler.mu.RLock() + if focusNode, exists := crawler.nodes[focusKey]; exists { + isPR = focusNode.NodeType == NodeTypePR + } + crawler.mu.RUnlock() + + // Fetch project info for focus node (separate query, only for focus) + if isPR { + graph.FocusProject = fetchPRProjects(ctx, gqlClient, graph.FocusOwner, graph.FocusRepo, graph.FocusNumber) + } else { + graph.FocusProject = fetchProjectInfo(ctx, gqlClient, graph.FocusOwner, graph.FocusRepo, graph.FocusNumber) + } + } + + // Format for LLM consumption - text format is token-efficient and sufficient + formattedOutput := formatGraphOutput(graph) + + return mcp.NewToolResultText(formattedOutput), nil + } +} diff --git a/pkg/github/issue_graph_prompts.go b/pkg/github/issue_graph_prompts.go new file mode 100644 index 000000000..58981a88d --- /dev/null +++ b/pkg/github/issue_graph_prompts.go @@ -0,0 +1,136 @@ +package github + +import ( + "context" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ConnectIssueGraphPrompt provides a guided workflow for finding and connecting missing relationships +// between issues and PRs in the issue graph +func ConnectIssueGraphPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { + return mcp.NewPrompt("ConnectIssueGraph", + mcp.WithPromptDescription(t("PROMPT_CONNECT_ISSUE_GRAPH_DESCRIPTION", "Find and connect missing relationships between issues and PRs in the issue graph")), + mcp.WithArgument("owner", mcp.ArgumentDescription("Repository owner"), mcp.RequiredArgument()), + mcp.WithArgument("repo", mcp.ArgumentDescription("Repository name"), mcp.RequiredArgument()), + mcp.WithArgument("issue_number", mcp.ArgumentDescription("Issue or PR number to analyze"), mcp.RequiredArgument()), + mcp.WithArgument("additional_repos", mcp.ArgumentDescription("Comma-separated list of additional owner/repo to search (e.g., 'github/copilot,microsoft/vscode')")), + mcp.WithArgument("known_links", mcp.ArgumentDescription("Comma-separated list of known related issue URLs that should be connected (e.g., epic links in other repos)")), + ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + owner := request.Params.Arguments["owner"] + repo := request.Params.Arguments["repo"] + issueNumber := request.Params.Arguments["issue_number"] + + additionalRepos := "" + if r, exists := request.Params.Arguments["additional_repos"]; exists { + additionalRepos = fmt.Sprintf("%v", r) + } + + knownLinks := "" + if k, exists := request.Params.Arguments["known_links"]; exists { + knownLinks = fmt.Sprintf("%v", k) + } + + systemPrompt := `You are a GitHub issue graph connection assistant. Your job is to find missing relationships between issues and PRs and help connect them properly. + +WORKFLOW: +1. First, use issue_graph tool on the specified issue/PR to see current relationships +2. Search for potentially related issues and PRs using search_issues and search_pull_requests +3. Identify missing connections (orphaned tasks, PRs without issue links, etc.) +4. For each missing connection, help the user add the appropriate reference +5. Verify the connections by running issue_graph again + +RELATIONSHIP TYPES TO LOOK FOR: +- Epic → Batch: Large initiatives broken into batches +- Batch → Task: Parent issues with sub-issues +- Task → PR: Issues with PRs that should "close" them +- Cross-repo: Epics/batches in different repos (e.g., planning repo vs implementation repo) + +SEARCH STRATEGIES: +- Search by keywords from the issue title +- Search by feature name or component +- Look for PRs that mention the issue number +- Check for issues with similar labels + +ADDING CONNECTIONS: +- PRs should reference issues with "Closes #123" or "Fixes #123" in body +- Cross-repo: "Closes owner/repo#123" +- Sub-issues can be added via the sub_issue_write tool +- Issue bodies can reference related work with "Related to #123" + +IMPORTANT: +- Ask the user before making any changes +- Cross-repo epics may need user input (they might not be searchable) +- Some relationships are intentionally loose - confirm with user` + + userPrompt := fmt.Sprintf(`I want to analyze and connect the issue graph for %s/%s#%s. + +Please: +1. Run issue_graph on this issue/PR to see current state +2. Search for related issues and PRs that should be connected +3. Identify any orphaned or missing relationships +4. Propose specific connections to add +5. After I approve changes, help me add the connections +6. Verify by running issue_graph again`, owner, repo, issueNumber) + + if additionalRepos != "" { + userPrompt += fmt.Sprintf(` + +Also search these additional repositories for related issues/PRs: +%s`, additionalRepos) + } + + if knownLinks != "" { + userPrompt += fmt.Sprintf(` + +These are known related issues that should be connected (e.g., epics in other repos): +%s + +Please verify these are properly referenced and suggest how to connect them if not.`, knownLinks) + } + + messages := []mcp.PromptMessage{ + { + Role: "user", + Content: mcp.NewTextContent(systemPrompt), + }, + { + Role: "user", + Content: mcp.NewTextContent(userPrompt), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf(`I'll help you analyze and connect the issue graph for %s/%s#%s. + +Let me start by running the issue_graph tool to see the current state of relationships: + +**Step 1: Analyze Current Graph** +I'll call issue_graph to see what connections already exist. + +**Step 2: Search for Missing Connections** +Then I'll search for: +- PRs that might close this issue but don't reference it +- Related issues that should be sub-issues or parent issues +- Cross-repo references that might be missing + +**Step 3: Propose Connections** +I'll list any missing relationships I find and propose how to connect them. + +**Step 4: Make Changes (with your approval)** +After you review, I'll help add the connections. + +**Step 5: Verify** +Finally, I'll run issue_graph again to confirm the connections. + +Let me start by getting the current issue graph...`, owner, repo, issueNumber)), + }, + } + + return &mcp.GetPromptResult{ + Messages: messages, + }, nil + } +} diff --git a/pkg/github/issue_graph_test.go b/pkg/github/issue_graph_test.go new file mode 100644 index 000000000..e58ef84b9 --- /dev/null +++ b/pkg/github/issue_graph_test.go @@ -0,0 +1,844 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v79/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetIssueGraph(t *testing.T) { + // Create mock client for tool definition verification + mockClient := github.NewClient(nil) + mockGQLClient := githubv4.NewClient(nil) + cache := stubRepoAccessCache(mockGQLClient, 15*time.Minute) + + tool, _ := GetIssueGraph( + stubGetClientFn(mockClient), + stubGetGQLClientFn(mockGQLClient), + cache, + translations.NullTranslationHelper, + stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + ) + + // Verify toolsnap + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + // Verify tool definition + assert.Equal(t, "issue_graph", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Verify read-only annotation + assert.NotNil(t, tool.Annotations) + assert.True(t, *tool.Annotations.ReadOnlyHint) +} + +func TestGetIssueGraph_SingleIssue(t *testing.T) { + // Mock issue data + mockIssue := &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue body"), + State: github.Ptr("open"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{ + {Name: github.Ptr("bug")}, + }, + } + + mockedHTTPClient := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockIssue, + ), + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + }), + ), + ) + + mockClient := github.NewClient(mockedHTTPClient) + mockGQLClient := githubv4.NewClient(nil) + cache := stubRepoAccessCache(mockGQLClient, 15*time.Minute) + + _, handler := GetIssueGraph( + stubGetClientFn(mockClient), + stubGetGQLClientFn(mockGQLClient), + cache, + translations.NullTranslationHelper, + stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + ) + + request := createMCPRequest(map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + "issue_number": float64(42), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + + // Check the result contains expected content + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "GRAPH SUMMARY") + assert.Contains(t, textContent.Text, "#42") + assert.Contains(t, textContent.Text, "Test Issue") + assert.Contains(t, textContent.Text, "task") // Should be classified as task +} + +func TestExtractIssueReferences(t *testing.T) { + tests := []struct { + name string + text string + defaultOwner string + defaultRepo string + expected []IssueReference + }{ + { + name: "same repo reference", + text: "This fixes #123", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "owner", Repo: "repo", Number: 123, IsParent: true}, + }, + }, + { + name: "cross repo reference", + text: "Related to other/repo#456", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "other", Repo: "repo", Number: 456, IsParent: false}, + }, + }, + { + name: "multiple references", + text: "Closes #1, related to #2 and other/project#3", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "owner", Repo: "repo", Number: 1, IsParent: true}, + {Owner: "other", Repo: "project", Number: 3, IsParent: false}, + {Owner: "owner", Repo: "repo", Number: 2, IsParent: false}, + }, + }, + { + name: "no references", + text: "This is just a comment", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{}, + }, + { + name: "fixes keyword", + text: "Fixes #100", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "owner", Repo: "repo", Number: 100, IsParent: true}, + }, + }, + { + name: "resolves keyword", + text: "Resolves #200", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "owner", Repo: "repo", Number: 200, IsParent: true}, + }, + }, + { + name: "full github issue URL", + text: "Related to https://github.com/other/project/issues/789", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "other", Repo: "project", Number: 789, IsParent: false}, + }, + }, + { + name: "full github PR URL", + text: "See https://github.com/other/project/pull/456 for the fix", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "other", Repo: "project", Number: 456, IsParent: false}, + }, + }, + { + name: "mixed URL and shorthand references", + text: "Fixes #100, see https://github.com/other/repo/issues/200 and other/project#300", + defaultOwner: "owner", + defaultRepo: "repo", + expected: []IssueReference{ + {Owner: "owner", Repo: "repo", Number: 100, IsParent: true}, + {Owner: "other", Repo: "project", Number: 300, IsParent: false}, + {Owner: "other", Repo: "repo", Number: 200, IsParent: false}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + refs := extractIssueReferences(tc.text, tc.defaultOwner, tc.defaultRepo) + assert.Equal(t, len(tc.expected), len(refs)) + for i, expected := range tc.expected { + if i < len(refs) { + assert.Equal(t, expected.Owner, refs[i].Owner) + assert.Equal(t, expected.Repo, refs[i].Repo) + assert.Equal(t, expected.Number, refs[i].Number) + assert.Equal(t, expected.IsParent, refs[i].IsParent) + } + } + }) + } +} + +func TestSanitizeBodyForGraph(t *testing.T) { + tests := []struct { + name string + body string + maxLines int + maxLineLen int + expected string + }{ + { + name: "removes URLs", + body: "Check https://example.com for details", + maxLines: 3, + maxLineLen: 100, + expected: "Check [link] for details", + }, + { + name: "removes markdown images", + body: "See ![image](https://example.com/img.png) here", + maxLines: 3, + maxLineLen: 100, + expected: "See [image] here", + }, + { + name: "truncates long lines", + body: "This is a very long line that should be truncated because it exceeds the maximum length allowed", + maxLines: 3, + maxLineLen: 30, + expected: "This is a very long line th...", + }, + { + name: "limits number of lines", + body: "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", + maxLines: 2, + maxLineLen: 100, + expected: "Line 1 | Line 2", + }, + { + name: "empty body", + body: "", + maxLines: 3, + maxLineLen: 100, + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := sanitizeBodyForGraph(tc.body, tc.maxLines, tc.maxLineLen) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestClassifyNode(t *testing.T) { + tests := []struct { + name string + isPR bool + labels []string + title string + issueType string + hasSubIssues bool + expected NodeType + }{ + { + name: "pull request", + isPR: true, + labels: []string{}, + title: "Fix bug", + expected: NodeTypePR, + }, + { + name: "epic by label", + isPR: false, + labels: []string{"type: epic", "priority: high"}, + title: "Project X", + expected: NodeTypeEpic, + }, + { + name: "epic by title", + isPR: false, + labels: []string{}, + title: "[Epic] Major refactoring", + expected: NodeTypeEpic, + }, + { + name: "epic by issue type", + isPR: false, + labels: []string{}, + title: "Major initiative", + issueType: "Epic", + expected: NodeTypeEpic, + }, + { + name: "batch issue", + isPR: false, + labels: []string{}, + title: "Backend improvements", + hasSubIssues: true, + expected: NodeTypeBatch, + }, + { + name: "regular task", + isPR: false, + labels: []string{"bug"}, + title: "Fix login issue", + expected: NodeTypeTask, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := classifyNode(tc.isPR, tc.labels, tc.title, tc.issueType, tc.hasSubIssues) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestFormatNodeRef(t *testing.T) { + tests := []struct { + name string + owner string + repo string + number int + focusOwner string + focusRepo string + expected string + }{ + { + name: "same repo uses short form", + owner: "owner", + repo: "repo", + number: 123, + focusOwner: "owner", + focusRepo: "repo", + expected: "#123", + }, + { + name: "cross repo uses full form", + owner: "other", + repo: "project", + number: 456, + focusOwner: "owner", + focusRepo: "repo", + expected: "other/project#456", + }, + { + name: "case insensitive match", + owner: "Owner", + repo: "Repo", + number: 789, + focusOwner: "owner", + focusRepo: "repo", + expected: "#789", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := formatNodeRef(tc.owner, tc.repo, tc.number, tc.focusOwner, tc.focusRepo) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestFormatGraphOutput(t *testing.T) { + graph := &IssueGraph{ + FocusOwner: "owner", + FocusRepo: "repo", + FocusNumber: 42, + Summary: "Focus: #42 (task) \"Test Issue\"\nState: open\n", + Nodes: []GraphNode{ + { + Owner: "owner", + Repo: "repo", + Number: 42, + NodeType: NodeTypeTask, + State: "open", + Title: "Test Issue", + BodyPreview: "This is a test", + Depth: 0, + IsFocus: true, + }, + }, + Edges: []GraphEdge{}, + } + + result := formatGraphOutput(graph) + + assert.Contains(t, result, "GRAPH SUMMARY") + assert.Contains(t, result, "#42|task|open|Test Issue [FOCUS]") + assert.Contains(t, result, "Preview: This is a test") + assert.Contains(t, result, "NODES (1 total)") +} + +func TestIssueGraphWithSubIssues(t *testing.T) { + // Mock parent issue + parentIssue := &github.Issue{ + Number: github.Ptr(100), + Title: github.Ptr("Parent Issue"), + Body: github.Ptr("Parent body"), + State: github.Ptr("open"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{}, + } + + // Mock sub-issues response + subIssuesJSON := `[{"number": 101, "title": "Sub Issue 1"}, {"number": 102, "title": "Sub Issue 2"}]` + + // Mock sub-issue details + subIssue1 := &github.Issue{ + Number: github.Ptr(101), + Title: github.Ptr("Sub Issue 1"), + Body: github.Ptr("Sub issue 1 body"), + State: github.Ptr("open"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + subIssue2 := &github.Issue{ + Number: github.Ptr(102), + Title: github.Ptr("Sub Issue 2"), + Body: github.Ptr("Sub issue 2 body"), + State: github.Ptr("open"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + requestCount := int32(0) + mockedHTTPClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Determine which issue is being requested based on URL + path := r.URL.Path + var issue *github.Issue + switch { + case strings.Contains(path, "/101"): + issue = subIssue1 + case strings.Contains(path, "/102"): + issue = subIssue2 + default: + issue = parentIssue + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(issue) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requestCount, 1) + // Return sub-issues only for the parent issue + path := r.URL.Path + if strings.Contains(path, "/100/") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(subIssuesJSON)) + } else { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + } + }), + ), + ) + + mockClient := github.NewClient(mockedHTTPClient) + mockGQLClient := githubv4.NewClient(nil) + cache := stubRepoAccessCache(mockGQLClient, 15*time.Minute) + + _, handler := GetIssueGraph( + stubGetClientFn(mockClient), + stubGetGQLClientFn(mockGQLClient), + cache, + translations.NullTranslationHelper, + stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + ) + + request := createMCPRequest(map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + "issue_number": float64(100), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + + // Check the result contains parent and relationships + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "#100") + assert.Contains(t, textContent.Text, "Parent Issue") +} + +func TestFindCrossReferencedNodeWithAncestors(t *testing.T) { + // Test scenario: PR #461 is cross-referenced by task #886, and #886's parent is batch #871 + // When searching for batch from #461, we should find #871 via the ancestor chain + + // Create a mock crawler with the graph structure + crawler := &graphCrawler{ + focusOwner: "github", + focusRepo: "github-mcp-server-remote", + focusNumber: 461, + originalOwner: "github", + originalRepo: "github-mcp-server-remote", + originalNumber: 461, + nodes: make(map[string]*GraphNode), + edges: make([]GraphEdge, 0), + parentMap: make(map[string]string), + } + + // Add nodes: PR #461 (focus), task #886, batch #871 + prNode := &GraphNode{ + Owner: "github", + Repo: "github-mcp-server-remote", + Number: 461, + NodeType: NodeTypePR, + State: "merged", + Title: "Implement initial scope challenge", + } + taskNode := &GraphNode{ + Owner: "github", + Repo: "copilot-agent-services", + Number: 886, + NodeType: NodeTypeTask, + State: "closed", + Title: "Add initial scope challenge to MCP remote server", + } + batchNode := &GraphNode{ + Owner: "github", + Repo: "copilot-agent-services", + Number: 871, + NodeType: NodeTypeBatch, + State: "open", + Title: "[Batch] Support scope challenge in remote MCP", + } + + crawler.nodes[nodeKey("github", "github-mcp-server-remote", 461)] = prNode + crawler.nodes[nodeKey("github", "copilot-agent-services", 886)] = taskNode + crawler.nodes[nodeKey("github", "copilot-agent-services", 871)] = batchNode + + // Add edge: task #886 cross-references PR #461 + crawler.edges = append(crawler.edges, GraphEdge{ + FromOwner: "github", + FromRepo: "copilot-agent-services", + FromNumber: 886, + ToOwner: "github", + ToRepo: "github-mcp-server-remote", + ToNumber: 461, + Relation: RelationTypeRelated, + }) + + // Add parent relationship: batch #871 is parent of task #886 + taskKey := nodeKey("github", "copilot-agent-services", 886) + batchKey := nodeKey("github", "copilot-agent-services", 871) + crawler.parentMap[taskKey] = batchKey + + // Test: findCrossReferencedNode should find batch #871 by traversing ancestors of #886 + prKey := nodeKey("github", "github-mcp-server-remote", 461) + foundNode := crawler.findCrossReferencedNode(prKey, NodeTypeBatch) + + require.NotNil(t, foundNode, "Should find batch node via cross-ref ancestor traversal") + assert.Equal(t, "github", foundNode.Owner) + assert.Equal(t, "copilot-agent-services", foundNode.Repo) + assert.Equal(t, 871, foundNode.Number) + assert.Equal(t, NodeTypeBatch, foundNode.NodeType) +} + +func TestFindBestFocusCrossRepoAncestors(t *testing.T) { + // Similar test but through the findBestFocus interface + + crawler := &graphCrawler{ + focusOwner: "github", + focusRepo: "github-mcp-server-remote", + focusNumber: 461, + originalOwner: "github", + originalRepo: "github-mcp-server-remote", + originalNumber: 461, + nodes: make(map[string]*GraphNode), + edges: make([]GraphEdge, 0), + parentMap: make(map[string]string), + } + + // Add nodes + crawler.nodes[nodeKey("github", "github-mcp-server-remote", 461)] = &GraphNode{ + Owner: "github", + Repo: "github-mcp-server-remote", + Number: 461, + NodeType: NodeTypePR, + } + crawler.nodes[nodeKey("github", "copilot-agent-services", 886)] = &GraphNode{ + Owner: "github", + Repo: "copilot-agent-services", + Number: 886, + NodeType: NodeTypeTask, + } + crawler.nodes[nodeKey("github", "copilot-agent-services", 871)] = &GraphNode{ + Owner: "github", + Repo: "copilot-agent-services", + Number: 871, + NodeType: NodeTypeBatch, + } + + // Add cross-reference edge + crawler.edges = append(crawler.edges, GraphEdge{ + FromOwner: "github", + FromRepo: "copilot-agent-services", + FromNumber: 886, + ToOwner: "github", + ToRepo: "github-mcp-server-remote", + ToNumber: 461, + Relation: RelationTypeRelated, + }) + + // Add parent relationship + crawler.parentMap[nodeKey("github", "copilot-agent-services", 886)] = nodeKey("github", "copilot-agent-services", 871) + + // Test findBestFocus with "batch" should find #871 + owner, repo, number, source := crawler.findBestFocus("batch") + + assert.Equal(t, "github", owner) + assert.Equal(t, "copilot-agent-services", repo) + assert.Equal(t, 871, number) + assert.Equal(t, FocusSourceCrossRef, source) +} + +func TestExtractTasklistItems(t *testing.T) { + tests := []struct { + name string + body string + defaultOwner string + defaultRepo string + expected []TasklistItem + }{ + { + name: "basic unchecked items", + body: `- [ ] Task one +- [ ] Task two +- [ ] Task three`, + defaultOwner: "owner", + defaultRepo: "repo", + expected: []TasklistItem{ + {Text: "Task one", Completed: false}, + {Text: "Task two", Completed: false}, + {Text: "Task three", Completed: false}, + }, + }, + { + name: "mixed checked and unchecked", + body: `- [x] Completed task +- [ ] Pending task +- [X] Another completed`, + defaultOwner: "owner", + defaultRepo: "repo", + expected: []TasklistItem{ + {Text: "Completed task", Completed: true}, + {Text: "Pending task", Completed: false}, + {Text: "Another completed", Completed: true}, + }, + }, + { + name: "items with issue references", + body: `- [ ] Implement feature #123 +- [x] Fix bug in other/repo#456 +- [ ] Review https://github.com/owner/repo/pull/789`, + defaultOwner: "owner", + defaultRepo: "repo", + expected: []TasklistItem{ + { + Text: "Implement feature #123", + Completed: false, + LinkedRef: &IssueReference{Owner: "owner", Repo: "repo", Number: 123}, + }, + { + Text: "Fix bug in other/repo#456", + Completed: true, + LinkedRef: &IssueReference{Owner: "other", Repo: "repo", Number: 456}, + }, + { + Text: "Review https://github.com/owner/repo/pull/789", + Completed: false, + LinkedRef: &IssueReference{Owner: "owner", Repo: "repo", Number: 789}, + }, + }, + }, + { + name: "asterisk syntax", + body: `* [ ] Task with asterisk +* [x] Completed asterisk task`, + defaultOwner: "owner", + defaultRepo: "repo", + expected: []TasklistItem{ + {Text: "Task with asterisk", Completed: false}, + {Text: "Completed asterisk task", Completed: true}, + }, + }, + { + name: "indented items", + body: ` - [ ] Indented task + - [x] More indented task`, + defaultOwner: "owner", + defaultRepo: "repo", + expected: []TasklistItem{ + {Text: "Indented task", Completed: false}, + {Text: "More indented task", Completed: true}, + }, + }, + { + name: "no tasklist items", + body: "This is just a regular body without any tasklist items.", + defaultOwner: "owner", + defaultRepo: "repo", + expected: nil, + }, + { + name: "empty body", + body: "", + defaultOwner: "owner", + defaultRepo: "repo", + expected: nil, + }, + { + name: "mixed content with tasklist", + body: `## Tasks + +Some description here. + +- [ ] First task +- [x] Second task + +More text after the list.`, + defaultOwner: "owner", + defaultRepo: "repo", + expected: []TasklistItem{ + {Text: "First task", Completed: false}, + {Text: "Second task", Completed: true}, + }, + }, + { + name: "real world example - scope challenge", + body: `## Tasks + +- [ ] Spike OAuth scope challenge escalation (in collaboration with Tyler for VS Code) +- [ ] Reduce scopes initially requested to match VS Code's +- [x] Build production ready scope challenge support in remote server +- [ ] Release scope challenge +- [ ] Work with VS Code team to establish if included by default has any other blockers`, + defaultOwner: "github", + defaultRepo: "copilot-agent-services", + expected: []TasklistItem{ + {Text: "Spike OAuth scope challenge escalation (in collaboration with Tyler for VS Code)", Completed: false}, + {Text: "Reduce scopes initially requested to match VS Code's", Completed: false}, + {Text: "Build production ready scope challenge support in remote server", Completed: true}, + {Text: "Release scope challenge", Completed: false}, + {Text: "Work with VS Code team to establish if included by default has any other blockers", Completed: false}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := extractTasklistItems(tc.body, tc.defaultOwner, tc.defaultRepo) + + if tc.expected == nil { + assert.Nil(t, result) + return + } + + require.Equal(t, len(tc.expected), len(result), "number of items should match") + + for i, expected := range tc.expected { + assert.Equal(t, expected.Text, result[i].Text, "item %d text should match", i) + assert.Equal(t, expected.Completed, result[i].Completed, "item %d completed status should match", i) + + if expected.LinkedRef != nil { + require.NotNil(t, result[i].LinkedRef, "item %d should have a linked reference", i) + assert.Equal(t, expected.LinkedRef.Owner, result[i].LinkedRef.Owner, "item %d linked owner should match", i) + assert.Equal(t, expected.LinkedRef.Repo, result[i].LinkedRef.Repo, "item %d linked repo should match", i) + assert.Equal(t, expected.LinkedRef.Number, result[i].LinkedRef.Number, "item %d linked number should match", i) + } else { + assert.Nil(t, result[i].LinkedRef, "item %d should not have a linked reference", i) + } + } + }) + } +} + +func TestFormatGraphOutputWithTasklist(t *testing.T) { + graph := &IssueGraph{ + FocusOwner: "owner", + FocusRepo: "repo", + FocusNumber: 100, + Summary: "Focus: #100 (batch) \"Batch with tasklist\"\nState: open\n", + Nodes: []GraphNode{ + { + Owner: "owner", + Repo: "repo", + Number: 100, + NodeType: NodeTypeBatch, + State: "open", + Title: "Batch with tasklist", + BodyPreview: "Tasks to complete", + Depth: 0, + IsFocus: true, + TasklistItems: []TasklistItem{ + {Text: "Task one", Completed: true}, + {Text: "Task two", Completed: false}, + {Text: "Task three with #123", Completed: false, LinkedRef: &IssueReference{Owner: "owner", Repo: "repo", Number: 123}}, + }, + }, + }, + Edges: []GraphEdge{}, + } + + result := formatGraphOutput(graph) + + // Verify tasklist section is present + assert.Contains(t, result, "Tasklist (1/3 completed):") + assert.Contains(t, result, "[x] Task one") + assert.Contains(t, result, "[ ] Task two") + assert.Contains(t, result, "[ ] Task three with #123 → #123") +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index f35168705..c65dc92c5 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -230,7 +230,15 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue { // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("issue_read", - mcp.WithDescription(t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository.")), + mcp.WithDescription(t("TOOL_ISSUE_READ_DESCRIPTION", `Get detailed information about a single issue: full body, comments, sub-issues, or labels. + +**USE issue_graph INSTEAD when user asks about:** +- Status, updates, or progress on issues/PRs/epics +- Project overview or how work is going +- Parent issues, sub-issues, or work hierarchy +- Related or blocking issues + +issue_read is for: reading the full issue body, fetching comments, listing sub-issues of a known parent, or getting label details - AFTER you already know which specific issue you need.`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), ReadOnlyHint: ToBoolPtr(true), diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a5605ec04..987ed4c76 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -205,6 +205,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListIssues(getGQLClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(GetLabel(getGQLClient, t)), + toolsets.NewServerTool(GetIssueGraph(getClient, getGQLClient, cache, t, flags)), ). AddWriteTools( toolsets.NewServerTool(IssueWrite(getClient, getGQLClient, t)), @@ -214,6 +215,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ).AddPrompts( toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), + toolsets.NewServerPrompt(ConnectIssueGraphPrompt(t)), ) users := toolsets.NewToolset(ToolsetMetadataUsers.ID, ToolsetMetadataUsers.Description). AddReadTools( diff --git a/work-plan.md b/work-plan.md new file mode 100644 index 000000000..74d5f9f7d --- /dev/null +++ b/work-plan.md @@ -0,0 +1,210 @@ +Use this file as a place to stash updated plans, knowledge, results or references so that even if you stop work you can continue without losing all context. Record decisions here too. + +--- + +## Progress & Decisions + +### Phase 1 - COMPLETED ✅ + +**Completed:** +- [x] Read OAuth scopes documentation: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps +- [x] Created `pkg/scopes/scopes.go` with OAuth scope constants and hierarchy utilities +- [x] Added scopes to ALL tool definitions using `mcp.Tool.Meta` field +- [x] Updated `cmd/github-mcp-server/generate_docs.go` to include scopes in README +- [x] Generated updated README.md with scope information +- [x] Updated all toolsnaps to reflect Meta field with scopes + +**Key Decision - Where to Store Scopes:** +- ❌ REJECTED: Adding `RequiredScopes` to `ServerTool` struct in toolsets package + - Reason: Adds complexity to toolsets package, requires changes to how tools are registered + - Creates tight coupling between toolsets and scopes packages +- ✅ ACCEPTED: Use `mcp.Tool.Meta` field to store scopes directly on tool definitions + - The MCP SDK's `Tool` struct has a `Meta map[string]any` field for custom metadata + - Scopes should be defined where tools are defined (repositories.go, issues.go, etc.) + - Cleaner separation of concerns - each tool knows its own requirements + - Easier to maintain - scope info is next to the API calls that require them + +**OAuth Scope Mapping (from docs):** +| API Area | Read Scope | Write Scope | +|----------|------------|-------------| +| Repos (public) | (no scope) | `public_repo` | +| Repos (private) | `repo` | `repo` | +| Issues | `repo` | `repo` | +| Pull Requests | `repo` | `repo` | +| Actions | `repo` | `repo` | +| Notifications | `notifications` | `notifications` | +| Gists (public) | (no scope) | `gist` | +| Users (public) | (no scope) | `user` | +| Organizations | `read:org` | `admin:org` | +| Code Scanning | `security_events` | `security_events` | +| Secret Scanning | `repo` | `repo` | +| Projects | `read:project` | `project` | +| Discussions | `repo` | `repo` | + +**Files Updated with Scopes (90+ tools):** +- [x] pkg/github/repositories.go - repos tools (repo, public_repo scopes) +- [x] pkg/github/git.go - git tools (repo scope) +- [x] pkg/github/issues.go - issues tools (repo scope) +- [x] pkg/github/pullrequests.go - PR tools (repo scope) +- [x] pkg/github/actions.go - actions tools (repo scope) +- [x] pkg/github/notifications.go - notifications tools (notifications scope) +- [x] pkg/github/discussions.go - discussions tools (repo scope) +- [x] pkg/github/gists.go - gists tools (gist scope for write) +- [x] pkg/github/search.go - search tools (repo scope) +- [x] pkg/github/code_scanning.go - code security (security_events scope) +- [x] pkg/github/secret_scanning.go - secret protection (repo scope) +- [x] pkg/github/dependabot.go - dependabot (repo scope) +- [x] pkg/github/projects.go - projects (project/read:project scope) +- [x] pkg/github/context_tools.go - context tools (no scope, read:org for teams) +- [x] pkg/github/labels.go - labels (repo scope) +- [x] pkg/github/security_advisories.go - security advisories (repo scope) + +**Package Structure:** +- `pkg/scopes/scopes.go` - Scope constants, hierarchy map, helper functions + - `Scope` type and constants (Repo, PublicRepo, Notifications, etc.) + - `ScopeHierarchy` map showing which scopes include others + - `WithScopes()` - creates Meta map for tool definitions + - `GetScopesFromMeta()` - extracts scopes from tool Meta + - `ScopeIncludes()`, `HasRequiredScopes()` - scope checking utilities + +**Documentation:** +- README.md now shows `(scopes: \`repo\`)` after each tool description +- Toolsnaps include `_meta.requiredOAuthScopes` array + +--- + +### Phase 2 - COMPLETED ✅ + +**Completed:** +- [x] Read fine-grained permissions documentation: https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens +- [x] Extended `pkg/scopes/scopes.go` with fine-grained permission types and helpers: + - `Permission` type with constants for all fine-grained permissions (actions, contents, issues, etc.) + - `PermissionLevel` type (read, write, admin) + - `FineGrainedPermission` struct combining permission and level + - `WithScopesAndPermissions()` helper for tool metadata + - `AddPermissions()` helper to add permissions to existing meta + - `GetPermissionsFromMeta()` to extract permissions + - `ReadPerm()`, `WritePerm()`, `AdminPerm()` convenience functions +- [x] Added comprehensive tests for fine-grained permissions in `pkg/scopes/scopes_test.go` +- [x] Created `docs/tool-permissions.md` with comprehensive documentation: + - OAuth scope hierarchy table + - Fine-grained permission levels explanation + - Complete tool-by-category permission mapping tables + - Minimum required scopes by use case + - Notes about limitations (notifications, metadata, etc.) +- [x] Updated README.md with links to tool-permissions.md: + - Added link in Prerequisites section + - Added callout note before Tools section + +**Key Decision - Documentation vs Code Changes:** +- ✅ ACCEPTED: Create comprehensive documentation in `docs/tool-permissions.md` + - Documents all ~90 tools with both OAuth scopes AND fine-grained permissions + - Easier to maintain as a single reference document + - Doesn't require modifying every tool file + - Users can look up permissions by tool or by category +- ⏸️ DEFERRED: Adding fine-grained permissions to every tool's Meta field + - Would require changes to ~90 tool definitions + - Phase 1 OAuth scopes are sufficient for tool metadata + - Documentation approach provides same info with less risk + +--- + +### Phase 3 - COMPLETED ✅ + +**Completed:** +- [x] Created `cmd/github-mcp-server/list_scopes.go` - new command to list required OAuth scopes +- [x] Created `script/list-scopes` - convenience wrapper script +- [x] Command respects all the same flags as stdio command (--toolsets, --read-only, etc.) +- [x] Three output formats: text (default), json, summary +- [x] JSON output includes: tools, unique_scopes, scopes_by_tool, tools_by_scope +- [x] Lint passes, tests pass + +**Implementation Details:** +- Added `list-scopes` subcommand to github-mcp-server binary +- Uses same toolset configuration logic as stdio server +- Creates toolset group with mock clients (no API calls needed) +- Extracts scopes from tool Meta field using existing scopes package +- Calculates accepted scopes (parent scopes that satisfy requirements) + +**Usage Examples:** +```bash +# List scopes for default toolsets +github-mcp-server list-scopes + +# List scopes for specific toolsets +github-mcp-server list-scopes --toolsets=repos,issues,pull_requests + +# List scopes for all toolsets +github-mcp-server list-scopes --toolsets=all + +# Output as JSON (for programmatic use) +github-mcp-server list-scopes --output=json + +# Just show unique scopes needed +github-mcp-server list-scopes --output=summary + +# Read-only mode (excludes write tools) +github-mcp-server list-scopes --read-only --output=summary +``` + +--- + +### Phase 4 - COMPLETED ✅ + +**Completed:** +- [x] Created `pkg/scopes/tool_scope_map.go` with exported types for library use +- [x] Created `pkg/scopes/tool_scope_map_test.go` with comprehensive tests +- [x] Lint passes, tests pass + +**Exported Types:** +- `ToolScopeMap` - map[string]*ToolScopeInfo for fast tool name -> scopes lookup +- `ToolScopeInfo` - contains RequiredScopes and AcceptedScopes as ScopeSet +- `ScopeSet` - map[string]bool for O(1) scope lookup +- `ToolMeta` - minimal struct for building scope maps + +**Key Methods:** +- `NewToolScopeInfo(required []Scope)` - creates info from required scopes, auto-calculates accepted +- `BuildToolScopeMapFromMeta(tools []ToolMeta)` - builds map from tool definitions +- `GetToolScopeInfo(meta map[string]any)` - creates info from tool Meta field +- `ToolScopeInfo.HasAcceptedScope(userScopes ...string)` - checks if token has access +- `ToolScopeInfo.MissingScopes(userScopes ...string)` - returns missing required scopes +- `ToolScopeMap.AllRequiredScopes()` - returns all unique required scopes +- `ToolScopeMap.ToolsRequiringScope(scope)` - returns tools that require a scope +- `ToolScopeMap.ToolsAcceptingScope(scope)` - returns tools that accept a scope + +**Usage Example:** +```go +import "github.com/github/github-mcp-server/pkg/scopes" + +// Build scope map from tool definitions +tools := []scopes.ToolMeta{ + {Name: "get_repo", Meta: someToolMeta}, + {Name: "create_issue", Meta: anotherToolMeta}, +} +scopeMap := scopes.BuildToolScopeMapFromMeta(tools) + +// Check if user's token can use a tool +if info, ok := scopeMap["create_issue"]; ok { + userScopes := []string{"repo", "user"} + if info.HasAcceptedScope(userScopes...) { + // User can use this tool + } else { + missing := info.MissingScopes(userScopes...) + fmt.Printf("Missing scopes: %v\n", missing) + } +} + +// Get all required scopes +allRequired := scopeMap.AllRequiredScopes() +fmt.Printf("All required: %v\n", allRequired.ToSlice()) +``` + +--- + +## Original Requirements + +The phases can be a stacked PR, so each one should have a new branch, and when a phase is complete we want a full pull request based on the previous, with the base branch of phase 1 being omgitsads/go-sdk + +IMPORTANT you MUST check all API calls and GraphQL calls etc. and verify what scopes are required. If unsure you must check before proceeding. + +These changes must be clean and clear. \ No newline at end of file