af42b98a71
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
License Check / license-check (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Adds the issue_graph tool that provides a comprehensive view of issue and PR relationships in a GitHub repository. This is designed to be the primary tool for understanding project status, work hierarchy, and issue dependencies. Key features: - Focus parameter: Auto-shift focus to epic/batch parent issues - Cross-repo support: Discover parent issues via GraphQL and sub-issues via REST - Status extraction: Parse milestone due dates and status keywords from body/comments - State reason: Show why issues/PRs are in their current state (completed, merged, etc.) - Project info: Fetch project name and status for the focus node - Issue Types: Detect GitHub's native Epic issue type for classification - Tasklist items: Extract legacy markdown checkbox items from issue body The tool returns a text-formatted graph showing: - Node types: epic, batch, task, pr - Full hierarchy across repositories - Sub-issues and closes/fixes references - Cross-references and related work - Open/closed/merged state of all related items Closes: #1510
2120 lines
65 KiB
Go
2120 lines
65 KiB
Go
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/github/github-mcp-server/pkg/utils"
|
|
"github.com/google/go-github/v79/github"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"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
|
|
// Note: This regex is used for extracting references from text (issue bodies), not for URL validation.
|
|
// The pattern `https?://` ensures github.com immediately follows the protocol - no other host can precede it.
|
|
// nolint:gosec // G107: This is a reference extraction regex, not a URL validator; owner/repo/number are validated downstream
|
|
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<<attempt) // exponential backoff
|
|
select {
|
|
case <-time.After(backoff):
|
|
continue // retry
|
|
case <-ctx.Done():
|
|
return nil, nil, ctx.Err()
|
|
}
|
|
}
|
|
// Mark repo as inaccessible for 403 (forbidden) or 404 (not found for entire repo)
|
|
if resp.StatusCode == 403 || resp.StatusCode == 404 {
|
|
// Check if it's a repo-level 404 vs issue-level 404
|
|
// For simplicity, we'll just skip this node
|
|
if resp.StatusCode == 403 && resp.Rate.Remaining > 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) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
|
|
schema := &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "Issue or pull request number to build the graph from",
|
|
},
|
|
"focus": {
|
|
Type: "string",
|
|
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: []any{"provided", "epic", "batch"},
|
|
},
|
|
"verbose": {
|
|
Type: "boolean",
|
|
Description: "Include crawl statistics showing how the graph was traversed (nodes fetched, depth reached, repos accessed, etc.)",
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "issue_number"},
|
|
}
|
|
|
|
return mcp.Tool{
|
|
Name: "issue_graph",
|
|
Description: t("TOOL_ISSUE_GRAPH_DESCRIPTION", `Get a graph representation of issue and pull request relationships, showing the full work hierarchy 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
|
|
- 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.
|
|
Use focus="batch" to find the nearest batch/parent issue in the hierarchy.`),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_ISSUE_GRAPH_USER_TITLE", "Get issue relationship graph"),
|
|
ReadOnlyHint: true,
|
|
},
|
|
InputSchema: schema,
|
|
},
|
|
func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
owner, err := RequiredParam[string](args, "owner")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
repo, err := RequiredParam[string](args, "repo")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
issueNumber, err := RequiredInt(args, "issue_number")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
focusType, err := OptionalParam[string](args, "focus")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
if focusType == "" {
|
|
focusType = "provided"
|
|
}
|
|
verbose, err := OptionalParam[bool](args, "verbose")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, 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, 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 utils.NewToolResultText(formattedOutput), nil, nil
|
|
}
|
|
}
|