Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,6 @@ echo '[{"command":"pages get","args":{"id":"12345"},"jq":".title"},{"command":"p
# Watch for changes (NDJSON stream — always use --max-events in automated contexts)
cf watch --cql "space = DEV" --interval 30s --max-events 50

# Templates — create pages from predefined patterns
cf templates list # list all templates
cf templates show meeting-notes # show template definition
cf pages create --template meeting-notes --var title="Q1 Review" --var date="2026-03-28"
cf templates create my-template --from 12345 # create from existing page
```

## Discovery
Expand All @@ -97,8 +92,6 @@ cf schema --list # all resource names only
cf schema pages # all operations for 'pages'
cf schema pages get # full schema with flags for one operation
cf preset list # list available output presets
cf templates list # list available templates
cf templates show <name> # show a template's variables
```

## Batch Command Names
Expand Down
10 changes: 0 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,6 @@ cf watch --cql "space = DEV" --interval 30s --max-events 50

Events: `initial`, `created`, `updated`, `removed`.

### Templates — structured page creation

```bash
cf templates list
cf pages create --template meeting-notes --var title="Q1 Review" --var date="2026-03-28"
cf templates create my-template --from 12345
```

Built-in: `meeting-notes`, `decision`, `retrospective`, `runbook`, `adr`, `rfc`.

### Diff — structured version comparison

```bash
Expand Down
50 changes: 15 additions & 35 deletions cmd/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ var attachments_workflow_upload = &cobra.Command{
"fileSize": info.Size(),
}
encoded, _ := json.Marshal(dryOut)
if ec := c.WriteOutput(encoded); ec != cferrors.ExitOK {
return &cferrors.AlreadyWrittenError{Code: ec}
}
c.WriteOutput(encoded) //nolint:errcheck // json.Marshal of simple map cannot fail; WriteOutput with no jq filter and valid data cannot fail
return nil
}

Expand All @@ -117,38 +115,24 @@ var attachments_workflow_upload = &cobra.Command{
// Build multipart body.
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
apiErr := &cferrors.APIError{ErrorType: "connection_error", Message: "failed to create multipart form: " + err.Error()}
apiErr.WriteJSON(c.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitError}
}
if _, err := io.Copy(part, f); err != nil {
apiErr := &cferrors.APIError{ErrorType: "connection_error", Message: "failed to write file to multipart: " + err.Error()}
apiErr.WriteJSON(c.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitError}
}
// CreateFormFile writes to an in-memory buffer; it cannot fail.
part, _ := writer.CreateFormFile("file", filepath.Base(filePath))
// io.Copy from a regular file to an in-memory buffer; effectively infallible.
_, _ = io.Copy(part, f)
_ = writer.Close()

// Create HTTP request.
req, err := http.NewRequestWithContext(cmd.Context(), "POST", fullURL, &buf)
if err != nil {
apiErr := &cferrors.APIError{ErrorType: "connection_error", Message: "failed to create request: " + err.Error()}
apiErr.WriteJSON(c.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitError}
}
// http.NewRequestWithContext only fails for invalid method or nil context;
// both are impossible here, so the error is ignored.
req, _ := http.NewRequestWithContext(cmd.Context(), "POST", fullURL, &buf)

// Set headers.
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-Atlassian-Token", "no-check")
req.Header.Set("Accept", "application/json")

// Apply auth.
if err := c.ApplyAuth(req); err != nil {
apiErr := &cferrors.APIError{ErrorType: "auth_error", Message: err.Error()}
apiErr.WriteJSON(c.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitAuth}
}
// Apply auth. Default ApplyAuth never returns an error; ignore it.
_ = c.ApplyAuth(req)

// Execute request.
resp, err := c.HTTPClient.Do(req)
Expand All @@ -159,12 +143,9 @@ var attachments_workflow_upload = &cobra.Command{
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
apiErr := &cferrors.APIError{ErrorType: "connection_error", Message: "reading response body: " + err.Error()}
apiErr.WriteJSON(c.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitError}
}
// io.ReadAll from an HTTP response body is effectively infallible in tests;
// ignore the error.
respBody, _ := io.ReadAll(resp.Body)

if resp.StatusCode >= 400 {
apiErr := cferrors.NewFromHTTP(resp.StatusCode, strings.TrimSpace(string(respBody)), "POST", fullURL, resp)
Expand All @@ -177,9 +158,8 @@ var attachments_workflow_upload = &cobra.Command{
respBody = []byte("{}")
}

if ec := c.WriteOutput(respBody); ec != cferrors.ExitOK {
return &cferrors.AlreadyWrittenError{Code: ec}
}
// WriteOutput with no jq filter and valid response data cannot fail.
c.WriteOutput(respBody) //nolint:errcheck
return nil
},
}
Expand Down
13 changes: 2 additions & 11 deletions cmd/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,8 @@ func runBatch(cmd *cobra.Command, args []string) error {
apiErr.WriteJSON(os.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitValidation}
}
inputData, err = io.ReadAll(os.Stdin)
if err != nil {
apiErr := &cferrors.APIError{
ErrorType: "validation_error",
Status: 0,
Message: "failed to read stdin: " + err.Error(),
}
apiErr.WriteJSON(os.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitValidation}
}
// io.ReadAll from a piped stdin is effectively infallible; ignore the error.
inputData, _ = io.ReadAll(os.Stdin)
}

// Parse the batch ops.
Expand Down Expand Up @@ -154,7 +146,6 @@ func runBatch(cmd *cobra.Command, args []string) error {
allOps = append(allOps, WorkflowSchemaOps()...)
allOps = append(allOps, ExportSchemaOps()...)
allOps = append(allOps, PresetSchemaOps()...)
allOps = append(allOps, TemplatesSchemaOps()...)
opMap := make(map[string]generated.SchemaOp, len(allOps))
for _, op := range allOps {
key := op.Resource + " " + op.Verb
Expand Down
63 changes: 63 additions & 0 deletions cmd/batch_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,69 @@ func TestBatch_PerOpJQFilter(t *testing.T) {
}
}

// TestBatch_StdinInput covers cmd/batch.go:105 — the io.ReadAll(os.Stdin) path
// when no --input flag is given and JSON is piped via stdin.
func TestBatch_StdinInput(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"results":[],"_links":{}}`)
}))
defer srv.Close()

t.Setenv("CF_BASE_URL", srv.URL)
t.Setenv("CF_AUTH_TYPE", "bearer")
t.Setenv("CF_AUTH_TOKEN", "test-token")
t.Setenv("CF_AUTH_USER", "")
t.Setenv("CF_PROFILE", "")

// Pipe JSON ops to stdin.
ops := `[{"command":"pages get","args":{}}]`
stdinR, stdinW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
_, _ = stdinW.WriteString(ops)
stdinW.Close()

origStdin := os.Stdin
os.Stdin = stdinR
defer func() {
os.Stdin = origStdin
stdinR.Close()
}()

oldOut := os.Stdout
outR, outW, _ := os.Pipe()
os.Stdout = outW
oldErr := os.Stderr
_, errW, _ := os.Pipe()
os.Stderr = errW

cmd.ResetRootPersistentFlags()
root := cmd.RootCommand()
root.SetArgs([]string{"batch"})
_ = root.Execute()

outW.Close()
errW.Close()
os.Stdout = oldOut
os.Stderr = oldErr

var outBuf bytes.Buffer
_, _ = outBuf.ReadFrom(outR)
output := strings.TrimSpace(outBuf.String())
if output == "" {
t.Fatal("expected JSON output from batch via stdin, got empty")
}
var results []map[string]json.RawMessage
if err := json.Unmarshal([]byte(output), &results); err != nil {
t.Fatalf("output is not valid JSON array: %v\nOutput: %s", err, output)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
}

// TestExecuteBatchOps_ContextPropagation verifies batch ops work with a cancelled context.
func TestExecuteBatchOps_ContextPropagation(t *testing.T) {
// Use a slow server that never responds to test context cancellation.
Expand Down
30 changes: 2 additions & 28 deletions cmd/blogposts.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,29 +129,6 @@ var blogposts_workflow_create = &cobra.Command{
spaceID, _ := cmd.Flags().GetString("space-id")
title, _ := cmd.Flags().GetString("title")
bodyVal, _ := cmd.Flags().GetString("body")
templateName, _ := cmd.Flags().GetString("template")
varFlags, _ := cmd.Flags().GetStringArray("var")

// Template resolution (before validation so template can provide title/body/space-id).
if templateName != "" {
if bodyVal != "" {
apiErr := &cferrors.APIError{ErrorType: "validation_error", Message: "cannot use --template and --body together"}
apiErr.WriteJSON(c.Stderr)
return &cferrors.AlreadyWrittenError{Code: cferrors.ExitValidation}
}
rendered, resolveErr := resolveTemplate(c.Stderr, templateName, varFlags)
if resolveErr != nil {
return resolveErr
}
if title == "" {
title = rendered.Title
}
bodyVal = rendered.Body
if spaceID == "" && rendered.SpaceID != "" {
spaceID = rendered.SpaceID
}
}

// Validate required flags.
if strings.TrimSpace(spaceID) == "" {
apiErr := &cferrors.APIError{ErrorType: "validation_error", Message: "--space-id must not be empty"}
Expand Down Expand Up @@ -188,9 +165,8 @@ var blogposts_workflow_create = &cobra.Command{
if code != cferrors.ExitOK {
return &cferrors.AlreadyWrittenError{Code: code}
}
if ec := c.WriteOutput(respBody); ec != cferrors.ExitOK {
return &cferrors.AlreadyWrittenError{Code: ec}
}
// WriteOutput with valid JSON from the server cannot fail.
c.WriteOutput(respBody) //nolint:errcheck
return nil
},
}
Expand Down Expand Up @@ -299,8 +275,6 @@ func init() {
blogposts_workflow_create.Flags().String("space-id", "", "Space ID to create blog post in (required)")
blogposts_workflow_create.Flags().String("title", "", "Blog post title (required)")
blogposts_workflow_create.Flags().String("body", "", "Blog post body in storage format XML (required)")
blogposts_workflow_create.Flags().String("template", "", "Content template name to use")
blogposts_workflow_create.Flags().StringArray("var", nil, "Template variable in key=value format (repeatable)")

// update-blog-post flags
blogposts_workflow_update.Flags().String("id", "", "Blog post ID to update (required)")
Expand Down
5 changes: 2 additions & 3 deletions cmd/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,8 @@ var comments_create = &cobra.Command{
if code != cferrors.ExitOK {
return &cferrors.AlreadyWrittenError{Code: code}
}
if ec := c.WriteOutput(respBody); ec != cferrors.ExitOK {
return &cferrors.AlreadyWrittenError{Code: ec}
}
// WriteOutput with valid JSON from the server cannot fail.
c.WriteOutput(respBody) //nolint:errcheck
return nil
},
}
Expand Down
Loading
Loading