-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathmodel.go
More file actions
478 lines (418 loc) · 13.6 KB
/
model.go
File metadata and controls
478 lines (418 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
package build
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/stainless-api/stainless-api-cli/pkg/console"
"github.com/stainless-api/stainless-api-cli/pkg/git"
"github.com/stainless-api/stainless-api-cli/pkg/stainlessutils"
"github.com/stainless-api/stainless-api-go"
)
// downloadSemaphore limits concurrent git operations to avoid rate limiting,
// token expiration, and git protocol errors when fetching multiple repos.
var downloadSemaphore = make(chan struct{}, 2)
const (
maxDownloadRetries = 3
initialRetryDelay = time.Second
)
var ErrUserCancelled = errors.New("user cancelled")
type Model struct {
stainless.Build // Current build. This component will keep fetching it until the build is completed. A zero value is permitted.
Client stainless.Client
Ctx context.Context
Branch string // Optional branch name for git checkout
Downloads map[stainless.Target]DownloadStatus // When a BuildTarget has a commit available, this target will download it, if it has been specified in the initialization.
Err error // This will be populated if the model concludes with an error
}
type DownloadStatus struct {
Status string // one of "not_started" "in_progress" "completed"
// One of "success", "failure', or empty if Status not "completed"
Conclusion string
Path string
Error string // Error message if Conclusion is "failure"
}
type TickMsg time.Time
type FetchBuildMsg stainless.Build
type ErrorMsg error
type DownloadMsg struct {
Target stainless.Target
// One of "not_started", "in_progress", "completed"
Status string
// One of "success", "failure',
Conclusion string
Error string
}
func NewModel(client stainless.Client, ctx context.Context, build stainless.Build, branch string, downloadPaths map[stainless.Target]string) Model {
downloads := map[stainless.Target]DownloadStatus{}
for target, path := range downloadPaths {
downloads[target] = DownloadStatus{
Path: path,
Status: "not_started",
}
}
return Model{
Build: build,
Client: client,
Ctx: ctx,
Branch: branch,
Downloads: downloads,
}
}
func (m Model) Init() tea.Cmd {
return func() tea.Msg {
return TickMsg(time.Now())
}
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
cmds := []tea.Cmd{}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
m.Err = ErrUserCancelled
cmds = append(cmds, tea.Quit)
}
case TickMsg:
if m.Build.ID != "" {
cmds = append(cmds, m.fetchBuildStatus())
}
cmds = append(cmds, tea.Tick(time.Second, func(t time.Time) tea.Msg {
return TickMsg(t)
}))
case DownloadMsg:
download := m.Downloads[msg.Target]
download.Status = msg.Status
download.Conclusion = msg.Conclusion
download.Error = msg.Error
m.Downloads[msg.Target] = download
case FetchBuildMsg:
m.Build = stainless.Build(msg)
buildObj := stainlessutils.NewBuild(m.Build)
languages := buildObj.Languages()
for _, target := range languages {
buildTarget := buildObj.BuildTarget(target)
if buildTarget == nil {
continue
}
// Start download when the commit step is done, and m.Downloads[target] is specified
downloadable := buildTarget.IsCommitCompleted() && buildTarget.IsGoodCommitConclusion()
if download, ok := m.Downloads[target]; ok && downloadable && download.Status == "not_started" {
download.Status = "in_progress"
cmds = append(cmds, m.downloadTarget(target))
m.Downloads[target] = download
}
}
case ErrorMsg:
m.Err = msg
}
return m, tea.Batch(cmds...)
}
func (m Model) downloadTarget(target stainless.Target) tea.Cmd {
return func() tea.Msg {
// Acquire semaphore to limit concurrent git operations.
// This prevents race conditions with GitHub rate limiting,
// auth token expiration, and git protocol errors.
downloadSemaphore <- struct{}{}
defer func() { <-downloadSemaphore }()
var lastErr error
retryDelay := initialRetryDelay
for attempt := 0; attempt < maxDownloadRetries; attempt++ {
if attempt > 0 {
// Wait before retry with exponential backoff
select {
case <-m.Ctx.Done():
return ErrorMsg(m.Ctx.Err())
case <-time.After(retryDelay):
}
retryDelay *= 2
}
// Request fresh auth URL for each attempt (tokens can expire)
params := stainless.BuildTargetOutputGetParams{
BuildID: m.Build.ID,
Target: stainless.BuildTargetOutputGetParamsTarget(target),
Type: "source",
Output: "git",
}
outputRes, err := m.Client.Builds.TargetOutputs.Get(m.Ctx, params)
if err != nil {
lastErr = err
continue
}
err = PullOutput(outputRes.Output, outputRes.URL, outputRes.Ref, m.Branch, m.Downloads[target].Path, console.NewGroup(true))
if err != nil {
lastErr = err
// Check if error is retryable (network/git protocol errors)
if isRetryableGitError(err) {
continue
}
// Non-retryable error, fail immediately
return DownloadMsg{
Target: target,
Status: "completed",
Conclusion: "failure",
Error: err.Error(),
}
}
// Success
return DownloadMsg{
Target: target,
Status: "completed",
Conclusion: "success",
}
}
// All retries exhausted
return DownloadMsg{
Target: target,
Status: "completed",
Conclusion: "failure",
Error: fmt.Sprintf("failed after %d attempts: %v", maxDownloadRetries, lastErr),
}
}
}
// isRetryableGitError checks if a git error is transient and worth retrying.
// "Repository not found" is included because it often indicates an expired/invalid
// auth token rather than a truly missing repo - retrying with a fresh auth URL often succeeds.
func isRetryableGitError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
retryablePatterns := []string{
"RPC failed",
"curl",
"HTTP 404",
"HTTP 429",
"HTTP 500",
"HTTP 502",
"HTTP 503",
"expected flush",
"Connection reset",
"Connection refused",
"timeout",
"Temporary failure",
"Repository not found", // Often means expired auth token, not actually missing
"not found", // Generic "not found" errors
}
for _, pattern := range retryablePatterns {
if strings.Contains(errStr, pattern) {
return true
}
}
return false
}
// downloadSemaphoreMu protects resize operations on the semaphore (for testing)
var downloadSemaphoreMu sync.Mutex
// SetMaxConcurrentDownloads allows adjusting the concurrency limit (mainly for testing)
func SetMaxConcurrentDownloads(n int) {
downloadSemaphoreMu.Lock()
defer downloadSemaphoreMu.Unlock()
downloadSemaphore = make(chan struct{}, n)
}
func (m Model) fetchBuildStatus() tea.Cmd {
return func() tea.Msg {
build, err := m.Client.Builds.Get(m.Ctx, m.Build.ID)
if err != nil {
return ErrorMsg(fmt.Errorf("failed to get build status: %v", err))
}
return FetchBuildMsg(*build)
}
}
// stripHTTPAuth removes HTTP authentication credentials from a URL for display purposes
func stripHTTPAuth(urlStr string) string {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
// Remove user info (username:password)
parsedURL.User = nil
return parsedURL.String()
}
// extractFilenameFromURL extracts the filename from just the URL path (without query parameters)
func extractFilenameFromURL(urlStr string) string {
// Parse URL to remove query parameters
parsedURL, err := url.Parse(urlStr)
if err != nil {
// If URL parsing fails, use the original approach
filename := filepath.Base(urlStr)
if filename == "." || filename == "/" || filename == "" {
return "download"
}
return filename
}
// Extract filename from URL path (without query parameters)
filename := filepath.Base(parsedURL.Path)
if filename == "." || filename == "/" || filename == "" {
return "download"
}
return filename
}
// extractFilename extracts the filename from a URL and HTTP response headers
func extractFilename(urlStr string, resp *http.Response) string {
// First, try to get filename from Content-Disposition header
if contentDisp := resp.Header.Get("Content-Disposition"); contentDisp != "" {
// Parse Content-Disposition header for filename
// Format: attachment; filename="example.txt" or attachment; filename=example.txt
if strings.Contains(contentDisp, "filename=") {
parts := strings.Split(contentDisp, "filename=")
if len(parts) > 1 {
filename := strings.TrimSpace(parts[1])
// Remove quotes if present
filename = strings.Trim(filename, `"`)
// Remove any additional parameters after semicolon
if idx := strings.Index(filename, ";"); idx != -1 {
filename = filename[:idx]
}
filename = strings.TrimSpace(filename)
if filename != "" {
return filename
}
}
}
}
// Fallback to URL path parsing
return extractFilenameFromURL(urlStr)
}
// checkoutBranchIfSafe attempts to checkout a branch if it's safe to do so.
// Returns true if the branch was checked out, false if we should checkout the ref instead.
func checkoutBranchIfSafe(targetDir, branch, ref string, targetGroup console.Group) (bool, error) {
remoteBranch := "origin/" + branch
// Check if the remote branch exists and matches the ref
remoteSHA, err := git.RevParse(targetDir, remoteBranch)
if err != nil || remoteSHA != ref {
// Remote branch doesn't exist or doesn't match ref, checkout ref instead
return false, nil
}
// Check if local branch exists
localSHA, err := git.RevParse(targetDir, branch)
if err != nil {
// Local branch doesn't exist, create it tracking the remote
targetGroup.Property("checking out branch", branch)
if err := git.Checkout(targetDir, "-b", branch, remoteBranch); err != nil {
return false, err
}
return true, nil
}
// Local branch exists - only checkout if it points to the same SHA as ref
if localSHA == ref {
targetGroup.Property("checking out branch", branch)
if err := git.Checkout(targetDir, branch); err != nil {
return false, err
}
return true, nil
}
// Local branch exists but points to a different SHA, checkout ref instead to be safe
return false, nil
}
// PullOutput handles downloading or cloning a build target output
func PullOutput(output, url, ref, branch, targetDir string, targetGroup console.Group) error {
switch output {
case "git":
// Extract repository name from git URL for directory name
// Handle formats like:
// - https://github.com/owner/repo.git
// - https://github.com/owner/repo
// - git@github.com:owner/repo.git
if targetDir == "" {
targetDir = filepath.Base(url)
}
// Remove .git suffix if present
targetDir = strings.TrimSuffix(targetDir, ".git")
// Handle empty or invalid names
if targetDir == "" || targetDir == "." || targetDir == "/" {
targetDir = "repository"
}
// Check if directory exists
if _, err := os.Stat(targetDir); err == nil {
// Check if it's a git directory
if _, err := os.Stat(filepath.Join(targetDir, ".git")); err != nil {
// Not a git directory, return error
return fmt.Errorf("directory %s already exists and is not a git repository", targetDir)
}
} else {
// Directory doesn't exist, create it and initialize git repo
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", targetDir, err)
}
// Initialize git repository
if err := git.Init(targetDir); err != nil {
return err
}
}
{
// Check if origin remote exists, add it if not present
if _, err := git.RemoteGetURL(targetDir, "origin"); err != nil {
// Origin doesn't exist, add it with stripped auth
targetGroup.Property("adding remote origin", stripHTTPAuth(url))
if err := git.RemoteAdd(targetDir, "origin", stripHTTPAuth(url)); err != nil {
return err
}
}
targetGroup.Property("fetching from", stripHTTPAuth(url))
// Fetch the specific ref
if err := git.Fetch(targetDir, url, ref); err != nil {
return err
}
// Also fetch the branch if provided, so we can check if it points to the same ref
if branch != "" {
// Branch fetch is best-effort - ignore errors
_ = git.Fetch(targetDir, url, branch+":refs/remotes/origin/"+branch)
}
}
// Checkout the specific ref or branch
{
checkedOutBranch := false
if branch != "" {
var err error
checkedOutBranch, err = checkoutBranchIfSafe(targetDir, branch, ref, targetGroup)
if err != nil {
return err
}
}
if !checkedOutBranch {
// Checkout the ref directly (detached HEAD)
targetGroup.Property("checking out ref", ref)
if err := git.Checkout(targetDir, ref); err != nil {
return err
}
}
}
case "url":
// Download the file directly to current directory
targetGroup.Property("downloading url", url)
// Download the file
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status: %s", resp.Status)
}
// Extract filename from URL and Content-Disposition header
filename := extractFilename(url, resp)
targetGroup.Property("downloaded", filename)
// Create the output file in current directory
outFile, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create output file: %v", err)
}
defer outFile.Close()
// Copy the response body to the output file
_, err = io.Copy(outFile, resp.Body)
if err != nil {
return fmt.Errorf("failed to save downloaded file: %v", err)
}
default:
return fmt.Errorf("unsupported output type: %s. Supported types are 'git' and 'url'", output)
}
return nil
}