Skip to content

Commit 71a61a0

Browse files
committed
refactor(github): use go-gh library instead of subprocess
Replace the os/exec-based approach for calling gh CLI with the official go-gh library (github.com/cli/go-gh/v2). This provides: - Native authenticated REST API access via DefaultRESTClient() - Automatic repository detection via repository.Current() - Better error handling and type safety - Reduced overhead from subprocess spawning Also adds a release workflow using cli/gh-extension-precompile@v2 for building cross-platform binaries with attestations.
1 parent 649d196 commit 71a61a0

5 files changed

Lines changed: 215 additions & 26 deletions

File tree

.github/workflows/release.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
id-token: write
11+
attestations: write
12+
13+
jobs:
14+
release:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: cli/gh-extension-precompile@v2
20+
with:
21+
generate_attestations: true
22+
go_version_file: go.mod

cmd/pr.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func runPR(cmd *cobra.Command, args []string) error {
6666

6767
// Create new PR
6868
fmt.Printf("Creating PR for %q targeting %q...\n", branch, base)
69-
prNumber, err := github.CreatePR(base, branch, "")
69+
prNumber, err := github.CreatePR(branch, base, branch, "")
7070
if err != nil {
7171
return err
7272
}

go.mod

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,26 @@ module github.com/boneskull/gh-stack
22

33
go 1.25.6
44

5-
require github.com/spf13/cobra v1.10.2
5+
require (
6+
github.com/cli/go-gh/v2 v2.13.0
7+
github.com/spf13/cobra v1.10.2
8+
)
69

710
require (
11+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
12+
github.com/cli/safeexec v1.0.0 // indirect
13+
github.com/cli/shurcooL-graphql v0.0.4 // indirect
14+
github.com/henvic/httpretty v0.0.6 // indirect
815
github.com/inconshreveable/mousetrap v1.1.0 // indirect
16+
github.com/kr/pretty v0.3.1 // indirect
17+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
18+
github.com/mattn/go-isatty v0.0.20 // indirect
19+
github.com/muesli/termenv v0.16.0 // indirect
20+
github.com/rivo/uniseg v0.4.7 // indirect
921
github.com/spf13/pflag v1.0.9 // indirect
22+
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
23+
golang.org/x/sys v0.31.0 // indirect
24+
golang.org/x/term v0.30.0 // indirect
25+
golang.org/x/text v0.23.0 // indirect
26+
gopkg.in/yaml.v3 v3.0.1 // indirect
1027
)

go.sum

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,62 @@
1+
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
2+
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
3+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5+
github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys=
6+
github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM=
7+
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
8+
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
9+
github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY=
10+
github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk=
111
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
12+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
13+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
14+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
15+
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
16+
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
17+
github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs=
18+
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
219
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
320
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
21+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
22+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
23+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
24+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
25+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
26+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
27+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
28+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
29+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
30+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
31+
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
32+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
33+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
34+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
35+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
36+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
37+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
438
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
539
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
640
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
741
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
842
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
43+
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
44+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
45+
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
46+
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
947
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
48+
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
49+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
50+
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
51+
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
52+
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
53+
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
54+
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
55+
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
1056
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
57+
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
58+
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
59+
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
60+
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
61+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
62+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/github/github.go

Lines changed: 122 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
package github
33

44
import (
5+
"bytes"
56
"encoding/json"
67
"fmt"
7-
"os/exec"
8-
"strconv"
9-
"strings"
8+
9+
"github.com/cli/go-gh/v2/pkg/api"
10+
"github.com/cli/go-gh/v2/pkg/repository"
1011
)
1112

1213
// PR represents a GitHub pull request.
@@ -16,41 +17,138 @@ type PR struct {
1617
Merged bool `json:"merged"`
1718
}
1819

19-
// CreatePR creates a new pull request and returns the PR number.
20-
func CreatePR(base, title, body string) (int, error) {
21-
args := []string{"pr", "create", "--base", base, "--title", title, "--body", body}
22-
out, err := exec.Command("gh", args...).Output()
20+
// Client wraps the go-gh REST client with repo context.
21+
type Client struct {
22+
rest *api.RESTClient
23+
owner string
24+
repo string
25+
}
26+
27+
// NewClient creates a new GitHub client for the current repository.
28+
func NewClient() (*Client, error) {
29+
rest, err := api.DefaultRESTClient()
2330
if err != nil {
24-
if exitErr, ok := err.(*exec.ExitError); ok {
25-
return 0, fmt.Errorf("gh pr create failed: %s", string(exitErr.Stderr))
26-
}
27-
return 0, fmt.Errorf("gh pr create failed: %w", err)
31+
return nil, fmt.Errorf("failed to create REST client: %w", err)
2832
}
2933

30-
// Output is the PR URL, extract the number
31-
url := strings.TrimSpace(string(out))
32-
parts := strings.Split(url, "/")
33-
if len(parts) == 0 {
34-
return 0, fmt.Errorf("unexpected output: %s", url)
34+
repo, err := repository.Current()
35+
if err != nil {
36+
return nil, fmt.Errorf("failed to detect repository: %w", err)
3537
}
36-
return strconv.Atoi(parts[len(parts)-1])
38+
39+
return &Client{
40+
rest: rest,
41+
owner: repo.Owner,
42+
repo: repo.Name,
43+
}, nil
3744
}
3845

39-
// GetPR fetches PR details by number.
40-
func GetPR(number int) (*PR, error) {
41-
out, err := exec.Command("gh", "pr", "view", strconv.Itoa(number), "--json", "number,state,merged").Output()
46+
// CreatePR creates a new pull request and returns the PR number.
47+
func (c *Client) CreatePR(head, base, title, body string) (int, error) {
48+
path := fmt.Sprintf("repos/%s/%s/pulls", c.owner, c.repo)
49+
50+
request := struct {
51+
Head string `json:"head"`
52+
Base string `json:"base"`
53+
Title string `json:"title"`
54+
Body string `json:"body"`
55+
}{
56+
Head: head,
57+
Base: base,
58+
Title: title,
59+
Body: body,
60+
}
61+
62+
reqBody, err := json.Marshal(request)
4263
if err != nil {
43-
return nil, err
64+
return 0, fmt.Errorf("failed to marshal request: %w", err)
4465
}
4566

67+
var response PR
68+
err = c.rest.Post(path, bytes.NewReader(reqBody), &response)
69+
if err != nil {
70+
return 0, fmt.Errorf("failed to create PR: %w", err)
71+
}
72+
73+
return response.Number, nil
74+
}
75+
76+
// GetPR fetches PR details by number.
77+
func (c *Client) GetPR(number int) (*PR, error) {
78+
path := fmt.Sprintf("repos/%s/%s/pulls/%d", c.owner, c.repo, number)
79+
4680
var pr PR
47-
if err := json.Unmarshal(out, &pr); err != nil {
48-
return nil, err
81+
err := c.rest.Get(path, &pr)
82+
if err != nil {
83+
return nil, fmt.Errorf("failed to get PR #%d: %w", number, err)
4984
}
85+
5086
return &pr, nil
5187
}
5288

5389
// UpdatePRBase updates the base branch of a PR.
90+
func (c *Client) UpdatePRBase(number int, base string) error {
91+
path := fmt.Sprintf("repos/%s/%s/pulls/%d", c.owner, c.repo, number)
92+
93+
request := struct {
94+
Base string `json:"base"`
95+
}{
96+
Base: base,
97+
}
98+
99+
reqBody, err := json.Marshal(request)
100+
if err != nil {
101+
return fmt.Errorf("failed to marshal request: %w", err)
102+
}
103+
104+
err = c.rest.Patch(path, bytes.NewReader(reqBody), nil)
105+
if err != nil {
106+
return fmt.Errorf("failed to update PR #%d base: %w", number, err)
107+
}
108+
109+
return nil
110+
}
111+
112+
// --- Convenience functions for backward compatibility ---
113+
114+
// defaultClient is a lazily-initialized client for convenience functions.
115+
var defaultClient *Client
116+
117+
func getDefaultClient() (*Client, error) {
118+
if defaultClient == nil {
119+
var err error
120+
defaultClient, err = NewClient()
121+
if err != nil {
122+
return nil, err
123+
}
124+
}
125+
return defaultClient, nil
126+
}
127+
128+
// CreatePR creates a new pull request using the default client.
129+
// Deprecated: Use NewClient() and call methods directly for better error handling.
130+
func CreatePR(head, base, title, body string) (int, error) {
131+
client, err := getDefaultClient()
132+
if err != nil {
133+
return 0, err
134+
}
135+
return client.CreatePR(head, base, title, body)
136+
}
137+
138+
// GetPR fetches PR details using the default client.
139+
func GetPR(number int) (*PR, error) {
140+
client, err := getDefaultClient()
141+
if err != nil {
142+
return nil, err
143+
}
144+
return client.GetPR(number)
145+
}
146+
147+
// UpdatePRBase updates the base branch using the default client.
54148
func UpdatePRBase(number int, base string) error {
55-
return exec.Command("gh", "pr", "edit", strconv.Itoa(number), "--base", base).Run()
149+
client, err := getDefaultClient()
150+
if err != nil {
151+
return err
152+
}
153+
return client.UpdatePRBase(number, base)
56154
}

0 commit comments

Comments
 (0)