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
849 lines
24 KiB
Go
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  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")
|
|
}
|