diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 27f1a0e..a9aa639 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25.12' + go-version: '1.25.13' - name: Build candidate and install external quality gates run: | diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8d9f8bc..4da9ca7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25.12' + go-version: '1.25.13' cache: true - name: Install govulncheck diff --git a/actions/annotate.go b/actions/annotate.go index 1dcc6ef..2a34527 100644 --- a/actions/annotate.go +++ b/actions/annotate.go @@ -1,3 +1,5 @@ +// CLI: pipekit annotate + package actions import ( diff --git a/actions/archive.go b/actions/archive.go index 89be0a2..4fd7c92 100644 --- a/actions/archive.go +++ b/actions/archive.go @@ -1,3 +1,5 @@ +// CLI: pipekit archive + package actions import ( diff --git a/actions/artifact.go b/actions/artifact.go index 2c68f6f..4585615 100644 --- a/actions/artifact.go +++ b/actions/artifact.go @@ -1,3 +1,5 @@ +// CLI: pipekit artifact + package actions import ( diff --git a/actions/assert.go b/actions/assert.go index 927fe74..a20496f 100644 --- a/actions/assert.go +++ b/actions/assert.go @@ -1,3 +1,5 @@ +// CLI: pipekit assert + package actions import ( @@ -49,19 +51,27 @@ func AssertCommand() cli.Command { }, }, { - Name: "json-path", - Usage: "assert a value at a JSON path matches expectation", + Name: "json-path", + Usage: "assert a value at a JSON path matches expectation (stdin, FILE, or --file)", + ArgsUsage: "[FILE]", Flags: []cli.Flag{ - cli.StringFlag{Name: "file", Usage: "JSON file to check"}, + cli.StringFlag{Name: "file", Usage: "JSON file to check (default: positional FILE, else stdin)"}, cli.StringFlag{Name: "path", Usage: "jq-style path expression", Required: true}, cli.StringFlag{Name: "expected", Usage: "expected value", Required: true}, }, Action: func(c *cli.Context) error { - filePath := c.String("file") - if filePath == "" { - return cli.NewExitError("--file is required", 1) + // --file stays supported and still wins; without it the + // input follows the same positional-FILE-or-stdin + // convention as every other input-taking command. + var ( + data []byte + err error + ) + if filePath := c.String("file"); filePath != "" { + data, err = os.ReadFile(filePath) + } else { + data, err = readAllInput(c) } - data, err := os.ReadFile(filePath) if err != nil { return cli.NewExitError(err.Error(), 1) } diff --git a/actions/cache_key.go b/actions/cache_key.go index 8b072bc..524134d 100644 --- a/actions/cache_key.go +++ b/actions/cache_key.go @@ -1,3 +1,8 @@ +// CLI: pipekit cache-key +// +// The command is hyphenated, the filename is not: guessing the CLI +// name from this filename gives `cache_key`, which does not exist. + package actions import ( diff --git a/actions/changelog.go b/actions/changelog.go index 4b09cc0..de222a9 100644 --- a/actions/changelog.go +++ b/actions/changelog.go @@ -1,3 +1,5 @@ +// CLI: pipekit changelog + package actions import ( diff --git a/actions/checksum.go b/actions/checksum.go index a0ee5ab..7c2ed3e 100644 --- a/actions/checksum.go +++ b/actions/checksum.go @@ -1,3 +1,5 @@ +// CLI: pipekit checksum + package actions import ( diff --git a/actions/comment.go b/actions/comment.go index 0c8d32a..7960729 100644 --- a/actions/comment.go +++ b/actions/comment.go @@ -1,3 +1,5 @@ +// CLI: pipekit comment + package actions import ( @@ -11,11 +13,47 @@ import ( "github.com/urfave/cli" ) +// commentGroupDescription is the group-level `pipekit comment --help` body. +// The real flags live one subcommand down, so without this you cannot learn +// the interface from the top of the tree. +const commentGroupDescription = `Input convention: every subcommand that takes a markdown body reads it from + stdin by default, and --body-file PATH overrides that. ` + "`amend`" + ` takes two + inputs (the existing comment and the new body), so whichever one + --body-file does not supply is the one read from stdin. + + Synopsis (the flags each subcommand actually takes): + + anchor NAME print the hidden marker for NAME + fence [FILE] wrap input in a fenced block (--language, --body-file) + render --anchor NAME body + hidden anchor (--body-file, stdin, or a BODY argument) + payload [FILE] wrap input as {"body": ...} for the GitHub comments API + amend --anchor NAME replace the visible body under an existing anchor + inspect [FILE] list anchors and fenced blocks in markdown or comments JSON + select --anchor NAME pick the comment carrying NAME; EXITS 1 when absent + + The sticky comment round-trip. ` + "`select`" + ` exits 1 when the anchor is not + present, and that exit code is the create-vs-update branch: + + comments=$(gh api repos/$REPO/issues/$PR/comments) + + if id=$(printf '%s' "$comments" | pipekit comment select --anchor ci --format id); then + printf '%s' "$comments" \ + | pipekit comment select --anchor ci --format body \ + | pipekit comment amend --anchor ci --body-file report.md \ + | pipekit comment payload \ + | gh api --method PATCH repos/$REPO/issues/comments/$id --input - + else + pipekit comment render --anchor ci --body-file report.md \ + | pipekit comment payload \ + | gh api --method POST repos/$REPO/issues/$PR/comments --input - + fi` + // CommentCommand returns the markdown comment command group. func CommentCommand() cli.Command { return cli.Command{ - Name: "comment", - Usage: "render, inspect, and amend anchored markdown comments", + Name: "comment", + Usage: "render, inspect, and amend anchored markdown comments", + Description: commentGroupDescription, Subcommands: []cli.Command{ { Name: "anchor", @@ -35,13 +73,14 @@ func CommentCommand() cli.Command { }, { Name: "fence", - Usage: "render stdin or a file as a fenced markdown code block", + Usage: "render stdin, --body-file, or a file as a fenced markdown code block", Flags: []cli.Flag{ cli.StringFlag{Name: "language, l", Usage: "code fence language tag"}, + bodyFileFlag("read the body from this file instead of stdin"), cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, }, Action: func(c *cli.Context) error { - body, err := readInputFileOrStdin(c) + body, err := readBodyFileOrInput(c) if err != nil { return cli.NewExitError(err.Error(), 1) } @@ -70,12 +109,13 @@ func CommentCommand() cli.Command { }, { Name: "payload", - Usage: "render stdin or a file as a GitHub comment API payload", + Usage: "render stdin, --body-file, or a file as a GitHub comment API payload", Flags: []cli.Flag{ + bodyFileFlag("read the body from this file instead of stdin"), cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, }, Action: func(c *cli.Context) error { - body, err := readInputFileOrStdin(c) + body, err := readBodyFileOrInput(c) if err != nil { return cli.NewExitError(err.Error(), 1) } @@ -87,22 +127,23 @@ func CommentCommand() cli.Command { }, }, { - Name: "amend", - Usage: "replace the visible body after a hidden anchor", + Name: "amend", + Usage: "replace the visible body after a hidden anchor", + ArgsUsage: "[EXISTING_COMMENT_FILE]", + Description: `amend needs two inputs. --body-file supplies the new body and the + existing comment comes from the positional FILE or stdin; drop + --body-file and it inverts — the existing comment must then be the + positional FILE, leaving stdin to carry the new body.`, Flags: []cli.Flag{ cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true}, - cli.StringFlag{Name: "body-file", Usage: "read replacement markdown body from file", Required: true}, + bodyFileFlag("read the replacement body from this file instead of stdin"), cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, }, Action: func(c *cli.Context) error { - existing, err := readInputFileOrStdin(c) + existing, body, err := readAmendInputs(c) if err != nil { return cli.NewExitError(err.Error(), 1) } - body, err := os.ReadFile(c.String("body-file")) - if err != nil { - return cli.NewExitError(fmt.Sprintf("reading body file: %v", err), 1) - } out, err := services.AmendAnchoredComment(string(existing), c.String("anchor"), string(body)) if err != nil { return cli.NewExitError(err.Error(), 1) @@ -127,8 +168,13 @@ func CommentCommand() cli.Command { }, }, { - Name: "select", - Usage: "select the first GitHub comment JSON item containing an anchor", + Name: "select", + Usage: "select the first GitHub comment JSON item containing an anchor (exit 1 if absent)", + ArgsUsage: "[COMMENTS_JSON_FILE]", + Description: `Exits 0 and prints the match, or exits 1 when no comment carries the + anchor. That exit code is the create-vs-update branch of a sticky + comment: success means PATCH an existing comment, failure means POST a + new one.`, Flags: []cli.Flag{ cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true}, cli.StringFlag{Name: "format, f", Value: "json", Usage: "output format: json, id, body, url"}, @@ -166,13 +212,42 @@ func CommentCommand() cli.Command { } } +// bodyFileFlag declares --body-file with a per-subcommand usage string. Every +// body-taking `comment` subcommand carries it, so that one convention — +// "stdin by default, --body-file overrides" — holds across the whole group. +func bodyFileFlag(usage string) cli.StringFlag { + return cli.StringFlag{Name: "body-file", Usage: usage} +} + +// bodyFileContents reads --body-file. The bool reports whether the flag was +// set; when it is not, callers fall back to their own established +// positional/stdin behaviour, which differs per subcommand and must not change. +func bodyFileContents(c *cli.Context) ([]byte, bool, error) { + path := c.String("body-file") + if path == "" { + return nil, false, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, true, fmt.Errorf("reading body file: %w", err) + } + return data, true, nil +} + +// readBodyFileOrInput is the fence/payload convention: --body-file wins, +// otherwise the positional FILE, otherwise stdin. +func readBodyFileOrInput(c *cli.Context) ([]byte, error) { + if data, set, err := bodyFileContents(c); set { + return data, err + } + return readInputFileOrStdin(c) +} + +// readCommentBody is the render convention: --body-file wins, otherwise the +// positional argument is the body TEXT itself (not a path), otherwise stdin. func readCommentBody(c *cli.Context) (string, error) { - if path := c.String("body-file"); path != "" { - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("reading body file: %w", err) - } - return string(data), nil + if data, set, err := bodyFileContents(c); set { + return string(data), err } data, err := readBytesFromArgOrStdin(c) if err != nil { @@ -181,6 +256,43 @@ func readCommentBody(c *cli.Context) (string, error) { return string(data), nil } +// readAmendInputs resolves amend's two inputs. --body-file supplies the body +// and the existing comment comes from the positional FILE or stdin (the +// pre-existing behaviour); without --body-file the roles invert so that stdin +// is free to carry the body. +func readAmendInputs(c *cli.Context) (existing, body []byte, err error) { + if data, set, ferr := bodyFileContents(c); set { + if ferr != nil { + return nil, nil, ferr + } + existing, err = readInputFileOrStdin(c) + if err != nil { + return nil, nil, err + } + return existing, data, nil + } + + path := c.Args().First() + if path == "" { + return nil, nil, fmt.Errorf( + "amend needs two inputs: either pass --body-file PATH with the existing comment on stdin, " + + "or pass the existing comment as a positional FILE with the new body on stdin") + } + existing, err = os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("reading %s: %w", path, err) + } + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + return nil, nil, fmt.Errorf("no replacement body: pass --body-file PATH or pipe the new body on stdin") + } + body, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, nil, err + } + return existing, body, nil +} + func readInputFileOrStdin(c *cli.Context) ([]byte, error) { r, err := readerFromArgOrStdin(c) if err != nil { diff --git a/actions/common.go b/actions/common.go index b744ec0..817c6a2 100644 --- a/actions/common.go +++ b/actions/common.go @@ -1,3 +1,7 @@ +// CLI: none +// +// Shared helpers for the action handlers. Declares no CLI command. + package actions import ( @@ -11,6 +15,40 @@ import ( "github.com/urfave/cli" ) +// GroupHelpTemplate is urfave/cli v1's SubcommandHelpTemplate with two +// changes, so that a command group can document itself. +// +// The stock template renders `{{if .Description}}{{.Description}}{{else}} +// {{.Usage}}{{end}}` on the NAME line, so giving a group a Description +// silently *replaces* its one-line summary with the whole block. This keeps +// the summary on NAME and gives Description its own section, placed last so +// the subcommand list stays near the top. +// +// A group's own CustomHelpTemplate cannot do this: ShowCommandHelp takes the +// `command == ""` branch for `pipekit --help` and hardcodes +// SubcommandHelpTemplate. Overriding that package variable (which the library +// documents as the customisation point) is the only hook. Groups with no +// Description render exactly as they did before. Leaf commands are unaffected +// — CommandHelpTemplate already has a DESCRIPTION section. +const GroupHelpTemplate = `NAME: + {{.HelpName}} - {{.Usage}} + +USAGE: + {{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}} command{{if .VisibleFlags}} [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}} + +COMMANDS:{{range .VisibleCategories}}{{if .Name}} + + {{.Name}}:{{range .VisibleCommands}} + {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{else}}{{range .VisibleCommands}} + {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}}{{end}}{{if .VisibleFlags}} + +OPTIONS: + {{range .VisibleFlags}}{{.}} + {{end}}{{end}}{{if .Description}} +DESCRIPTION: + {{.Description}} +{{end}}` + // firstArgOrErr returns the first positional argument or a CLI exit error // using the given argument name in the message. func firstArgOrErr(c *cli.Context, name string) (string, error) { diff --git a/actions/config.go b/actions/config.go index b7adbfb..4fc59f1 100644 --- a/actions/config.go +++ b/actions/config.go @@ -1,3 +1,5 @@ +// CLI: pipekit config + package actions import ( diff --git a/actions/diff.go b/actions/diff.go index c2414e7..f81694b 100644 --- a/actions/diff.go +++ b/actions/diff.go @@ -1,3 +1,5 @@ +// CLI: pipekit diff + package actions import ( diff --git a/actions/doctor.go b/actions/doctor.go index 6ccf547..401eb93 100644 --- a/actions/doctor.go +++ b/actions/doctor.go @@ -1,3 +1,5 @@ +// CLI: pipekit doctor + package actions import ( diff --git a/actions/env.go b/actions/env.go index 526e7b5..a780fdc 100644 --- a/actions/env.go +++ b/actions/env.go @@ -1,3 +1,5 @@ +// CLI: pipekit env + package actions import ( diff --git a/actions/exec.go b/actions/exec.go index e319843..0c19de6 100644 --- a/actions/exec.go +++ b/actions/exec.go @@ -1,3 +1,5 @@ +// CLI: pipekit exec + package actions import ( diff --git a/actions/git.go b/actions/git.go index 9f0de01..83cc5a5 100644 --- a/actions/git.go +++ b/actions/git.go @@ -1,3 +1,5 @@ +// CLI: pipekit git + package actions import ( diff --git a/actions/http.go b/actions/http.go index c6595e4..e5e910a 100644 --- a/actions/http.go +++ b/actions/http.go @@ -1,3 +1,5 @@ +// CLI: pipekit http + package actions import ( diff --git a/actions/image.go b/actions/image.go index 41923f9..7adf3f5 100644 --- a/actions/image.go +++ b/actions/image.go @@ -1,3 +1,5 @@ +// CLI: pipekit image + package actions import ( diff --git a/actions/json.go b/actions/json.go index 868ca58..8d8129d 100644 --- a/actions/json.go +++ b/actions/json.go @@ -1,3 +1,8 @@ +// CLI: pipekit json, pipekit yaml +// +// Both groups are built from the same dataCommand tree; there is no +// yaml.go. + package actions import ( diff --git a/actions/lock.go b/actions/lock.go index d8d2566..683f250 100644 --- a/actions/lock.go +++ b/actions/lock.go @@ -1,3 +1,5 @@ +// CLI: pipekit lock + package actions import ( diff --git a/actions/mask.go b/actions/mask.go index ab08c6c..967b29e 100644 --- a/actions/mask.go +++ b/actions/mask.go @@ -1,3 +1,5 @@ +// CLI: pipekit mask + package actions import ( diff --git a/actions/matrix.go b/actions/matrix.go index ec59881..a6a410c 100644 --- a/actions/matrix.go +++ b/actions/matrix.go @@ -1,3 +1,5 @@ +// CLI: pipekit matrix + package actions import ( diff --git a/actions/misc.go b/actions/misc.go index 48adcbd..27e818f 100644 --- a/actions/misc.go +++ b/actions/misc.go @@ -1,3 +1,8 @@ +// CLI: pipekit port, pipekit uuid, pipekit random +// +// Three unrelated small groups share this file; none of them is called +// `misc`. + package actions import ( diff --git a/actions/notify.go b/actions/notify.go index 3d4f8bd..a42fbfc 100644 --- a/actions/notify.go +++ b/actions/notify.go @@ -1,3 +1,5 @@ +// CLI: pipekit notify + package actions import ( diff --git a/actions/parse.go b/actions/parse.go index ae065e3..5fc3711 100644 --- a/actions/parse.go +++ b/actions/parse.go @@ -1,3 +1,5 @@ +// CLI: pipekit parse + package actions import ( diff --git a/actions/render.go b/actions/render.go index cfccff6..8fa7ad5 100644 --- a/actions/render.go +++ b/actions/render.go @@ -1,3 +1,5 @@ +// CLI: pipekit render + package actions import ( @@ -15,10 +17,21 @@ func RenderCommand() cli.Command { Name: "render", Usage: "render a Go template file with values + sprig-like funcs", ArgsUsage: "[TEMPLATE_FILE]", + Description: `Values are namespaced Helm-style: everything from --values and --set is + merged under .Values, and .Env is auto-populated from the environment. + A template that reads a bare {{ .name }} renders "" — write + {{ .Values.name }} instead. + + --values prod.yaml with {"image": {"tag": "v1"}} → {{ .Values.image.tag }} + --set replicas=3 → {{ .Values.replicas }} + $HOME → {{ .Env.HOME }} + + --envsubst switches to plain $VAR / ${VAR} / ${VAR:-default} expansion + instead, where none of the above applies.`, Flags: []cli.Flag{ cli.StringFlag{Name: "template, t", Usage: "template file (alt to positional)"}, - cli.StringSliceFlag{Name: "values, v", Usage: "JSON/YAML/TOML values file (repeatable; later wins)"}, - cli.StringSliceFlag{Name: "set, s", Usage: "key=value override (repeatable, dotted keys ok)"}, + cli.StringSliceFlag{Name: "values, v", Usage: "JSON/YAML/TOML values file, merged under .Values (repeatable; later wins)"}, + cli.StringSliceFlag{Name: "set, s", Usage: "key=value override under .Values (repeatable, dotted keys ok)"}, cli.StringFlag{Name: "output, o", Usage: "write output to this file (default: stdout)"}, cli.BoolFlag{Name: "envsubst", Usage: "envsubst mode: substitute $VAR / ${VAR} / ${VAR:-default} from the environment instead of Go templating"}, cli.BoolFlag{Name: "strict", Usage: "with --envsubst, fail when a referenced variable is unset and has no default"}, diff --git a/actions/report.go b/actions/report.go index 9517d1b..e53496d 100644 --- a/actions/report.go +++ b/actions/report.go @@ -1,3 +1,5 @@ +// CLI: pipekit report + package actions import ( diff --git a/actions/retry.go b/actions/retry.go index 7a20133..3083ab7 100644 --- a/actions/retry.go +++ b/actions/retry.go @@ -1,3 +1,5 @@ +// CLI: pipekit retry + package actions import ( diff --git a/actions/summary.go b/actions/summary.go index a0d80b9..b72d83b 100644 --- a/actions/summary.go +++ b/actions/summary.go @@ -1,3 +1,5 @@ +// CLI: pipekit summary + package actions import ( diff --git a/actions/timecmd.go b/actions/timecmd.go index 03e0e8f..8b5fab7 100644 --- a/actions/timecmd.go +++ b/actions/timecmd.go @@ -1,3 +1,7 @@ +// CLI: pipekit time +// +// The command is `time`; the file is timecmd.go. + package actions import ( diff --git a/actions/transform.go b/actions/transform.go index a567c15..514d1dd 100644 --- a/actions/transform.go +++ b/actions/transform.go @@ -1,3 +1,5 @@ +// CLI: pipekit transform + package actions import ( diff --git a/actions/url.go b/actions/url.go index c0d7363..c92d9cf 100644 --- a/actions/url.go +++ b/actions/url.go @@ -1,3 +1,5 @@ +// CLI: pipekit url + package actions import ( diff --git a/actions/version.go b/actions/version.go index 3c6d056..0e6a225 100644 --- a/actions/version.go +++ b/actions/version.go @@ -1,3 +1,5 @@ +// CLI: pipekit version + package actions import ( diff --git a/actions/wait.go b/actions/wait.go index edec29a..848526b 100644 --- a/actions/wait.go +++ b/actions/wait.go @@ -1,3 +1,5 @@ +// CLI: pipekit wait + package actions import ( diff --git a/docs/AI/README.md b/docs/AI/README.md index 2dbda8f..7848141 100644 --- a/docs/AI/README.md +++ b/docs/AI/README.md @@ -25,6 +25,10 @@ pipekit/ ├── .goreleaser.yml # Multi-platform release config ├── .github/workflows/release.yaml # GitHub Actions release on tag push ├── actions/ # CLI command handlers (one file per command group) +│ # Each file opens with a `// CLI: pipekit ` header, because the +│ # filename is not always the command: cache_key.go is `cache-key`, +│ # timecmd.go is `time`, misc.go is `port`/`uuid`/`random`, and json.go +│ # is both `json` and `yaml`. main_test.go enforces the headers. │ ├── env.go # env from-json, from-yaml, from-dotenv, to-* │ ├── mask.go # mask values, file, github, env │ ├── transform.go # transform base64, url, case, regex, template, hash, slug @@ -209,8 +213,18 @@ go build -ldflags="-s -w \ 1. Create `services/newcmd_service.go` with business logic functions 2. Create `services/newcmd_service_test.go` with unit tests -3. Create `actions/newcmd.go` with `func NewCmdCommand() cli.Command` -4. Register in `main.go`: add `actions.NewCmdCommand()` to `app.Commands` +3. Create `actions/newcmd.go` with `func NewCmdCommand() cli.Command`, opening + with a `// CLI: pipekit newcmd` header (`main_test.go` fails without it) +4. Register in `main.go`: add `actions.NewCmdCommand()` to `commands()` + +**Two conventions the tests enforce:** + +- **Input.** A command that takes a document reads it from the positional + `FILE` or stdin. A `--body-file` / `--file` flag may override, but must never + be the *only* way in. +- **Empty JSON output is `[]`, never `null`.** Run any possibly-nil slice + through `services.emptyIfNil` before marshalling — a `null` matrix errors a + GitHub Actions workflow where `[]` correctly skips the job. ### Adding a Subcommand to Existing Group diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index e1bae8f..9167a67 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -301,8 +301,10 @@ pipekit assert env-exists DEPLOY_TOKEN CLUSTER_NAME IMAGE_TAG # Required files pipekit assert file-exists Dockerfile docker-compose.yml -# Value at a JSON path +# Value at a JSON path — from --file, a positional file, or stdin pipekit assert json-path --file package.json --path ".version" --expected "1.0.0" +pipekit assert json-path package.json --path ".version" --expected "1.0.0" +kubectl get pod api -o json | pipekit assert json-path --path ".status.phase" --expected "Running" # Valid semver pipekit assert semver "1.2.3" @@ -368,6 +370,15 @@ go test -list . ./... | tail -n +2 \ `shard` outputs in `list` format by default (one per line). Use `--format csv` or `--format json` for other shapes. +`from-json` treats a JSON `null` on stdin as an empty list, and every generator +emits `[]` rather than `null` when nothing matches — so a matrix built from an +empty result skips the job instead of erroring the workflow: + +```sh +echo 'null' | pipekit matrix from-json # {"item":[]} +pipekit matrix from-files "nope-*.yaml" # {"file":[]} +``` + --- @@ -467,6 +478,25 @@ pipekit diff match "api/**" --base origin/main && echo "API changed" pipekit diff affected --config .pipekit-diff.yaml --base origin/main --output json ``` +**JSON output of an empty result is `[]`, never `null`.** This is a guarantee, not +an accident of encoding — a nil slice marshals to `null` in Go, and that is a real +bug when the output is piped somewhere. A GitHub Actions matrix of `null` makes the +job **error**; `[]` makes it correctly **skip**, so "nothing changed" stays a green +no-op instead of turning into a red build: + +```bash +# Nothing matched the filter: +pipekit diff files --include 'infra/**' --output json # → [] (not null) +pipekit diff files --include 'infra/**' --output json | pipekit matrix from-json +# → {"item":[]} — the matrix job is skipped, not failed +``` + +The same holds for every command that emits a JSON list: `matrix` (`from-json`, +`from-dirs`, `from-files`), `archive list --json`, `changelog generate --format json`, +and `http get --paginate`. `matrix from-json` additionally accepts a literal `null` +on stdin and treats it as empty, so a third-party tool that emits `null` degrades +instead of propagating the failure. + `.pipekit-diff.yaml`: ```yaml @@ -483,6 +513,11 @@ services: Output formats: `json`, `csv`, `list` (newline-separated, default). +**An empty result is `[]`, never `null`.** This matters when the value feeds a +matrix: GitHub Actions *errors* the workflow on `matrix: null` but correctly +*skips* the job on `matrix: []`. A docs-only commit filtered out by `--include` +therefore skips the job instead of failing the build. + --- @@ -916,6 +951,13 @@ Hidden anchors use HTML comments, so GitHub keeps them in the API body but does ``` +**Input convention.** Every body-taking subcommand reads the markdown body from +stdin by default, and `--body-file PATH` overrides it. `amend` takes two inputs +(the existing comment and the new body), so whichever one `--body-file` does not +supply is the one read from stdin. `render`'s positional argument is the body +*text* itself; for `fence`, `payload`, `inspect`, `select` and `amend` the +positional argument is a *file*. +
comment render — create an anchored comment body @@ -941,6 +983,8 @@ printf '## Preview\n\nReady\n' \ pipekit comment fence --language yaml values.yaml cat script.js | pipekit comment fence --language js + +pipekit comment fence --language js --body-file script.js ``` The fence is automatically lengthened when the content itself contains triple backticks. @@ -967,6 +1011,10 @@ Outputs JSON with comment metadata, hidden anchors, and fenced code blocks. ```sh pipekit comment payload comment.md > payload.json +pipekit comment payload --body-file comment.md > payload.json + +cat comment.md | pipekit comment payload > payload.json + gh api \ --method POST \ repos/OWNER/REPO/issues/123/comments \ @@ -997,13 +1045,40 @@ gh api repos/OWNER/REPO/issues/123/comments \ | `--anchor, -a` | Hidden anchor to search for | | `--format, -f` | `json`, `id`, `body`, or `url` | +**`select` exits 1 when the anchor is absent, and that exit code is the +create-vs-update branch of a sticky comment** — success means PATCH the comment +you found, failure means POST a new one: + +```sh +comments=$(gh api repos/OWNER/REPO/issues/123/comments) + +if id=$(printf '%s' "$comments" | pipekit comment select --anchor preview --format id); then + printf '%s' "$comments" \ + | pipekit comment select --anchor preview --format body \ + | pipekit comment amend --anchor preview --body-file preview.md \ + | pipekit comment payload \ + | gh api --method PATCH repos/OWNER/REPO/issues/comments/$id --input - +else + pipekit comment render --anchor preview --body-file preview.md \ + | pipekit comment payload \ + | gh api --method POST repos/OWNER/REPO/issues/123/comments --input - +fi +``` +
comment amend — replace visible content after an anchor ```sh +# existing comment as a file, new body from --body-file pipekit comment amend existing.md --anchor preview --body-file preview.md > updated.md + +# existing comment on stdin, new body from --body-file +cat existing.md | pipekit comment amend --anchor preview --body-file preview.md + +# existing comment as a file, new body on stdin +generate-report | pipekit comment amend --anchor preview existing.md ``` If the input does not contain the anchor, a fresh anchored comment is created. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ade484e..dfa2707 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -9,6 +9,7 @@ Working on pipekit itself. For an architectural deep-dive aimed at AI assistants - [Common workflows](#common-workflows) - [Adding a new command](#adding-a-new-command) - [Adding a flag](#adding-a-flag) +- [Two conventions the tests enforce](#two-conventions-the-tests-enforce) - [Testing](#testing) - [Linting](#linting) - [Releasing](#releasing) @@ -85,8 +86,8 @@ go get -u ./... && go mod tidy && go test ./... 1. **Service** — `services/newcmd_service.go`: pure functions accepting `io.Reader` / `io.Writer` / strings, returning `(result, error)`. 2. **Test** — `services/newcmd_service_test.go`: table-driven tests for the functions above. -3. **Action** — `actions/newcmd.go`: a `func NewCmdCommand() cli.Command` that wires flags, reads input, calls the service, formats output, and returns `cli.NewExitError` on failure. -4. **Register** — add `actions.NewCmdCommand()` to `app.Commands` in `main.go`. +3. **Action** — `actions/newcmd.go`: a `func NewCmdCommand() cli.Command` that wires flags, reads input, calls the service, formats output, and returns `cli.NewExitError` on failure. Start the file with a `// CLI: pipekit newcmd` header — the filename does not always match the command (`cache_key.go` is `cache-key`, `timecmd.go` is `time`), and `main_test.go` fails if the header is missing or wrong. +4. **Register** — add `actions.NewCmdCommand()` to `commands()` in `main.go`. 5. **Docs** — add a row to the commands table in `README.md`, an entry under [`COMMANDS.md`](COMMANDS.md), and a recipe in [`EXAMPLES.md`](EXAMPLES.md) if it replaces a common bash idiom. For a subcommand on an existing group, skip steps 1–4 minus the relevant additions: extend the existing service file and add a `cli.Command{}` to the group's `Subcommands` slice. @@ -115,6 +116,13 @@ Pass it to the service. Don't reach into `os.Args` from the service layer. --- +## Two conventions the tests enforce + +- **Input.** A command that takes a document reads it from the positional `FILE` or stdin. A `--body-file` / `--file` flag may override that, but must never be the only way in — `integration/input_convention_test.go` checks the `comment` group and `assert json-path`. +- **Empty JSON output is `[]`, never `null`.** Pass any possibly-nil slice through `services.emptyIfNil` before marshalling. GitHub Actions *errors* a workflow on `matrix: null` but correctly *skips* the job on `matrix: []`, so a nil slice turns a green no-op into a red build. Covered by `integration/empty_array_test.go`. + +--- + ## Testing - Unit tests live next to the service they cover (`services/*_service_test.go`). diff --git a/go.mod b/go.mod index 19289a3..49258f0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/AxeForging/pipekit go 1.25.0 -toolchain go1.25.12 +toolchain go1.25.13 require ( github.com/Masterminds/semver/v3 v3.4.0 @@ -15,7 +15,7 @@ require ( github.com/rs/zerolog v1.34.0 github.com/ulikunitz/xz v0.5.15 github.com/urfave/cli v1.22.17 - google.golang.org/grpc v1.72.2 + google.golang.org/grpc v1.82.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -25,9 +25,9 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 1627dfc..9df46d5 100644 --- a/go.sum +++ b/go.sum @@ -3,21 +3,23 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -57,33 +59,35 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 h1:h6p3mQqrmT1XkHVTfzLdNz1u7IhINeZkz67/xTbOuWs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/integration/empty_array_test.go b/integration/empty_array_test.go new file mode 100644 index 0000000..db637cb --- /dev/null +++ b/integration/empty_array_test.go @@ -0,0 +1,187 @@ +package integration + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// mustGitIn runs a git command inside dir, failing the test on error. +func mustGitIn(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", + "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", + "GIT_COMMITTER_EMAIL=t@t", + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String()) + } +} + +// repoWithDocsOnlyChange builds a repo whose HEAD commit touches only +// README.md, so `--include 'infra/**'` filters the diff down to nothing. +func repoWithDocsOnlyChange(t *testing.T) string { + t.Helper() + dir := t.TempDir() + mustGitIn(t, dir, "init", "-q", ".") + mustGitIn(t, dir, "config", "user.email", "test@example.com") + mustGitIn(t, dir, "config", "user.name", "test") + mustGitIn(t, dir, "config", "commit.gpgsign", "false") + + if err := os.MkdirAll(filepath.Join(dir, "infra", "dev"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "infra", "dev", "main.tf"), []byte("a\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGitIn(t, dir, "add", "-A") + mustGitIn(t, dir, "commit", "-qm", "init") + + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme\nchanged\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGitIn(t, dir, "add", "-A") + mustGitIn(t, dir, "commit", "-qm", "docs only") + return dir +} + +// Regression for the `null` matrix bug. +// +// `diff files --output json` filtered down to nothing used to print `null`. +// Fed to a GitHub Actions matrix that is an error, not an empty matrix: the +// workflow fails instead of skipping the job. A docs-only commit therefore +// turned a green no-op run red. +func TestE2E_DiffEmptyResultIsJSONArrayNotNull(t *testing.T) { + dir := repoWithDocsOnlyChange(t) + + for _, sub := range []string{"files", "dirs"} { + t.Run(sub, func(t *testing.T) { + stdout, stderr, code := runPipekitIn(t, dir, + []string{"diff", sub, "--base", "HEAD~1", "--head", "HEAD", "--include", "infra/**", "--output", "json"}, "") + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if got := strings.TrimSpace(stdout); got != "[]" { + t.Errorf("diff %s --output json = %q, want %q", sub, got, "[]") + } + }) + } +} + +// The full reported pipeline: the diff feeds `matrix from-json`, which is how +// the value actually reaches a workflow's `matrix:` key. +func TestE2E_DiffPipedToMatrixIsEmptyArrayNotNull(t *testing.T) { + dir := repoWithDocsOnlyChange(t) + + diffOut, stderr, code := runPipekitIn(t, dir, + []string{"diff", "files", "--base", "HEAD~1", "--head", "HEAD", "--include", "infra/**", "--output", "json"}, "") + if code != 0 { + t.Fatalf("diff exit %d, stderr: %s", code, stderr) + } + + stdout, stderr, code := runPipekit(t, []string{"matrix", "from-json"}, diffOut) + if code != 0 { + t.Fatalf("matrix exit %d, stderr: %s", code, stderr) + } + if got := strings.TrimSpace(stdout); got != `{"item":[]}` { + t.Errorf("matrix from-json = %q, want %q", got, `{"item":[]}`) + } +} + +// Belt and braces: a literal `null` on stdin — which some other tool may hand +// us — is treated as an empty list rather than propagated. +func TestE2E_MatrixFromJSONTreatsNullAsEmpty(t *testing.T) { + tests := []struct { + name string + stdin string + args []string + want string + }{ + {name: "literal null", stdin: "null\n", args: []string{"matrix", "from-json"}, want: `{"item":[]}`}, + {name: "empty array", stdin: "[]\n", args: []string{"matrix", "from-json"}, want: `{"item":[]}`}, + { + name: "filter matches nothing", + stdin: `[{"name":"api","deploy":"false"}]`, + args: []string{"matrix", "from-json", "--filter-field", "deploy", "--filter-value", "true"}, + want: `{"item":[]}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, code := runPipekit(t, tc.args, tc.stdin) + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if got := strings.TrimSpace(stdout); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// The same class of bug in the other commands that build a slice with +// `var x []T` + conditional append. +func TestE2E_MatrixGeneratorsEmptyIsArrayNotNull(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "loose.txt"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + stdout, stderr, code := runPipekit(t, []string{"matrix", "from-dirs", dir}, "") + if code != 0 { + t.Fatalf("from-dirs exit %d, stderr: %s", code, stderr) + } + if got := strings.TrimSpace(stdout); got != `{"dir":[]}` { + t.Errorf("matrix from-dirs = %q, want %q", got, `{"dir":[]}`) + } + + stdout, stderr, code = runPipekit(t, []string{"matrix", "from-files", filepath.Join(dir, "*.nope")}, "") + if code != 0 { + t.Fatalf("from-files exit %d, stderr: %s", code, stderr) + } + if got := strings.TrimSpace(stdout); got != `{"file":[]}` { + t.Errorf("matrix from-files = %q, want %q", got, `{"file":[]}`) + } +} + +func TestE2E_ArchiveListEmptyIsArrayNotNull(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "empty.tar") + // 10240 zero bytes is a valid empty tar (two zero blocks plus padding). + if err := os.WriteFile(tarPath, make([]byte, 10240), 0o644); err != nil { + t.Fatal(err) + } + + stdout, stderr, code := runPipekit(t, []string{"archive", "list", tarPath, "--json"}, "") + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if got := strings.TrimSpace(stdout); got != "[]" { + t.Errorf("archive list --json on an empty tar = %q, want %q", got, "[]") + } +} + +func TestE2E_ChangelogEmptyRangeIsArrayNotNull(t *testing.T) { + dir := repoWithDocsOnlyChange(t) + + stdout, stderr, code := runPipekitIn(t, dir, + []string{"changelog", "generate", "--from", "HEAD", "--to", "HEAD", "--format", "json"}, "") + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if got := strings.TrimSpace(stdout); got != "[]" { + t.Errorf("changelog generate --format json = %q, want %q", got, "[]") + } +} diff --git a/integration/input_convention_test.go b/integration/input_convention_test.go new file mode 100644 index 0000000..de1f4f0 --- /dev/null +++ b/integration/input_convention_test.go @@ -0,0 +1,250 @@ +package integration + +import ( + "strings" + "testing" +) + +// Every body-taking `comment` subcommand must accept the body on stdin AND +// via --body-file, and produce byte-identical output either way. Before this, +// render/amend took --body-file while payload/fence took only stdin or a +// positional — three conventions across four subcommands. +func TestE2E_CommentBodyStdinAndBodyFileAgree(t *testing.T) { + body := "hello **world**\n" + bodyFile := writeTempFile(t, "body.md", body) + + tests := []struct { + name string + stdinArgs []string + flagArgs []string + }{ + { + name: "fence", + stdinArgs: []string{"comment", "fence", "--language", "md"}, + flagArgs: []string{"comment", "fence", "--language", "md", "--body-file", bodyFile}, + }, + { + name: "payload", + stdinArgs: []string{"comment", "payload"}, + flagArgs: []string{"comment", "payload", "--body-file", bodyFile}, + }, + { + name: "render", + stdinArgs: []string{"comment", "render", "--anchor", "ci"}, + flagArgs: []string{"comment", "render", "--anchor", "ci", "--body-file", bodyFile}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + viaStdin, stderr, code := runPipekit(t, tc.stdinArgs, body) + if code != 0 { + t.Fatalf("stdin form exit %d, stderr: %s", code, stderr) + } + viaFlag, stderr, code := runPipekit(t, tc.flagArgs, "") + if code != 0 { + t.Fatalf("--body-file form exit %d, stderr: %s", code, stderr) + } + if viaStdin != viaFlag { + t.Errorf("stdin form = %q but --body-file form = %q; the two must agree", viaStdin, viaFlag) + } + if !strings.Contains(viaFlag, "hello **world**") { + t.Errorf("body missing from output: %q", viaFlag) + } + }) + } +} + +// fence and payload kept their positional FILE argument. +func TestE2E_CommentPositionalFileStillWorks(t *testing.T) { + bodyFile := writeTempFile(t, "body.md", "positional\n") + + for _, sub := range []string{"fence", "payload"} { + t.Run(sub, func(t *testing.T) { + stdout, stderr, code := runPipekit(t, []string{"comment", sub, bodyFile}, "") + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if !strings.Contains(stdout, "positional") { + t.Errorf("stdout = %q", stdout) + } + }) + } +} + +// render's positional argument is the body TEXT itself, not a path. That is +// long-standing behaviour and must not shift to a file read. +func TestE2E_CommentRenderPositionalIsLiteralBody(t *testing.T) { + stdout, stderr, code := runPipekit(t, []string{"comment", "render", "--anchor", "ci", "literal body text"}, "") + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if !strings.Contains(stdout, "literal body text") { + t.Errorf("stdout = %q, want the argument rendered as the body", stdout) + } +} + +// amend takes two inputs. Both 0.2.3 invocations must keep working, and the +// new stdin-body form must produce the same result. +func TestE2E_CommentAmendInputForms(t *testing.T) { + existing, _, code := runPipekit(t, []string{"comment", "render", "--anchor", "ci", "--body-file", writeTempFile(t, "old.md", "old body\n")}, "") + if code != 0 { + t.Fatal("setup: render failed") + } + existingFile := writeTempFile(t, "existing.md", existing) + newFile := writeTempFile(t, "new.md", "NEW body\n") + + tests := []struct { + name string + args []string + stdin string + }{ + { + name: "0.2.3: existing on stdin, body via --body-file", + args: []string{"comment", "amend", "--anchor", "ci", "--body-file", newFile}, + stdin: existing, + }, + { + name: "0.2.3: existing as positional, body via --body-file", + args: []string{"comment", "amend", "--anchor", "ci", "--body-file", newFile, existingFile}, + }, + { + name: "new: existing as positional, body on stdin", + args: []string{"comment", "amend", "--anchor", "ci", existingFile}, + stdin: "NEW body\n", + }, + } + + var first string + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, code := runPipekit(t, tc.args, tc.stdin) + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if !strings.Contains(stdout, "NEW body") || strings.Contains(stdout, "old body") { + t.Errorf("body not replaced: %q", stdout) + } + if i == 0 { + first = stdout + } else if stdout != first { + t.Errorf("form disagrees with the first one:\n got %q\nwant %q", stdout, first) + } + }) + } +} + +// With neither --body-file nor a positional file, amend has nowhere to read +// its two inputs from. It must say so rather than fail obscurely. +func TestE2E_CommentAmendWithoutEitherInputExplainsItself(t *testing.T) { + _, stderr, code := runPipekit(t, []string{"comment", "amend", "--anchor", "ci"}, "") + if code == 0 { + t.Fatal("expected a non-zero exit") + } + for _, want := range []string{"--body-file", "stdin"} { + if !strings.Contains(stderr, want) { + t.Errorf("error message does not mention %q: %s", want, stderr) + } + } +} + +// assert json-path was the other command that demanded a flag where the rest +// of the CLI accepts stdin. All three forms must agree. +func TestE2E_AssertJSONPathAcceptsStdinFileAndFlag(t *testing.T) { + doc := `{"status":{"phase":"Running"}}` + path := writeTempFile(t, "doc.json", doc) + + tests := []struct { + name string + args []string + stdin string + }{ + {name: "--file (0.2.3)", args: []string{"assert", "json-path", "--file", path, "--path", ".status.phase", "--expected", "Running"}}, + {name: "positional FILE", args: []string{"assert", "json-path", path, "--path", ".status.phase", "--expected", "Running"}}, + {name: "stdin", args: []string{"assert", "json-path", "--path", ".status.phase", "--expected", "Running"}, stdin: doc}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, stderr, code := runPipekit(t, tc.args, tc.stdin) + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + }) + } + + // A failed assertion must still exit non-zero through every input path. + _, _, code := runPipekit(t, []string{"assert", "json-path", "--path", ".status.phase", "--expected", "Pending"}, doc) + if code == 0 { + t.Error("expected a non-zero exit on a mismatch read from stdin") + } +} + +// assertHelpContains runs a --help invocation and checks it mentions each +// wanted fragment. Help text is part of the interface here: two of these +// fixes exist purely because it was silent about something load-bearing. +func assertHelpContains(t *testing.T, args []string, wants ...string) string { + t.Helper() + stdout, _, code := runPipekit(t, args, "") + if code != 0 { + t.Fatalf("%v exit %d", args, code) + } + for _, want := range wants { + if !strings.Contains(stdout, want) { + t.Errorf("`pipekit %s` help does not mention %q:\n%s", strings.Join(args[:len(args)-1], " "), want, stdout) + } + } + return stdout +} + +// Group-level help has to be enough to use the group. Previously +// `pipekit comment --help` listed only --help. +func TestE2E_CommentGroupHelpDocumentsTheInterface(t *testing.T) { + assertHelpContains(t, []string{"comment", "--help"}, + // The one-line summary must survive alongside the long description. + "pipekit comment - render, inspect, and amend anchored markdown comments", + "--body-file", // the input convention + "stdin", // + "anchor NAME", // per-subcommand synopsis + "EXITS 1", // the create-vs-update branch + "gh api", // the worked example + "--method PATCH", // + "--method POST", // + ) +} + +// A group without a Description must render exactly as it always did — the +// help template change is not allowed to disturb the other 30+ groups. +func TestE2E_GroupHelpWithoutDescriptionIsUnchanged(t *testing.T) { + stdout, _, code := runPipekit(t, []string{"matrix", "--help"}, "") + if code != 0 { + t.Fatalf("exit %d", code) + } + if !strings.Contains(stdout, "pipekit matrix - dynamic CI matrix generation") { + t.Errorf("matrix help lost its usage line:\n%s", stdout) + } + if strings.Contains(stdout, "DESCRIPTION:") { + t.Errorf("matrix has no Description; help must not grow an empty section:\n%s", stdout) + } +} + +// `render` puts values under .Values (Helm-style). Help that omits this +// produces templates that render "" for every field. +func TestE2E_RenderHelpDocumentsValuesNamespace(t *testing.T) { + assertHelpContains(t, []string{"render", "--help"}, ".Values", ".Env", "") +} + +// ...and the help must be telling the truth. +func TestE2E_RenderValuesNamespaceMatchesHelp(t *testing.T) { + tpl := writeTempFile(t, "t.tpl", "bare={{ .name }} values={{ .Values.name }}\n") + + stdout, stderr, code := runPipekit(t, []string{"render", tpl, "--set", "name=hello"}, "") + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if !strings.Contains(stdout, "values=hello") { + t.Errorf("expected .Values.name to resolve: %q", stdout) + } + if !strings.Contains(stdout, "bare=") { + t.Errorf("expected a bare .name to be unresolved, as the help says: %q", stdout) + } +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 69accc5..0e882e0 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -33,8 +33,16 @@ func binaryPath(t *testing.T) string { // runPipekit runs the built binary with given args (and optional stdin). // Returns stdout, stderr, exit code. func runPipekit(t *testing.T, args []string, stdin string, env ...string) (string, string, int) { + t.Helper() + return runPipekitIn(t, "", args, stdin, env...) +} + +// runPipekitIn is runPipekit with an explicit working directory, which the +// git-aware commands (diff, changelog, git) need. +func runPipekitIn(t *testing.T, dir string, args []string, stdin string, env ...string) (string, string, int) { t.Helper() cmd := exec.Command(binaryPath(t), args...) + cmd.Dir = dir if stdin != "" { cmd.Stdin = strings.NewReader(stdin) } diff --git a/main.go b/main.go index 8a6b6c6..ef317dd 100644 --- a/main.go +++ b/main.go @@ -20,12 +20,30 @@ var ( func main() { helpers.SetupLogger("info") + // Let a command group carry a Description without losing its one-line + // Usage — see actions.GroupHelpTemplate. Groups with no Description are + // rendered exactly as before. + cli.SubcommandHelpTemplate = actions.GroupHelpTemplate + app := cli.NewApp() app.Name = "pipekit" app.Usage = "CI/CD pipeline Swiss Army knife" app.Version = Version - app.Commands = []cli.Command{ + app.Commands = commands() + + err := app.Run(os.Args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// commands is the registered command tree. It is a function so that tests can +// enumerate it — see main_test.go, which cross-checks every name here against +// the `// CLI:` header of the actions/ file that implements it. +func commands() []cli.Command { + return []cli.Command{ actions.EnvCommand(), actions.MaskCommand(), actions.TransformCommand(), @@ -72,10 +90,4 @@ func main() { }, }, } - - err := app.Run(os.Args) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..e3a9f02 --- /dev/null +++ b/main_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// cliHeaderRe matches the `// CLI: pipekit [, pipekit ...]` header +// that every actions/*.go file carries, or `// CLI: none` for a file that +// declares no command. +var cliHeaderRe = regexp.MustCompile(`^// CLI: (.+)$`) + +// commandsDefinedInMainGo are registered inline in main.go rather than in an +// actions/ file, so no header can declare them. Keep this list empty if you +// can: a command belongs in actions/. +var commandsDefinedInMainGo = map[string]bool{"build-info": true} + +// headerNames reads the `// CLI:` header from one actions file. +func headerNames(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + if line == "package actions" { + break + } + m := cliHeaderRe.FindStringSubmatch(line) + if m == nil { + continue + } + if strings.TrimSpace(m[1]) == "none" { + return nil + } + var names []string + for _, part := range strings.Split(m[1], ",") { + part = strings.TrimSpace(part) + if !strings.HasPrefix(part, "pipekit ") { + t.Errorf("%s: CLI header entry %q should read `pipekit `", filepath.Base(path), part) + continue + } + names = append(names, strings.TrimPrefix(part, "pipekit ")) + } + return names + } + t.Errorf("%s: no `// CLI:` header. Every actions file must state the command "+ + "it implements (or `// CLI: none`), because the filename does not always match — "+ + "cache_key.go is `cache-key`, timecmd.go is `time`, misc.go is three commands.", filepath.Base(path)) + return nil +} + +// declaredCommands maps CLI name -> the actions file whose header claims it. +func declaredCommands(t *testing.T) map[string]string { + t.Helper() + paths, err := filepath.Glob(filepath.Join("actions", "*.go")) + if err != nil { + t.Fatal(err) + } + if len(paths) == 0 { + t.Fatal("no actions/*.go files found") + } + + declared := make(map[string]string) + for _, path := range paths { + if strings.HasSuffix(path, "_test.go") { + continue + } + for _, name := range headerNames(t, path) { + if prev, dup := declared[name]; dup { + t.Errorf("command %q is claimed by both %s and %s", name, prev, filepath.Base(path)) + continue + } + declared[name] = filepath.Base(path) + } + } + return declared +} + +// Every registered command must be findable from the source tree by name. +// Without this, `actions/cache_key.go` invites you to write `pipekit cache_key`, +// which does not exist. +func TestCLIHeadersCoverEveryRegisteredCommand(t *testing.T) { + declared := declaredCommands(t) + + for _, cmd := range commands() { + if commandsDefinedInMainGo[cmd.Name] { + continue + } + if _, ok := declared[cmd.Name]; !ok { + t.Errorf("command %q is registered but no actions/*.go `// CLI:` header declares it", cmd.Name) + } + } +} + +// ...and the headers must not claim commands that do not exist. +func TestCLIHeadersDoNotClaimUnregisteredCommands(t *testing.T) { + registered := make(map[string]bool) + for _, cmd := range commands() { + registered[cmd.Name] = true + } + + for name, file := range declaredCommands(t) { + if !registered[name] { + t.Errorf("%s declares `pipekit %s`, but no such command is registered in main.go", file, name) + } + } +} + +// The files whose name genuinely differs from the CLI command are the reason +// the headers exist. Pin them so a future rename cannot quietly drop the note. +func TestCLIHeadersDocumentTheFilenameMismatches(t *testing.T) { + declared := declaredCommands(t) + + mismatches := map[string]string{ + "cache-key": "cache_key.go", // underscore vs hyphen + "time": "timecmd.go", // suffixed filename + "port": "misc.go", // three commands in one file + "uuid": "misc.go", + "random": "misc.go", + "yaml": "json.go", // shares a file with `json` + } + + for cmd, wantFile := range mismatches { + got, ok := declared[cmd] + if !ok { + t.Errorf("no header declares `pipekit %s`", cmd) + continue + } + if got != wantFile { + t.Errorf("`pipekit %s` is declared by %s, expected %s", cmd, got, wantFile) + } + } +} + +// A duplicate registration would silently shadow a command. +func TestRegisteredCommandNamesAreUnique(t *testing.T) { + seen := make(map[string]bool) + var dupes []string + for _, cmd := range commands() { + if seen[cmd.Name] { + dupes = append(dupes, cmd.Name) + } + seen[cmd.Name] = true + } + sort.Strings(dupes) + if len(dupes) > 0 { + t.Errorf("duplicate command names registered: %v", dupes) + } +} diff --git a/services/archive_service.go b/services/archive_service.go index 9510317..6d3e5fc 100644 --- a/services/archive_service.go +++ b/services/archive_service.go @@ -440,7 +440,9 @@ func listTar(input string, format string) ([]ArchiveEntry, error) { entries = append(entries, ArchiveEntry{Name: hdr.Name, Size: hdr.Size, Mode: os.FileMode(hdr.Mode).String()}) } sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) - return entries, nil + // listZip builds with make(), so an empty zip already lists as []. + // An empty tar must not disagree with it. + return emptyIfNil(entries), nil } func listZip(input string) ([]ArchiveEntry, error) { diff --git a/services/archive_service_test.go b/services/archive_service_test.go index 0929deb..c6e6117 100644 --- a/services/archive_service_test.go +++ b/services/archive_service_test.go @@ -3,6 +3,7 @@ package services import ( "archive/tar" "archive/zip" + "encoding/json" "os" "path/filepath" "strings" @@ -114,3 +115,55 @@ func archiveHasEntry(entries []ArchiveEntry, name string) bool { } return false } + +// Regression: listZip builds its slice with make() and so already returned +// [], while listTar used `var entries []ArchiveEntry` and returned nil — +// making `archive list --json` emit `null` for an empty tar and `[]` for an +// empty zip. Both must be []. +func TestListArchive_EmptyArchiveIsEmptySlice(t *testing.T) { + dir := t.TempDir() + + tarPath := filepath.Join(dir, "empty.tar") + tf, err := os.Create(tarPath) + if err != nil { + t.Fatal(err) + } + if err := tar.NewWriter(tf).Close(); err != nil { + t.Fatal(err) + } + if err := tf.Close(); err != nil { + t.Fatal(err) + } + + zipPath := filepath.Join(dir, "empty.zip") + zf, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + if err := zip.NewWriter(zf).Close(); err != nil { + t.Fatal(err) + } + if err := zf.Close(); err != nil { + t.Fatal(err) + } + + for _, path := range []string{tarPath, zipPath} { + entries, err := ListArchive(path, "") + if err != nil { + t.Fatalf("ListArchive(%s): %v", filepath.Base(path), err) + } + if entries == nil { + t.Errorf("ListArchive(%s) returned a nil slice; it marshals to null", filepath.Base(path)) + } + if len(entries) != 0 { + t.Errorf("ListArchive(%s) = %#v, want no entries", filepath.Base(path), entries) + } + data, err := json.Marshal(entries) + if err != nil { + t.Fatal(err) + } + if string(data) != "[]" { + t.Errorf("ListArchive(%s) as JSON = %s, want []", filepath.Base(path), data) + } + } +} diff --git a/services/changelog_service.go b/services/changelog_service.go index baa4bc0..9f5b00d 100644 --- a/services/changelog_service.go +++ b/services/changelog_service.go @@ -49,7 +49,9 @@ func GenerateChangelog(opts ChangelogOptions) (string, []ChangelogEntry, error) } entries = append(entries, entry) } - return FormatChangelogMarkdown(entries, opts.Conventional), entries, nil + // An empty commit range leaves entries nil; `changelog generate + // --format json` still has to emit []. + return FormatChangelogMarkdown(entries, opts.Conventional), emptyIfNil(entries), nil } // FormatChangelogMarkdown renders entries as release-note markdown. diff --git a/services/changelog_service_test.go b/services/changelog_service_test.go index 898be14..f82a06d 100644 --- a/services/changelog_service_test.go +++ b/services/changelog_service_test.go @@ -1,6 +1,7 @@ package services import ( + "encoding/json" "os" "path/filepath" "strings" @@ -36,3 +37,31 @@ func TestGenerateChangelogConventional(t *testing.T) { t.Fatalf("unexpected changelog:\n%s", markdown) } } + +// Regression: an empty commit range left entries nil, so +// `changelog generate --format json` emitted `null`. +func TestGenerateChangelog_EmptyRangeIsEmptySlice(t *testing.T) { + dir := initGitRepo(t) + t.Chdir(dir) + + markdown, entries, err := GenerateChangelog(ChangelogOptions{From: "HEAD", To: "HEAD"}) + if err != nil { + t.Fatal(err) + } + if entries == nil { + t.Fatal("entries is nil; it marshals to null instead of []") + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %#v", entries) + } + data, err := json.Marshal(entries) + if err != nil { + t.Fatal(err) + } + if string(data) != "[]" { + t.Errorf("entries as JSON = %s, want []", data) + } + if !strings.Contains(markdown, "No changes.") { + t.Errorf("markdown = %q, want the no-changes notice", markdown) + } +} diff --git a/services/diff_service.go b/services/diff_service.go index 6e8d25d..c2d88be 100644 --- a/services/diff_service.go +++ b/services/diff_service.go @@ -115,7 +115,13 @@ func DiffAffected(base, head string, config domain.DiffConfig) ([]string, error) } // FormatDiffOutput formats a list of strings in the specified format. +// +// A filtered-to-empty result arrives here as a nil slice. It is normalised +// once, up front, so no branch below can emit `null` — see emptyIfNil for why +// `null` is not merely untidy but breaks a GitHub Actions matrix. func FormatDiffOutput(items []string, format string) (string, error) { + items = emptyIfNil(items) + switch strings.ToLower(format) { case "json": data, err := json.Marshal(items) diff --git a/services/diff_service_test.go b/services/diff_service_test.go index 1017b3e..7725b36 100644 --- a/services/diff_service_test.go +++ b/services/diff_service_test.go @@ -31,3 +31,56 @@ func TestMatchGlob_DoubleStar(t *testing.T) { } } } + +// Regression: a filtered-to-empty diff used to marshal the nil slice as +// `null`. Consumed as a GitHub Actions matrix, `null` errors the workflow +// while `[]` correctly skips the job — so "nothing changed" turned a green +// no-op into a red build. +func TestFormatDiffOutput_EmptyNeverMarshalsToNull(t *testing.T) { + tests := []struct { + name string + items []string + }{ + {name: "nil slice", items: nil}, + {name: "empty non-nil slice", items: []string{}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := FormatDiffOutput(tc.items, "json") + if err != nil { + t.Fatalf("FormatDiffOutput error: %v", err) + } + if got != "[]" { + t.Errorf("FormatDiffOutput(%v, json) = %q, want %q", tc.items, got, "[]") + } + }) + } +} + +func TestFormatDiffOutput_EmptyOtherFormats(t *testing.T) { + for _, format := range []string{"list", "csv", ""} { + got, err := FormatDiffOutput(nil, format) + if err != nil { + t.Fatalf("FormatDiffOutput(nil, %q) error: %v", format, err) + } + if got != "" { + t.Errorf("FormatDiffOutput(nil, %q) = %q, want empty string", format, got) + } + } +} + +func TestFormatDiffOutput_PopulatedJSON(t *testing.T) { + got, err := FormatDiffOutput([]string{"a", "b"}, "json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != `["a","b"]` { + t.Errorf("got %q, want %q", got, `["a","b"]`) + } +} + +func TestFormatDiffOutput_UnknownFormat(t *testing.T) { + if _, err := FormatDiffOutput(nil, "xml"); err == nil { + t.Error("expected an error for an unknown format") + } +} diff --git a/services/http_service.go b/services/http_service.go index 2c27b31..a0d6947 100644 --- a/services/http_service.go +++ b/services/http_service.go @@ -429,5 +429,8 @@ func ExecuteHTTPPaginated(opts HTTPRequestOptions, itemsPath string, maxPages in opts.URL = ParseLinkNext(res.Headers.Get("Link"), opts.URL) } - return items, pages, nil + // An API whose first page is `[]` leaves items nil: appending zero + // elements to a nil slice is a no-op. An un-paginated GET forwards `[]` + // faithfully, so --paginate must not turn the same response into `null`. + return emptyIfNil(items), pages, nil } diff --git a/services/http_service_test.go b/services/http_service_test.go index 09b279c..dcb1085 100644 --- a/services/http_service_test.go +++ b/services/http_service_test.go @@ -204,3 +204,31 @@ func TestExecuteHTTPPaginated_ItemsPathAndMaxPages(t *testing.T) { t.Error("expected error when items path is not an array") } } + +// Regression: an endpoint whose only page is `[]` left the merged slice nil, +// so `http get --paginate` printed `null` where the same request without +// --paginate printed `[]`. Downstream `fromJson`/matrix consumers break on it. +func TestExecuteHTTPPaginated_EmptyFirstPageIsEmptySlice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `[]`) + })) + defer srv.Close() + + items, pages, err := ExecuteHTTPPaginated(HTTPRequestOptions{URL: srv.URL + "/items"}, "", 0) + if err != nil { + t.Fatalf("ExecuteHTTPPaginated() error = %v", err) + } + if pages != 1 { + t.Errorf("pages = %d, want 1", pages) + } + if items == nil { + t.Fatal("items is nil; it marshals to null instead of []") + } + data, err := json.Marshal(items) + if err != nil { + t.Fatal(err) + } + if string(data) != "[]" { + t.Errorf("merged items as JSON = %s, want []", data) + } +} diff --git a/services/json_output.go b/services/json_output.go new file mode 100644 index 0000000..bb7890d --- /dev/null +++ b/services/json_output.go @@ -0,0 +1,23 @@ +package services + +// emptyIfNil returns a non-nil slice, so that encoding/json emits `[]` rather +// than `null`. +// +// This matters beyond tidiness. A pipekit JSON array is usually consumed as a +// GitHub Actions matrix, and the two values behave very differently there: +// +// matrix: {"item": []} → the job is skipped (correct: nothing to do) +// matrix: {"item": null} → the workflow *errors* (a red build for a no-op) +// +// So "no results" must never marshal to `null`. Anything in this package that +// marshals a slice built with `var x []T` + conditional `append` has to run it +// through here first; `make([]T, 0)` is already safe. +// +// FormatDiffJSON in structdiff_service.go does the same thing inline for +// []DiffEntry; this is the generic version. +func emptyIfNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/services/matrix_service.go b/services/matrix_service.go index 0f2b3d4..88530c1 100644 --- a/services/matrix_service.go +++ b/services/matrix_service.go @@ -25,7 +25,7 @@ func MatrixFromDirs(dirPath, key string) (string, error) { } sort.Strings(names) - matrix := map[string][]string{key: names} + matrix := map[string][]string{key: emptyIfNil(names)} data, err := json.Marshal(matrix) if err != nil { return "", err @@ -46,7 +46,7 @@ func MatrixFromFiles(pattern, key string) (string, error) { } sort.Strings(names) - matrix := map[string][]string{key: names} + matrix := map[string][]string{key: emptyIfNil(names)} data, err := json.Marshal(matrix) if err != nil { return "", err @@ -73,7 +73,10 @@ func MatrixFromJSON(r io.Reader, key string, filterField, filterValue string) (s raw = filtered } - matrix := map[string]interface{}{key: raw} + // raw is nil in two cases that both have to emit `[]`, not `null`: + // a filter that matched nothing, and a literal `null` on stdin (which + // encoding/json decodes into a nil slice without erroring). + matrix := map[string]interface{}{key: emptyIfNil(raw)} data, err := json.Marshal(matrix) if err != nil { return "", err diff --git a/services/matrix_service_test.go b/services/matrix_service_test.go index 68177fb..448e875 100644 --- a/services/matrix_service_test.go +++ b/services/matrix_service_test.go @@ -96,3 +96,93 @@ func TestMatrixCombine(t *testing.T) { t.Errorf("expected 4 combinations (2x2), got %d", len(includes)) } } + +// Regression: an empty result must be `[]`, never `null`. GitHub Actions +// errors the workflow on a null matrix but skips the job on an empty one. +func TestMatrixFromDirs_NoSubdirsIsEmptyArray(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "loose.txt"), []byte(""), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmpDir, ".hidden"), 0755); err != nil { + t.Fatal(err) + } + + result, err := MatrixFromDirs(tmpDir, "service") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != `{"service":[]}` { + t.Errorf("MatrixFromDirs = %s, want {\"service\":[]}", result) + } +} + +func TestMatrixFromFiles_NoMatchesIsEmptyArray(t *testing.T) { + result, err := MatrixFromFiles(filepath.Join(t.TempDir(), "*.nope"), "config") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != `{"config":[]}` { + t.Errorf("MatrixFromFiles = %s, want {\"config\":[]}", result) + } +} + +func TestMatrixFromJSON_EmptyResultIsEmptyArray(t *testing.T) { + tests := []struct { + name string + input string + filterField string + filterValue string + }{ + { + name: "empty input array", + input: `[]`, + }, + { + name: "filter matches nothing", + input: `[{"name":"api","deploy":"false"}]`, + filterField: "deploy", + filterValue: "true", + }, + { + // Defensive: someone else's tool (or an upstream pipekit command + // on an older build) may hand us a literal null. encoding/json + // decodes it into a nil slice without erroring, so it would + // otherwise propagate straight through. + name: "literal null on stdin", + input: `null`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := MatrixFromJSON(strings.NewReader(tc.input), "item", tc.filterField, tc.filterValue) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != `{"item":[]}` { + t.Errorf("MatrixFromJSON(%s) = %s, want {\"item\":[]}", tc.input, result) + } + }) + } +} + +func TestMatrixFromJSON_RejectsNonArray(t *testing.T) { + if _, err := MatrixFromJSON(strings.NewReader(`{"a":1}`), "item", "", ""); err == nil { + t.Error("expected an error for a JSON object input") + } +} + +func TestMatrixShard_EmptyShardIsEmptyArray(t *testing.T) { + // MatrixShard feeds FormatDiffOutput via `matrix shard --format json`. + out, err := MatrixShard([]string{"a"}, 3, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, err := FormatDiffOutput(out, "json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "[]" { + t.Errorf("empty shard as json = %q, want %q", got, "[]") + } +}