@@ -13,12 +13,34 @@ import (
1313 "github.com/xeaser/squad-opencode/internal/watch"
1414)
1515
16- // ParseListJSON parses `gh issue list --json number,title,state`.
16+ // ParseListJSON parses `gh issue list --json number,title,state,labels `.
1717func ParseListJSON (data []byte ) ([]watch.Issue , error ) {
18- var issues []watch.Issue
19- if err := json .Unmarshal (data , & issues ); err != nil {
18+ var rows []struct {
19+ Number int `json:"number"`
20+ Title string `json:"title"`
21+ State string `json:"state"`
22+ Labels []struct {
23+ Name string `json:"name"`
24+ } `json:"labels"`
25+ }
26+ if err := json .Unmarshal (data , & rows ); err != nil {
2027 return nil , err
2128 }
29+ issues := make ([]watch.Issue , 0 , len (rows ))
30+ for _ , r := range rows {
31+ var labels []string
32+ for _ , l := range r .Labels {
33+ if l .Name != "" {
34+ labels = append (labels , l .Name )
35+ }
36+ }
37+ issues = append (issues , watch.Issue {
38+ Number : r .Number ,
39+ Title : r .Title ,
40+ State : r .State ,
41+ Labels : labels ,
42+ })
43+ }
2244 return issues , nil
2345}
2446
@@ -34,7 +56,7 @@ func (g GHLister) args() []string {
3456 if limit <= 0 {
3557 limit = 20
3658 }
37- args := []string {"issue" , "list" , "--json" , "number,title,state" , "--limit" , strconv .Itoa (limit )}
59+ args := []string {"issue" , "list" , "--json" , "number,title,state,labels " , "--limit" , strconv .Itoa (limit )}
3860 for _ , label := range g .Labels {
3961 if label == "" {
4062 continue
@@ -71,3 +93,208 @@ func (g GHLister) List(ctx context.Context) ([]watch.Issue, error) {
7193 }
7294 return issues , nil
7395}
96+
97+ // GHPRChecker lists open PRs and maps linked issues (closing refs or body keywords).
98+ type GHPRChecker struct {
99+ Dir string
100+ run func (ctx context.Context , args ... string ) ([]byte , error )
101+ }
102+
103+ // OpenLinks implements watch.LinkedPRChecker. Issue → first matching open PR.
104+ func (g GHPRChecker ) OpenLinks (ctx context.Context ) (map [int ]int , error ) {
105+ raw , err := g .exec (ctx , "pr" , "list" , "--state" , "open" , "--limit" , "1000" , "--json" , "number" )
106+ if err != nil {
107+ return nil , err
108+ }
109+ var prs []struct {
110+ Number int `json:"number"`
111+ }
112+ if len (bytes .TrimSpace (raw )) > 0 {
113+ if err := json .Unmarshal (raw , & prs ); err != nil {
114+ return nil , fmt .Errorf ("gh pr list: not JSON (%w)" , err )
115+ }
116+ }
117+ links := make (map [int ]int )
118+ for _ , pr := range prs {
119+ if pr .Number <= 0 {
120+ continue
121+ }
122+ issues , err := g .linkedIssues (ctx , pr .Number )
123+ if err != nil {
124+ return nil , err
125+ }
126+ for _ , n := range issues {
127+ if n > 0 {
128+ if _ , exists := links [n ]; ! exists {
129+ links [n ] = pr .Number
130+ }
131+ }
132+ }
133+ }
134+ return links , nil
135+ }
136+
137+ func (g GHPRChecker ) linkedIssues (ctx context.Context , n int ) ([]int , error ) {
138+ raw , err := g .exec (ctx , "pr" , "view" , strconv .Itoa (n ), "--json" , "closingIssuesReferences,body" )
139+ if err != nil {
140+ return nil , err
141+ }
142+ if len (raw ) == 0 {
143+ return nil , nil
144+ }
145+ var wrap struct {
146+ Body string `json:"body"`
147+ ClosingIssuesReferences []struct {
148+ Number int `json:"number"`
149+ } `json:"closingIssuesReferences"`
150+ }
151+ if err := json .Unmarshal (raw , & wrap ); err != nil {
152+ return parseBodyIssueRefs (string (raw )), nil
153+ }
154+ var out []int
155+ seen := map [int ]bool {}
156+ for _ , ref := range wrap .ClosingIssuesReferences {
157+ if ref .Number > 0 && ! seen [ref .Number ] {
158+ seen [ref .Number ] = true
159+ out = append (out , ref .Number )
160+ }
161+ }
162+ for _ , num := range parseBodyIssueRefs (wrap .Body ) {
163+ if num > 0 && ! seen [num ] {
164+ seen [num ] = true
165+ out = append (out , num )
166+ }
167+ }
168+ return out , nil
169+ }
170+
171+ func (g GHPRChecker ) exec (ctx context.Context , args ... string ) ([]byte , error ) {
172+ if g .run != nil {
173+ return g .run (ctx , args ... )
174+ }
175+ cmd := exec .CommandContext (ctx , "gh" , args ... )
176+ if g .Dir != "" {
177+ cmd .Dir = g .Dir
178+ }
179+ cmd .Env = append (os .Environ (), "NO_COLOR=1" , "CLICOLOR=0" , "GH_FORCE_TTY=0" , "GH_PROMPT_DISABLED=1" )
180+ var stdout , stderr bytes.Buffer
181+ cmd .Stdout = & stdout
182+ cmd .Stderr = & stderr
183+ if err := cmd .Run (); err != nil {
184+ return nil , fmt .Errorf ("gh %s: %w (%s)" , strings .Join (args , " " ), err , strings .TrimSpace (stderr .String ()))
185+ }
186+ return bytes .TrimSpace (stdout .Bytes ()), nil
187+ }
188+
189+ // GHProjectSource lists GitHub Project v2 items (issue number + Status).
190+ type GHProjectSource struct {
191+ Dir string
192+ run func (ctx context.Context , args ... string ) ([]byte , error )
193+ }
194+
195+ // Items implements watch.ProjectSource.
196+ func (g GHProjectSource ) Items (ctx context.Context , project int ) ([]watch.ProjectItem , error ) {
197+ owner , err := g .owner (ctx )
198+ if err != nil {
199+ return nil , err
200+ }
201+ raw , err := g .exec (ctx , "project" , "item-list" , strconv .Itoa (project ), "--owner" , owner , "--format" , "json" , "--limit" , "1000" )
202+ if err != nil {
203+ return nil , err
204+ }
205+ return ParseProjectItemsJSON (raw )
206+ }
207+
208+ func (g GHProjectSource ) owner (ctx context.Context ) (string , error ) {
209+ raw , err := g .exec (ctx , "repo" , "view" , "--json" , "owner" )
210+ if err != nil {
211+ return "" , err
212+ }
213+ var wrap struct {
214+ Owner struct {
215+ Login string `json:"login"`
216+ } `json:"owner"`
217+ }
218+ if err := json .Unmarshal (raw , & wrap ); err != nil || wrap .Owner .Login == "" {
219+ preview := string (raw )
220+ if len (preview ) > 160 {
221+ preview = preview [:160 ]
222+ }
223+ if err != nil {
224+ return "" , fmt .Errorf ("gh repo view: not JSON (%w): %q" , err , preview )
225+ }
226+ return "" , fmt .Errorf ("gh repo view: empty owner: %q" , preview )
227+ }
228+ return wrap .Owner .Login , nil
229+ }
230+
231+ func (g GHProjectSource ) exec (ctx context.Context , args ... string ) ([]byte , error ) {
232+ if g .run != nil {
233+ return g .run (ctx , args ... )
234+ }
235+ cmd := exec .CommandContext (ctx , "gh" , args ... )
236+ if g .Dir != "" {
237+ cmd .Dir = g .Dir
238+ }
239+ cmd .Env = append (os .Environ (), "NO_COLOR=1" , "CLICOLOR=0" , "GH_FORCE_TTY=0" , "GH_PROMPT_DISABLED=1" )
240+ var stdout , stderr bytes.Buffer
241+ cmd .Stdout = & stdout
242+ cmd .Stderr = & stderr
243+ if err := cmd .Run (); err != nil {
244+ return nil , fmt .Errorf ("gh %s: %w (%s)" , strings .Join (args , " " ), err , strings .TrimSpace (stderr .String ()))
245+ }
246+ return bytes .TrimSpace (stdout .Bytes ()), nil
247+ }
248+
249+ // ParseProjectItemsJSON parses `gh project item-list --format json`.
250+ func ParseProjectItemsJSON (data []byte ) ([]watch.ProjectItem , error ) {
251+ if len (bytes .TrimSpace (data )) == 0 {
252+ return nil , nil
253+ }
254+ var wrap struct {
255+ Items []struct {
256+ Status string `json:"status"`
257+ Content struct {
258+ Number int `json:"number"`
259+ } `json:"content"`
260+ } `json:"items"`
261+ }
262+ if err := json .Unmarshal (data , & wrap ); err != nil {
263+ return nil , fmt .Errorf ("gh project item-list: not JSON (%w)" , err )
264+ }
265+ var out []watch.ProjectItem
266+ for _ , it := range wrap .Items {
267+ if it .Content .Number <= 0 {
268+ continue
269+ }
270+ out = append (out , watch.ProjectItem {
271+ Number : it .Content .Number ,
272+ Status : it .Status ,
273+ })
274+ }
275+ return out , nil
276+ }
277+
278+ func parseBodyIssueRefs (body string ) []int {
279+ low := strings .ToLower (body )
280+ var out []int
281+ for _ , key := range []string {"closes #" , "fixes #" , "mentions #" } {
282+ rest := low
283+ for {
284+ i := strings .Index (rest , key )
285+ if i < 0 {
286+ break
287+ }
288+ rest = rest [i + len (key ):]
289+ n := 0
290+ for len (rest ) > 0 && rest [0 ] >= '0' && rest [0 ] <= '9' {
291+ n = n * 10 + int (rest [0 ]- '0' )
292+ rest = rest [1 :]
293+ }
294+ if n > 0 {
295+ out = append (out , n )
296+ }
297+ }
298+ }
299+ return out
300+ }
0 commit comments