Skip to content

Commit 8e498b0

Browse files
committed
Add build info/expire commands and tests
Implement build detail and expiration endpoints, add CLI subcommands, and cover new paths with HTTP/integration/output tests. Route build listings through /v1/builds with app filtering to support sorting, update docs and plan notes, and narrow gitignore rules for local binaries.
1 parent 3078fac commit 8e498b0

File tree

10 files changed

+436
-22
lines changed

10 files changed

+436
-22
lines changed

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*.dll
88
*.so
99
*.dylib
10-
asc
11-
asc-debug
10+
/asc
11+
/asc-debug
1212

1313
# Test binary, built with `go test -c`
1414
*.test

Agents.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ asc apps --json
7474
asc apps --sort name --json
7575
asc builds --app "123456789" --json
7676
asc builds --app "123456789" --sort -uploadedDate --json
77+
asc builds info --build "BUILD_ID" --json
78+
asc builds expire --build "BUILD_ID" --json
7779

7880
# Utilities
7981
asc version

PLAN.md

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ A fast, AI-agent-friendly CLI for App Store Connect that enables developers to s
2424
- Feedback/crash/review endpoints aligned to ASC OpenAPI spec
2525
- Code compiles and unit tests run
2626
- Live API validation: feedback/crashes return data; reviews may be empty if no reviews exist
27+
- Apps/builds list commands working with manual pagination
28+
- Sorting supported for apps/builds/reviews/feedback/crashes
29+
- Build info and build expiration commands available
30+
- Installer script available for latest release downloads
31+
- HTTP-level client tests and CLI/output tests added
2732

2833
### What Doesn't Work Yet
2934

@@ -175,37 +180,35 @@ asc reviews --app "123456789" --json
175180

176181
**Goal:** Add commands for managing apps and builds
177182

178-
#### Features
183+
#### Features (Current + Remaining)
179184

180-
1. **List Apps**
185+
1. **List Apps**
181186
```bash
182-
asc apps list
183-
asc apps list --json
187+
asc apps --json
184188
```
185189

186-
2. **List Builds**
190+
2. **List Builds**
187191
```bash
188-
asc builds list --app "APP_ID"
189-
asc builds list --app "APP_ID" --json
192+
asc builds --app "APP_ID" --json
190193
```
191194

192-
3. **Build Details**
195+
3. **Build Details**
193196
```bash
194197
asc builds info --build "BUILD_ID"
195198
```
196199

197-
4. **Expire Build**
200+
4. **Expire Build**
198201
```bash
199-
asc builds expire --build "BUILD_ID" --app "APP_ID"
202+
asc builds expire --build "BUILD_ID"
200203
```
201204

202205
#### Technical Tasks
203206

204-
- [ ] Implement `GET /v1/apps`
205-
- [ ] Implement `GET /v1/apps/{id}/builds`
206-
- [ ] Implement `PATCH /v1/builds/{id}`
207+
- [x] Implement `GET /v1/apps`
208+
- [x] Implement `GET /v1/apps/{id}/builds`
209+
- [x] Implement `GET /v1/builds/{id}`
210+
- [x] Implement `PATCH /v1/builds/{id}`
207211
- [ ] Add build expiration workflow
208-
- [ ] Add pagination support
209212

210213
---
211214

@@ -410,7 +413,7 @@ github.com/goreleaser/nfpm/v2 - Packaging via `go run` (optional)
410413

411414
**Phase 1: Foundation - IMPLEMENTED** (validated locally)
412415

413-
Next: Add auto-pagination, more filters (build/tester/platform), and mockable integration tests
416+
Next: Add auto-pagination and beta management commands
414417

415418
## Known Issues
416419

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ asc builds --app "123456789" --sort -uploadedDate --json
137137
# Fetch next page
138138
asc apps --next "<links.next>" --json
139139
asc builds --next "<links.next>" --json
140+
141+
# Build details
142+
asc builds info --build "BUILD_ID" --json
143+
144+
# Expire a build (irreversible)
145+
asc builds expire --build "BUILD_ID" --json
140146
```
141147

142148
### Utilities

cmd/commands.go

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,16 @@ Examples:
381381
asc builds --app "123456789" --limit 10 --json
382382
asc builds --app "123456789" --sort -uploadedDate --json
383383
asc builds --app "123456789" --output table
384-
asc builds --next "<links.next>" --json`,
384+
asc builds --next "<links.next>" --json
385+
386+
Subcommands:
387+
info Show build details
388+
expire Expire a build for TestFlight`,
385389
FlagSet: fs,
390+
Subcommands: []*ffcli.Command{
391+
BuildsInfoCommand(),
392+
BuildsExpireCommand(),
393+
},
386394
Exec: func(ctx context.Context, args []string) error {
387395
if *limit != 0 && (*limit < 1 || *limit > 200) {
388396
return fmt.Errorf("builds: --limit must be between 1 and 200")
@@ -431,6 +439,104 @@ Examples:
431439
}
432440
}
433441

442+
// BuildsInfoCommand returns a build detail subcommand.
443+
func BuildsInfoCommand() *ffcli.Command {
444+
fs := flag.NewFlagSet("builds info", flag.ExitOnError)
445+
446+
buildID := fs.String("build", "", "Build ID")
447+
output := fs.String("output", "json", "Output format: json (default), table, markdown")
448+
jsonFlag := fs.Bool("json", false, "Output in JSON format (shorthand)")
449+
pretty := fs.Bool("pretty", false, "Pretty-print JSON output")
450+
451+
return &ffcli.Command{
452+
Name: "info",
453+
ShortUsage: "asc builds info [flags]",
454+
ShortHelp: "Show build details.",
455+
LongHelp: `Show build details.
456+
457+
Examples:
458+
asc builds info --build "BUILD_ID" --json`,
459+
FlagSet: fs,
460+
Exec: func(ctx context.Context, args []string) error {
461+
if strings.TrimSpace(*buildID) == "" {
462+
fmt.Fprintln(os.Stderr, "Error: --build is required")
463+
fs.Usage()
464+
return flag.ErrHelp
465+
}
466+
467+
client, err := getASCClient()
468+
if err != nil {
469+
return fmt.Errorf("builds info: %w", err)
470+
}
471+
472+
requestCtx, cancel := contextWithTimeout(ctx)
473+
defer cancel()
474+
475+
build, err := client.GetBuild(requestCtx, strings.TrimSpace(*buildID))
476+
if err != nil {
477+
return fmt.Errorf("builds info: failed to fetch: %w", err)
478+
}
479+
480+
format := *output
481+
if *jsonFlag {
482+
format = "json"
483+
}
484+
485+
return printOutput(build, format, *pretty)
486+
},
487+
}
488+
}
489+
490+
// BuildsExpireCommand returns a build expiration subcommand.
491+
func BuildsExpireCommand() *ffcli.Command {
492+
fs := flag.NewFlagSet("builds expire", flag.ExitOnError)
493+
494+
buildID := fs.String("build", "", "Build ID")
495+
output := fs.String("output", "json", "Output format: json (default), table, markdown")
496+
jsonFlag := fs.Bool("json", false, "Output in JSON format (shorthand)")
497+
pretty := fs.Bool("pretty", false, "Pretty-print JSON output")
498+
499+
return &ffcli.Command{
500+
Name: "expire",
501+
ShortUsage: "asc builds expire [flags]",
502+
ShortHelp: "Expire a build for TestFlight.",
503+
LongHelp: `Expire a build for TestFlight.
504+
505+
This action is irreversible for the specified build.
506+
507+
Examples:
508+
asc builds expire --build "BUILD_ID" --json`,
509+
FlagSet: fs,
510+
Exec: func(ctx context.Context, args []string) error {
511+
if strings.TrimSpace(*buildID) == "" {
512+
fmt.Fprintln(os.Stderr, "Error: --build is required")
513+
fs.Usage()
514+
return flag.ErrHelp
515+
}
516+
517+
client, err := getASCClient()
518+
if err != nil {
519+
return fmt.Errorf("builds expire: %w", err)
520+
}
521+
522+
requestCtx, cancel := contextWithTimeout(ctx)
523+
defer cancel()
524+
525+
build, err := client.ExpireBuild(requestCtx, strings.TrimSpace(*buildID))
526+
if err != nil {
527+
return fmt.Errorf("builds expire: failed to expire: %w", err)
528+
}
529+
530+
format := *output
531+
if *jsonFlag {
532+
format = "json"
533+
}
534+
535+
return printOutput(build, format, *pretty)
536+
},
537+
}
538+
}
539+
434540
// VersionCommand returns a version subcommand
435541
func VersionCommand(version string) *ffcli.Command {
436542
return &ffcli.Command{

cmd/commands_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,45 @@ func TestUnknownCommandPrintsHelpError(t *testing.T) {
128128
t.Fatalf("expected unknown command message, got %q", stderr)
129129
}
130130
}
131+
132+
func TestBuildsInfoRequiresBuildID(t *testing.T) {
133+
root := RootCommand("1.2.3")
134+
135+
stdout, stderr := captureOutput(t, func() {
136+
if err := root.Parse([]string{"builds", "info"}); err != nil {
137+
t.Fatalf("parse error: %v", err)
138+
}
139+
err := root.Run(context.Background())
140+
if !errors.Is(err, flag.ErrHelp) {
141+
t.Fatalf("expected ErrHelp, got %v", err)
142+
}
143+
})
144+
145+
if stdout != "" {
146+
t.Fatalf("expected empty stdout, got %q", stdout)
147+
}
148+
if !strings.Contains(stderr, "--build is required") {
149+
t.Fatalf("expected missing build error, got %q", stderr)
150+
}
151+
}
152+
153+
func TestBuildsExpireRequiresBuildID(t *testing.T) {
154+
root := RootCommand("1.2.3")
155+
156+
stdout, stderr := captureOutput(t, func() {
157+
if err := root.Parse([]string{"builds", "expire"}); err != nil {
158+
t.Fatalf("parse error: %v", err)
159+
}
160+
err := root.Run(context.Background())
161+
if !errors.Is(err, flag.ErrHelp) {
162+
t.Fatalf("expected ErrHelp, got %v", err)
163+
}
164+
})
165+
166+
if stdout != "" {
167+
t.Fatalf("expected empty stdout, got %q", stdout)
168+
}
169+
if !strings.Contains(stderr, "--build is required") {
170+
t.Fatalf("expected missing build error, got %q", stderr)
171+
}
172+
}

internal/asc/client.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ type Response[T any] struct {
4949
Links Links `json:"links,omitempty"`
5050
}
5151

52+
// SingleResponse is a generic ASC API response wrapper for single resources.
53+
type SingleResponse[T any] struct {
54+
Data Resource[T] `json:"data"`
55+
Links Links `json:"links,omitempty"`
56+
}
57+
5258
// FeedbackAttributes describes beta feedback screenshot submissions.
5359
type FeedbackAttributes struct {
5460
CreatedDate string `json:"createdDate"`
@@ -97,6 +103,9 @@ type AppsResponse = Response[AppAttributes]
97103
// BuildsResponse is the response from builds endpoint.
98104
type BuildsResponse = Response[BuildAttributes]
99105

106+
// BuildResponse is the response from build detail/updates.
107+
type BuildResponse = SingleResponse[BuildAttributes]
108+
100109
type listQuery struct {
101110
limit int
102111
nextURL string
@@ -740,11 +749,12 @@ func (c *Client) GetBuilds(ctx context.Context, appID string, opts ...BuildsOpti
740749
opt(query)
741750
}
742751

743-
path := fmt.Sprintf("/v1/apps/%s/builds", appID)
752+
path := "/v1/builds"
744753
if query.nextURL != "" {
745754
path = query.nextURL
746755
} else {
747756
values := url.Values{}
757+
values.Set("filter[app]", appID)
748758
if query.sort != "" {
749759
values.Set("sort", query.sort)
750760
}
@@ -769,6 +779,56 @@ func (c *Client) GetBuilds(ctx context.Context, appID string, opts ...BuildsOpti
769779
return &response, nil
770780
}
771781

782+
// GetBuild retrieves a single build by ID.
783+
func (c *Client) GetBuild(ctx context.Context, buildID string) (*BuildResponse, error) {
784+
path := fmt.Sprintf("/v1/builds/%s", buildID)
785+
data, err := c.do(ctx, "GET", path, nil)
786+
if err != nil {
787+
return nil, err
788+
}
789+
790+
var response BuildResponse
791+
if err := json.Unmarshal(data, &response); err != nil {
792+
return nil, fmt.Errorf("failed to parse response: %w", err)
793+
}
794+
795+
return &response, nil
796+
}
797+
798+
// ExpireBuild expires a build for TestFlight testing.
799+
func (c *Client) ExpireBuild(ctx context.Context, buildID string) (*BuildResponse, error) {
800+
payload := struct {
801+
Data struct {
802+
Type string `json:"type"`
803+
ID string `json:"id"`
804+
Attributes struct {
805+
Expired bool `json:"expired"`
806+
} `json:"attributes"`
807+
} `json:"data"`
808+
}{}
809+
payload.Data.Type = "builds"
810+
payload.Data.ID = buildID
811+
payload.Data.Attributes.Expired = true
812+
813+
body, err := BuildRequestBody(payload)
814+
if err != nil {
815+
return nil, err
816+
}
817+
818+
path := fmt.Sprintf("/v1/builds/%s", buildID)
819+
data, err := c.do(ctx, "PATCH", path, body)
820+
if err != nil {
821+
return nil, err
822+
}
823+
824+
var response BuildResponse
825+
if err := json.Unmarshal(data, &response); err != nil {
826+
return nil, fmt.Errorf("failed to parse response: %w", err)
827+
}
828+
829+
return &response, nil
830+
}
831+
772832
// Links represents pagination links
773833
type Links struct {
774834
Self string `json:"self,omitempty"`
@@ -802,6 +862,8 @@ func PrintMarkdown(data interface{}) error {
802862
return printAppsMarkdown(v)
803863
case *BuildsResponse:
804864
return printBuildsMarkdown(v)
865+
case *BuildResponse:
866+
return printBuildsMarkdown(&BuildsResponse{Data: []Resource[BuildAttributes]{v.Data}})
805867
default:
806868
return PrintJSON(data)
807869
}
@@ -820,6 +882,8 @@ func PrintTable(data interface{}) error {
820882
return printAppsTable(v)
821883
case *BuildsResponse:
822884
return printBuildsTable(v)
885+
case *BuildResponse:
886+
return printBuildsTable(&BuildsResponse{Data: []Resource[BuildAttributes]{v.Data}})
823887
default:
824888
return PrintJSON(data)
825889
}

0 commit comments

Comments
 (0)