From af42b98a711a62cef7c505254139847e4131c134 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 7 Dec 2025 01:18:34 +0100 Subject: [PATCH] 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 --- README.md | 7 + go.mod | 38 +- go.sum | 92 +- pkg/github/__toolsnaps__/issue_graph.snap | 43 + pkg/github/__toolsnaps__/issue_read.snap | 2 +- pkg/github/instructions.go | 21 +- pkg/github/issue_graph.go | 2119 +++++++++++++++++++++ pkg/github/issue_graph_test.go | 848 +++++++++ pkg/github/issues.go | 2 +- pkg/github/tools.go | 1 + 10 files changed, 3103 insertions(+), 70 deletions(-) create mode 100644 pkg/github/__toolsnaps__/issue_graph.snap create mode 100644 pkg/github/issue_graph.go create mode 100644 pkg/github/issue_graph_test.go diff --git a/README.md b/README.md index c7243033b..b73fed5a7 100644 --- a/README.md +++ b/README.md @@ -723,6 +723,13 @@ The following sets of tools are available: - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) +- **issue_graph** - Get issue relationship graph + - `focus`: Which node type to focus on: 'provided' (default) uses the specified issue/PR, 'epic' shifts focus to the nearest epic in the hierarchy, 'batch' shifts focus to the nearest batch/parent issue (string, optional) + - `issue_number`: Issue or pull request number to build the graph from (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `verbose`: Include crawl statistics showing how the graph was traversed (nodes fetched, depth reached, repos accessed, etc.) (boolean, optional) + - **issue_read** - Get issue details - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. diff --git a/go.mod b/go.mod index 661778fc3..a0f590c7c 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,37 @@ module github.com/github/github-mcp-server -go 1.24.0 +go 1.24.4 require ( github.com/google/go-github/v79 v79.0.0 github.com/google/jsonschema-go v0.3.0 github.com/josephburnett/jd v1.9.2 + github.com/mark3labs/mcp-go v0.43.2 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/migueleliasweb/go-github-mock v1.3.0 + github.com/migueleliasweb/go-github-mock v1.5.0 + github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 - github.com/spf13/cobra v1.10.1 + github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 ) require ( github.com/aymerick/douceur v0.2.0 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/swag v0.21.1 // indirect - github.com/google/go-github/v71 v71.0.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/google/go-github/v73 v73.0.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/mux v1.8.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect + golang.org/x/net v0.47.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) @@ -35,24 +40,23 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.5.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e422a548c..f8bd86977 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,47 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU= -github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= -github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= +github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= +github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-github/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y= github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= github.com/josephburnett/jd v1.9.2/go.mod h1:bImDr8QXpxMb3SD+w1cDRHp97xP6UwI88xUAuxwDQfM= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -46,84 +49,73 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88= -github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY= +github.com/migueleliasweb/go-github-mock v1.5.0 h1:dIr6vgVz8QY9sDiDopWxk6pDw4d7K/xIcCk/NQe4ajM= +github.com/migueleliasweb/go-github-mock v1.5.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o= github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/github/__toolsnaps__/issue_graph.snap b/pkg/github/__toolsnaps__/issue_graph.snap new file mode 100644 index 000000000..fa22314a7 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_graph.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get issue relationship graph" + }, + "description": "Get a graph representation of issue and pull request relationships, showing the full work hierarchy in one call.\n\nReturns a comprehensive view including:\n- Node types: epic (large initiatives), batch (parent issues), task (regular issues), pr (pull requests)\n- Full hierarchy: epic → batch → task → PR relationships\n- Sub-issues and \"closes/fixes\" references\n- Cross-references and related work\n- Status updates extracted from issue bodies and comments\n- Open/closed/merged state of all related items\n\nUse focus=\"epic\" to automatically find and focus on the parent epic of any issue.\nUse focus=\"batch\" to find the nearest batch/parent issue in the hierarchy.", + "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "issue_number" + ], + "properties": { + "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": [ + "provided", + "epic", + "batch" + ] + }, + "issue_number": { + "type": "number", + "description": "Issue or pull request number to build the graph from" + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "verbose": { + "type": "boolean", + "description": "Include crawl statistics showing how the graph was traversed (nodes fetched, depth reached, repos accessed, etc.)" + } + } + }, + "name": "issue_graph" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index c6a9e7306..c14c23adf 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -3,7 +3,7 @@ "readOnlyHint": true, "title": "Get issue details" }, - "description": "Get information about a specific issue in a GitHub repository.", + "description": "Get detailed information about a single issue in a GitHub repository, including body, comments, sub-issues, or labels.", "inputSchema": { "type": "object", "required": [ diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go index 3a5fb54bb..c5d305a5c 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/instructions.go @@ -63,7 +63,26 @@ Before creating a pull request, search for pull request templates in the reposit case "issues": return `## Issues -Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` +When users ask about issue/PR status, progress, or project updates, use 'issue_graph' first - it returns the full work hierarchy (epics, parent issues, tasks, PRs) in one call. + +'issue_graph' is ideal when users ask: +- "update on..." / "status of..." / "progress on..." +- "what's happening with..." / "how is... going" +- "project status" / "overall progress" +- Questions about epics, parent issues, sub-issues, or blocking relationships + +Example: "give me an update on issue #123" → Call issue_graph(owner, repo, 123) first to see the full context. + +Use focus="epic" to find and center on the parent epic of any issue. + +After understanding the hierarchy from 'issue_graph': +- Use 'issue_read' for full details of a specific issue (body, comments, labels) +- Use 'search_issues' to find related issues not captured in the graph + +For creating/modifying issues: +- Check 'list_issue_types' first for organizations to use proper issue types +- Use 'search_issues' before creating new issues to avoid duplicates +- Always set 'state_reason' when closing issues` case "discussions": return `## Discussions diff --git a/pkg/github/issue_graph.go b/pkg/github/issue_graph.go new file mode 100644 index 000000000..764e7d2b8 --- /dev/null +++ b/pkg/github/issue_graph.go @@ -0,0 +1,2119 @@ +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< 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 + } +} diff --git a/pkg/github/issue_graph_test.go b/pkg/github/issue_graph_test.go new file mode 100644 index 000000000..361721413 --- /dev/null +++ b/pkg/github/issue_graph_test.go @@ -0,0 +1,848 @@ +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") +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 46111a4d6..d0e410c58 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -263,7 +263,7 @@ Options are: return mcp.Tool{ Name: "issue_read", - Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository."), + Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get detailed information about a single issue in a GitHub repository, including body, comments, sub-issues, or labels."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), ReadOnlyHint: true, diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d37af98b8..521f0dc3a 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -205,6 +205,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListIssues(getGQLClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(GetLabel(getGQLClient, t)), + toolsets.NewServerTool(GetIssueGraph(getClient, getGQLClient, cache, t, flags)), ). AddWriteTools( toolsets.NewServerTool(IssueWrite(getClient, getGQLClient, t)),