Files
github--github-mcp-server/pkg/github/issue_graph_test.go
Sam Morrow 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
feat(issue_graph): Add issue_graph tool for visualizing issue/PR relationships
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
2025-12-07 01:34:18 +01:00

849 lines
24 KiB
Go

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/google/jsonschema-go/jsonschema"
"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)
inputSchema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, inputSchema.Properties, "owner")
assert.Contains(t, inputSchema.Properties, "repo")
assert.Contains(t, inputSchema.Properties, "issue_number")
assert.ElementsMatch(t, 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}),
)
args := map[string]any{
"owner": "testowner",
"repo": "testrepo",
"issue_number": float64(42),
}
request := createMCPRequest(args)
result, _, err := handler(context.Background(), &request, args)
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}),
)
args := map[string]any{
"owner": "testowner",
"repo": "testrepo",
"issue_number": float64(100),
}
request := createMCPRequest(args)
result, _, err := handler(context.Background(), &request, args)
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")
}