Skip to content

Commit 0b32122

Browse files
authored
refactor: hookdeck api calls using latest version
refactor: move hookdeck api calls to pkg/hookdeck chore: hookdeck api calls using latest version
1 parent d8e11c3 commit 0b32122

File tree

15 files changed

+338
-287
lines changed

15 files changed

+338
-287
lines changed

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
66
You may obtain a copy of the License at
77
8-
http://www.apache.org/licenses/LICENSE-2.0
8+
http://www.apache.org/licenses/LICENSE-2.0
99
1010
Unless required by applicable law or agreed to in writing, software
1111
distributed under the License is distributed on an "AS IS" BASIS,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hookdeck-cli",
3-
"version": "1.3.0-beta.1",
3+
"version": "1.3.0",
44
"description": "Hookdeck CLI",
55
"repository": {
66
"type": "git",

pkg/hookdeck/auth.go

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package hookdeck
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"io"
8+
"net/url"
9+
"strings"
10+
"time"
11+
12+
log "github.com/sirupsen/logrus"
13+
)
14+
15+
const maxAttemptsDefault = 2 * 60
16+
const intervalDefault = 2 * time.Second
17+
const maxBackoffInterval = 30 * time.Second
18+
19+
// ValidateAPIKeyResponse returns the user and team associated with a key
20+
type ValidateAPIKeyResponse struct {
21+
UserID string `json:"user_id"`
22+
UserName string `json:"user_name"`
23+
UserEmail string `json:"user_email"`
24+
OrganizationName string `json:"organization_name"`
25+
OrganizationID string `json:"organization_id"`
26+
ProjectID string `json:"team_id"`
27+
ProjectName string `json:"team_name_no_org"`
28+
ProjectMode string `json:"team_mode"`
29+
ClientID string `json:"client_id"`
30+
}
31+
32+
// PollAPIKeyResponse returns the data of the polling client login
33+
type PollAPIKeyResponse struct {
34+
Claimed bool `json:"claimed"`
35+
UserID string `json:"user_id"`
36+
UserName string `json:"user_name"`
37+
UserEmail string `json:"user_email"`
38+
OrganizationName string `json:"organization_name"`
39+
OrganizationID string `json:"organization_id"`
40+
ProjectID string `json:"team_id"`
41+
ProjectName string `json:"team_name"`
42+
ProjectMode string `json:"team_mode"`
43+
APIKey string `json:"key"`
44+
ClientID string `json:"client_id"`
45+
}
46+
47+
// UpdateClientInput represents the input for updating a CLI client
48+
type UpdateClientInput struct {
49+
DeviceName string `json:"device_name"`
50+
}
51+
52+
// LoginSession represents an in-progress login flow
53+
type LoginSession struct {
54+
BrowserURL string
55+
pollURL string
56+
}
57+
58+
// GuestSession represents an in-progress guest login flow
59+
type GuestSession struct {
60+
BrowserURL string
61+
GuestURL string
62+
pollURL string
63+
}
64+
65+
// StartLogin initiates the login flow and returns a session to wait for completion
66+
func (c *Client) StartLogin(deviceName string) (*LoginSession, error) {
67+
data := struct {
68+
DeviceName string `json:"device_name"`
69+
}{
70+
DeviceName: deviceName,
71+
}
72+
73+
jsonData, err := json.Marshal(data)
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
res, err := c.Post(context.Background(), "/2025-07-01/cli-auth", jsonData, nil)
79+
if err != nil {
80+
return nil, err
81+
}
82+
defer res.Body.Close()
83+
84+
body, err := io.ReadAll(res.Body)
85+
if err != nil {
86+
return nil, err
87+
}
88+
89+
var links struct {
90+
BrowserURL string `json:"browser_url"`
91+
PollURL string `json:"poll_url"`
92+
}
93+
err = json.Unmarshal(body, &links)
94+
if err != nil {
95+
return nil, err
96+
}
97+
98+
return &LoginSession{
99+
BrowserURL: links.BrowserURL,
100+
pollURL: links.PollURL,
101+
}, nil
102+
}
103+
104+
// StartGuestLogin initiates a guest login flow and returns a session to wait for completion
105+
func (c *Client) StartGuestLogin(deviceName string) (*GuestSession, error) {
106+
guest, err := c.CreateGuestUser(CreateGuestUserInput{
107+
DeviceName: deviceName,
108+
})
109+
if err != nil {
110+
return nil, err
111+
}
112+
113+
return &GuestSession{
114+
BrowserURL: guest.BrowserURL,
115+
GuestURL: guest.Url,
116+
pollURL: guest.PollURL,
117+
}, nil
118+
}
119+
120+
// WaitForAPIKey polls until the user completes login and returns the API key response
121+
func (s *LoginSession) WaitForAPIKey(interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) {
122+
return pollForAPIKey(s.pollURL, interval, maxAttempts)
123+
}
124+
125+
// WaitForAPIKey polls until the user completes login and returns the API key response
126+
func (s *GuestSession) WaitForAPIKey(interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) {
127+
return pollForAPIKey(s.pollURL, interval, maxAttempts)
128+
}
129+
130+
// PollForAPIKeyWithKey polls for login completion using a CLI API key (for interactive login)
131+
func (c *Client) PollForAPIKeyWithKey(apiKey string, interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) {
132+
pollURL := c.BaseURL.String() + "/2025-07-01/cli-auth/poll?key=" + apiKey
133+
return pollForAPIKey(pollURL, interval, maxAttempts)
134+
}
135+
136+
// ValidateAPIKey validates an API key and returns user/project information
137+
func (c *Client) ValidateAPIKey() (*ValidateAPIKeyResponse, error) {
138+
res, err := c.Get(context.Background(), "/2025-07-01/cli-auth/validate", "", nil)
139+
if err != nil {
140+
return nil, err
141+
}
142+
defer res.Body.Close()
143+
144+
body, err := io.ReadAll(res.Body)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
var response ValidateAPIKeyResponse
150+
err = json.Unmarshal(body, &response)
151+
if err != nil {
152+
return nil, err
153+
}
154+
155+
return &response, nil
156+
}
157+
158+
// pollForAPIKey polls Hookdeck at the specified interval until either the API key is available or we've reached the max attempts.
159+
// This is an internal function that creates its own client for polling with rate limit suppression.
160+
func pollForAPIKey(pollURL string, interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) {
161+
if maxAttempts == 0 {
162+
maxAttempts = maxAttemptsDefault
163+
}
164+
165+
if interval == 0 {
166+
interval = intervalDefault
167+
}
168+
169+
parsedURL, err := url.Parse(pollURL)
170+
if err != nil {
171+
return nil, err
172+
}
173+
174+
baseURL := &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}
175+
176+
client := &Client{
177+
BaseURL: baseURL,
178+
SuppressRateLimitErrors: true, // Rate limiting is expected during polling
179+
}
180+
181+
var count = 0
182+
currentInterval := interval
183+
consecutiveRateLimits := 0
184+
185+
for count < maxAttempts {
186+
res, err := client.Get(context.TODO(), parsedURL.Path, parsedURL.Query().Encode(), nil)
187+
188+
// Check if error is due to rate limiting (429)
189+
if err != nil && isRateLimitError(err) {
190+
consecutiveRateLimits++
191+
backoffInterval := calculateBackoff(currentInterval, consecutiveRateLimits)
192+
193+
log.WithFields(log.Fields{
194+
"attempt": count + 1,
195+
"max_attempts": maxAttempts,
196+
"backoff_interval": backoffInterval,
197+
"rate_limits": consecutiveRateLimits,
198+
}).Debug("Rate limited while polling, waiting before retry...")
199+
200+
time.Sleep(backoffInterval)
201+
currentInterval = backoffInterval
202+
count++
203+
continue
204+
}
205+
206+
// Reset back-off on successful request
207+
if err == nil {
208+
consecutiveRateLimits = 0
209+
currentInterval = interval
210+
}
211+
212+
// Handle other errors (non-429)
213+
if err != nil {
214+
return nil, err
215+
}
216+
217+
var response PollAPIKeyResponse
218+
219+
defer res.Body.Close()
220+
body, err := io.ReadAll(res.Body)
221+
if err != nil {
222+
return nil, err
223+
}
224+
err = json.Unmarshal(body, &response)
225+
if err != nil {
226+
return nil, err
227+
}
228+
229+
if response.Claimed {
230+
return &response, nil
231+
}
232+
233+
count++
234+
time.Sleep(currentInterval)
235+
}
236+
237+
return nil, errors.New("exceeded max attempts")
238+
}
239+
240+
// UpdateClient updates a CLI client's device name
241+
func (c *Client) UpdateClient(clientID string, input UpdateClientInput) error {
242+
jsonData, err := json.Marshal(input)
243+
if err != nil {
244+
return err
245+
}
246+
247+
_, err = c.Put(context.Background(), "/2025-07-01/cli/"+clientID, jsonData, nil)
248+
return err
249+
}
250+
251+
// isRateLimitError checks if an error is a 429 rate limit error
252+
func isRateLimitError(err error) bool {
253+
if err == nil {
254+
return false
255+
}
256+
errMsg := err.Error()
257+
return strings.Contains(errMsg, "429") || strings.Contains(errMsg, "Too Many Requests")
258+
}
259+
260+
// calculateBackoff implements exponential back-off with a maximum cap
261+
func calculateBackoff(baseInterval time.Duration, consecutiveFailures int) time.Duration {
262+
// Exponential: baseInterval * 2^consecutiveFailures
263+
backoff := baseInterval * time.Duration(1<<uint(consecutiveFailures))
264+
265+
// Cap at maxBackoffInterval
266+
if backoff > maxBackoffInterval {
267+
backoff = maxBackoffInterval
268+
}
269+
270+
return backoff
271+
}

pkg/hookdeck/ci.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func (c *Client) CreateCIClient(input CreateCIClientInput) (CIClient, error) {
2929
if err != nil {
3030
return CIClient{}, err
3131
}
32-
res, err := c.Post(context.Background(), "/cli-auth/ci", input_bytes, nil)
32+
res, err := c.Post(context.Background(), "/2025-07-01/cli-auth/ci", input_bytes, nil)
3333
if err != nil {
3434
return CIClient{}, err
3535
}

pkg/hookdeck/events.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package hookdeck
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
// RetryEvent retries an event by ID
9+
func (c *Client) RetryEvent(eventID string) error {
10+
retryURL := fmt.Sprintf("/2025-07-01/events/%s/retry", eventID)
11+
resp, err := c.Post(context.Background(), retryURL, []byte("{}"), nil)
12+
if err != nil {
13+
return err
14+
}
15+
defer resp.Body.Close()
16+
17+
return nil
18+
}

pkg/hookdeck/guest.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func (c *Client) CreateGuestUser(input CreateGuestUserInput) (GuestUser, error)
2424
if err != nil {
2525
return GuestUser{}, err
2626
}
27-
res, err := c.Post(context.Background(), "/cli/guest", input_bytes, nil)
27+
res, err := c.Post(context.Background(), "/2025-07-01/cli/guest", input_bytes, nil)
2828
if err != nil {
2929
return GuestUser{}, err
3030
}

pkg/hookdeck/projects.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type Project struct {
1313
}
1414

1515
func (c *Client) ListProjects() ([]Project, error) {
16-
res, err := c.Get(context.Background(), "/teams", "", nil)
16+
res, err := c.Get(context.Background(), "/2025-07-01/teams", "", nil)
1717
if err != nil {
1818
return []Project{}, err
1919
}

pkg/hookdeck/session.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func (c *Client) CreateSession(input CreateSessionInput) (Session, error) {
2929
if err != nil {
3030
return Session{}, err
3131
}
32-
res, err := c.Post(context.Background(), "/cli-sessions", input_bytes, nil)
32+
res, err := c.Post(context.Background(), "/2025-07-01/cli-sessions", input_bytes, nil)
3333
if err != nil {
3434
return Session{}, err
3535
}

pkg/listen/tui/model.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
tea "github.com/charmbracelet/bubbletea"
1212
hookdecksdk "github.com/hookdeck/hookdeck-go-sdk"
1313

14+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
1415
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
1516
)
1617

@@ -39,6 +40,9 @@ type Model struct {
3940
// Configuration
4041
cfg *Config
4142

43+
// API client (initialized once)
44+
client *hookdeck.Client
45+
4246
// Event history
4347
events []EventInfo
4448
selectedIndex int
@@ -83,8 +87,15 @@ type Config struct {
8387

8488
// NewModel creates a new TUI model
8589
func NewModel(cfg *Config) Model {
90+
parsedBaseURL, _ := url.Parse(cfg.APIBaseURL)
91+
8692
return Model{
87-
cfg: cfg,
93+
cfg: cfg,
94+
client: &hookdeck.Client{
95+
BaseURL: parsedBaseURL,
96+
APIKey: cfg.APIKey,
97+
ProjectID: cfg.ProjectID,
98+
},
8899
events: make([]EventInfo, 0),
89100
selectedIndex: -1,
90101
ready: false,

pkg/listen/tui/styles.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ var (
4141
Bold(true)
4242

4343
brandAccentStyle = lipgloss.NewStyle().
44-
Foreground(lipgloss.Color("4")) // Blue
44+
Foreground(lipgloss.Color("4")) // Blue
4545

4646
// Component styles
4747
selectionIndicatorStyle = lipgloss.NewStyle().

0 commit comments

Comments
 (0)