From 5e76f841b88f929b3befb26bdbff472b10ed44b8 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Mon, 6 Jul 2026 15:25:42 +0200 Subject: [PATCH] feat: add report, annotate, lock commands plus semantic diff, multi-doc YAML, pagination, envsubst, and CI guards New command groups: - report: parse JUnit XML / lcov / cobertura into markdown summaries, GITHUB_OUTPUT counts, and threshold gates (--fail-on-failures, --min) - annotate: emit inline PR annotations on GitHub (plain file:line fallback elsewhere) and convert any linter's JSON findings - lock: file-based mutex (acquire/release/run) with timeout and stale takeover for serializing steps on shared runners Extended commands: - json/yaml diff: semantic diff ignoring key order and formatting; text/json/markdown output, --ignore prefixes, --exit-code gate - yaml get/set/del --doc: address one document in a multi-doc stream by index or field match; edits leave sibling documents byte-identical and compose with --preserve - http get --paginate: follow Link rel=next headers and merge pages (--items for nested arrays, --max-pages cap) - render --envsubst: $VAR / ${VAR} / ${VAR:-default} substitution with --strict unset-variable gate - assert versions-match: monorepo version-sync guard across manifests - artifact assert --max-size/--min-size: size budgets with human units - version next: line-anchored conventional-commit parsing (feat/fix/ perf/refactor/revert, ! bang, BREAKING CHANGE footers in bodies) Co-Authored-By: Claude Fable 5 --- README.md | 13 +- actions/annotate.go | 114 +++++++++++ actions/artifact.go | 27 ++- actions/assert.go | 18 ++ actions/http.go | 43 ++++- actions/json.go | 124 +++++++++++- actions/lock.go | 80 ++++++++ actions/render.go | 20 +- actions/report.go | 123 ++++++++++++ docs/COMMANDS.md | 180 +++++++++++++++++- docs/EXAMPLES.md | 121 ++++++++++++ integration/timesavers_test.go | 152 +++++++++++++++ main.go | 3 + services/annotate_service.go | 176 +++++++++++++++++ services/annotate_service_test.go | 88 +++++++++ services/artifact_service.go | 68 +++++++ services/artifact_service_test.go | 53 +++++- services/http_service.go | 78 ++++++++ services/http_service_test.go | 67 +++++++ services/lock_service.go | 106 +++++++++++ services/lock_service_test.go | 82 ++++++++ services/multidoc_service.go | 216 +++++++++++++++++++++ services/multidoc_service_test.go | 177 ++++++++++++++++++ services/render_service.go | 73 ++++++++ services/render_service_test.go | 54 +++++- services/report_service.go | 281 ++++++++++++++++++++++++++++ services/report_service_test.go | 142 ++++++++++++++ services/structdiff_service.go | 172 +++++++++++++++++ services/structdiff_service_test.go | 127 +++++++++++++ services/version_service.go | 111 ++++++++--- services/version_service_test.go | 70 ++++++- 31 files changed, 3089 insertions(+), 70 deletions(-) create mode 100644 actions/annotate.go create mode 100644 actions/lock.go create mode 100644 actions/report.go create mode 100644 integration/timesavers_test.go create mode 100644 services/annotate_service.go create mode 100644 services/annotate_service_test.go create mode 100644 services/lock_service.go create mode 100644 services/lock_service_test.go create mode 100644 services/multidoc_service.go create mode 100644 services/multidoc_service_test.go create mode 100644 services/report_service.go create mode 100644 services/report_service_test.go create mode 100644 services/structdiff_service.go create mode 100644 services/structdiff_service_test.go diff --git a/README.md b/README.md index 498b437..e71b176 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)** | `mask` | Hide secrets in logs (regex patterns, partial reveal, GitHub `::add-mask::`) | [↗](docs/COMMANDS.md#mask) | | `transform` | base64, URL-encode, case conversion, regex replace, templates, hash, slug | [↗](docs/COMMANDS.md#transform) | | `summary` | Append markdown / tables / badges / collapsibles to `$GITHUB_STEP_SUMMARY` | [↗](docs/COMMANDS.md#summary) | -| `assert` | Pipeline guards: env vars, files, JSON paths, semver, URL health | [↗](docs/COMMANDS.md#assert) | +| `assert` | Pipeline guards: env vars, files, JSON paths, semver, URL health, version sync | [↗](docs/COMMANDS.md#assert) | | `matrix` | Build GitHub Actions matrix JSON from dirs / files / JSON / Cartesian product | [↗](docs/COMMANDS.md#matrix) | | `notify` | Slack / Discord / Teams / generic webhook with status formatting | [↗](docs/COMMANDS.md#notify) | | `wait` | Poll a URL / TCP port / shell command until ready | [↗](docs/COMMANDS.md#wait) | @@ -86,22 +86,25 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)** | `retry` | Run any command with attempt count, delay, backoff, exit-code filtering | [↗](docs/COMMANDS.md#retry) | | `cache-key` | Deterministic SHA256 cache keys from files / globs / composite parts | [↗](docs/COMMANDS.md#cache-key) | | `checksum` | Generate / verify release checksums for artifact files | [↗](docs/COMMANDS.md#checksum) | -| `artifact` | Assert artifacts exist and generate size/SHA256 manifests | [↗](docs/COMMANDS.md#artifact) | +| `artifact` | Assert artifacts exist (with size budgets) and generate size/SHA256 manifests | [↗](docs/COMMANDS.md#artifact) | | `archive` | Pack, list, and unpack zip/tar/tar.gz/tar.xz/tar.zst archives | [↗](docs/COMMANDS.md#archive) | | `git` | CI-friendly git metadata: ref, SHA, tags, dirty state | [↗](docs/COMMANDS.md#git) | | `changelog` | Generate release notes from git commit ranges | [↗](docs/COMMANDS.md#changelog) | | `config` | Resolve env-specific config maps; map branches to environments | [↗](docs/COMMANDS.md#config) | | `parse` | Pull fenced code blocks / YAML / frontmatter out of issue bodies, PR comments, markdown | [↗](docs/COMMANDS.md#parse) | | `comment` | Render, inspect, select, and amend hidden-anchor PR comments | [↗](docs/COMMANDS.md#comment) | -| `json` / `yaml` | Get / set / del / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV | [↗](docs/COMMANDS.md#json) | -| `render` | Render Go templates with a sprig-like FuncMap and stacked `--values` files | [↗](docs/COMMANDS.md#render) | +| `json` / `yaml` | Get / set / del / semantic diff / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV — incl. multi-doc YAML addressing (`--doc`) | [↗](docs/COMMANDS.md#json) | +| `render` | Render Go templates with a sprig-like FuncMap and stacked `--values` files; `--envsubst` mode | [↗](docs/COMMANDS.md#render) | | `exec` | Unified retry + mask + tee + timeout command runner | [↗](docs/COMMANDS.md#exec) | -| `http` | Curl-like HTTP requests, JSON extraction, uploads, and chained flows | [↗](docs/COMMANDS.md#http) | +| `http` | Curl-like HTTP requests, JSON extraction, uploads, chained flows, and Link-header pagination | [↗](docs/COMMANDS.md#http) | | `url parse` | Split a URL into `SCHEME / HOST / PORT / USER / PASSWORD / PATH / QUERY` env vars | [↗](docs/COMMANDS.md#url) | | `image parse` | Split a container image ref into registry / repository / tag / digest | [↗](docs/COMMANDS.md#image) | | `time` | RFC3339 / unix / tag / compact / iso timestamps; format conversion; arithmetic | [↗](docs/COMMANDS.md#time) | | `port free` · `uuid` · `random` | Tiny generators for ephemeral resource names and ports | [↗](docs/COMMANDS.md#misc) | | `doctor` | Diagnose CI platform, env-var contract, tool availability | [↗](docs/COMMANDS.md#doctor) | +| `report` | Parse JUnit XML / lcov / cobertura into summaries, outputs, and threshold gates | [↗](docs/COMMANDS.md#report) | +| `annotate` | Emit inline PR annotations (GitHub) or plain diagnostics; convert linter JSON | [↗](docs/COMMANDS.md#annotate) | +| `lock` | File-based mutex: acquire / release / run with timeout and stale takeover | [↗](docs/COMMANDS.md#lock) | Full flag reference → **[docs/COMMANDS.md](docs/COMMANDS.md)** diff --git a/actions/annotate.go b/actions/annotate.go new file mode 100644 index 0000000..1dcc6ef --- /dev/null +++ b/actions/annotate.go @@ -0,0 +1,114 @@ +package actions + +import ( + "fmt" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// AnnotateCommand returns the CI annotation command group. +func AnnotateCommand() cli.Command { + levelCmd := func(level, usage string) cli.Command { + return cli.Command{ + Name: level, + Usage: usage, + ArgsUsage: "MESSAGE", + Flags: annotateFlags(), + Action: func(c *cli.Context) error { + msg, err := firstArgOrErr(c, "MESSAGE") + if err != nil { + return err + } + a := services.Annotation{ + Level: level, + File: c.String("file"), + Line: c.Int("line"), + EndLine: c.Int("end-line"), + Col: c.Int("col"), + Title: c.String("title"), + Message: msg, + } + fmt.Println(services.FormatAnnotation(a, annotatePlatform(c))) + return nil + }, + } + } + + return cli.Command{ + Name: "annotate", + Usage: "emit CI annotations (inline PR comments on GitHub, plain lines elsewhere)", + Subcommands: []cli.Command{ + levelCmd("error", "emit an error annotation"), + levelCmd("warning", "emit a warning annotation"), + levelCmd("notice", "emit a notice annotation"), + { + Name: "from-json", + Usage: "convert a JSON array of linter findings into annotations", + ArgsUsage: "[FILE]", + Flags: []cli.Flag{ + cli.StringFlag{Name: "file-key", Value: "file", Usage: "field holding the file path"}, + cli.StringFlag{Name: "line-key", Value: "line", Usage: "field holding the line number"}, + cli.StringFlag{Name: "col-key", Value: "col", Usage: "field holding the column"}, + cli.StringFlag{Name: "message-key", Value: "message", Usage: "field holding the message"}, + cli.StringFlag{Name: "severity-key", Value: "severity", Usage: "field holding the severity"}, + cli.StringFlag{Name: "title-key", Value: "title", Usage: "field holding the title"}, + cli.StringFlag{Name: "platform", Usage: "force output platform: github-actions, plain (default: auto-detect)"}, + cli.BoolFlag{Name: "fail-on-errors", Usage: "exit 1 when any finding normalizes to error level"}, + }, + Action: func(c *cli.Context) error { + data, err := readReportInput(c) + if err != nil { + return err + } + keys := services.AnnotationKeys{ + File: c.String("file-key"), + Line: c.String("line-key"), + Col: c.String("col-key"), + Message: c.String("message-key"), + Severity: c.String("severity-key"), + Title: c.String("title-key"), + } + annotations, err := services.ParseAnnotationsJSON(data, keys) + if err != nil { + return err + } + platform := annotatePlatform(c) + errors := 0 + for _, a := range annotations { + if services.NormalizeAnnotationLevel(a.Level) == "error" { + errors++ + } + fmt.Println(services.FormatAnnotation(a, platform)) + } + if c.Bool("fail-on-errors") && errors > 0 { + return cli.NewExitError(fmt.Sprintf("%d error finding(s)", errors), 1) + } + return nil + }, + }, + }, + } +} + +func annotateFlags() []cli.Flag { + return []cli.Flag{ + cli.StringFlag{Name: "file, f", Usage: "source file the annotation points at"}, + cli.IntFlag{Name: "line, l", Usage: "line number"}, + cli.IntFlag{Name: "end-line", Usage: "end line for a range"}, + cli.IntFlag{Name: "col", Usage: "column number"}, + cli.StringFlag{Name: "title, t", Usage: "annotation title"}, + cli.StringFlag{Name: "platform", Usage: "force output platform: github-actions, plain (default: auto-detect)"}, + } +} + +func annotatePlatform(c *cli.Context) string { + if p := c.String("platform"); p != "" { + if p == "github" { + return "github-actions" + } + return p + } + return services.DetectCI().Name +} diff --git a/actions/artifact.go b/actions/artifact.go index 1ca3681..2c68f6f 100644 --- a/actions/artifact.go +++ b/actions/artifact.go @@ -38,7 +38,7 @@ func ArtifactCommand() cli.Command { } out += "\n" if path := c.String("output"); path != "" { - return os.WriteFile(path, []byte(out), 0644) + return os.WriteFile(path, []byte(out), 0o644) } fmt.Print(out) return nil @@ -46,14 +46,35 @@ func ArtifactCommand() cli.Command { }, { Name: "assert", - Usage: "fail unless each artifact path or glob resolves", + Usage: "fail unless each artifact path or glob resolves (and fits the size budget)", ArgsUsage: "PATH_OR_GLOB...", + Flags: []cli.Flag{ + cli.StringFlag{Name: "max-size", Usage: "fail when any matched file exceeds this size (e.g. 5MB, 100KiB)"}, + cli.StringFlag{Name: "min-size", Usage: "fail when any matched file is smaller than this size"}, + }, Action: func(c *cli.Context) error { patterns, err := argsOrErr(c, "artifact path or glob") if err != nil { return err } - return services.AssertArtifacts(patterns) + if err := services.AssertArtifacts(patterns); err != nil { + return err + } + if c.String("max-size") == "" && c.String("min-size") == "" { + return nil + } + var min, max int64 + if v := c.String("max-size"); v != "" { + if max, err = services.ParseSize(v); err != nil { + return cli.NewExitError(err.Error(), 1) + } + } + if v := c.String("min-size"); v != "" { + if min, err = services.ParseSize(v); err != nil { + return cli.NewExitError(err.Error(), 1) + } + } + return services.AssertArtifactSizes(patterns, min, max) }, }, }, diff --git a/actions/assert.go b/actions/assert.go index c7ac61e..927fe74 100644 --- a/actions/assert.go +++ b/actions/assert.go @@ -1,6 +1,7 @@ package actions import ( + "fmt" "os" "strconv" "strings" @@ -177,6 +178,23 @@ func AssertCommand() cli.Command { return nil }, }, + { + Name: "versions-match", + Usage: "assert all files declare the same version (monorepo release guard)", + ArgsUsage: "FILE FILE...", + Action: func(c *cli.Context) error { + files := c.Args() + if len(files) < 2 { + return cli.NewExitError("at least two files required", 1) + } + version, err := services.AssertVersionsMatch(files) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + fmt.Println(version) + return nil + }, + }, { Name: "path", Usage: "assert one or more paths exist (file or directory)", diff --git a/actions/http.go b/actions/http.go index 7f75b7c..c6595e4 100644 --- a/actions/http.go +++ b/actions/http.go @@ -55,6 +55,9 @@ func httpMethodCommand(method string) cli.Command { cli.BoolFlag{Name: "backoff", Usage: "exponential backoff between retries"}, cli.BoolFlag{Name: "insecure, k", Usage: "skip TLS certificate verification"}, cli.BoolFlag{Name: "verbose, v", Usage: "print response status and headers to stderr"}, + cli.BoolFlag{Name: "paginate", Usage: "follow Link rel=\"next\" headers and merge all pages (GET only)"}, + cli.StringFlag{Name: "items", Usage: "jq-style path to the items array inside each page (default: body is the array)"}, + cli.IntFlag{Name: "max-pages", Value: 50, Usage: "pagination safety cap"}, }, Action: func(c *cli.Context) error { urlStr, err := firstArgOrErr(c, "URL") @@ -98,7 +101,7 @@ func httpMethodCommand(method string) cli.Command { return cli.NewExitError(err.Error(), 1) } - res, err := services.ExecuteHTTPRequest(services.HTTPRequestOptions{ + opts := services.HTTPRequestOptions{ Method: method, URL: urlStr, Headers: headers, @@ -109,24 +112,44 @@ func httpMethodCommand(method string) cli.Command { RetryDelay: retryDelay, Backoff: c.Bool("backoff"), InsecureTLS: c.Bool("insecure"), - }) - if err != nil { - return cli.NewExitError(err.Error(), 1) - } - if c.Bool("verbose") { - printHTTPVerbose(res) } - out := res.Body + var out []byte + if c.Bool("paginate") { + if method != "GET" { + return cli.NewExitError("--paginate only works with GET", 1) + } + items, pages, err := services.ExecuteHTTPPaginated(opts, c.String("items"), c.Int("max-pages")) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + if c.Bool("verbose") { + fmt.Fprintf(os.Stderr, "fetched %d item(s) across %d page(s)\n", len(items), pages) + } + out, err = json.Marshal(items) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + out = append(out, '\n') + } else { + res, err := services.ExecuteHTTPRequest(opts) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + if c.Bool("verbose") { + printHTTPVerbose(res) + } + out = res.Body + } if path := c.String("jq"); path != "" { - val, err := services.ExtractHTTPJSON(res.Body, path) + val, err := services.ExtractHTTPJSON(out, path) if err != nil { return cli.NewExitError(err.Error(), 1) } out = []byte(formatHTTPResult(val, c.Bool("raw"))) } if output := c.String("output"); output != "" { - return os.WriteFile(output, out, 0644) + return os.WriteFile(output, out, 0o644) } if outputKey := c.String("to-github-output"); outputKey != "" { return services.WriteToGitHubOutputValue(outputKey, strings.TrimRight(string(out), "\n")) diff --git a/actions/json.go b/actions/json.go index 8f0d82d..868ca58 100644 --- a/actions/json.go +++ b/actions/json.go @@ -38,6 +38,62 @@ func dataCommand(name, usage string, defaultFormat services.DataFormat) cli.Comm dataConvertCmd(defaultFormat), dataPrettyCmd(defaultFormat), dataTableCmd(defaultFormat), + dataDiffCmd(defaultFormat), + }, + } +} + +func dataDiffCmd(def services.DataFormat) cli.Command { + return cli.Command{ + Name: "diff", + Usage: "semantic diff of two files (key order and formatting ignored)", + ArgsUsage: "FILE1 FILE2", + Flags: []cli.Flag{ + cli.StringFlag{Name: "output, o", Value: "text", Usage: "output format: text, json, markdown"}, + cli.StringSliceFlag{Name: "ignore", Usage: "path prefix to exclude (repeatable, e.g. .metadata.generation)"}, + cli.BoolFlag{Name: "exit-code", Usage: "exit 1 when the files differ (like git diff --exit-code)"}, + cli.BoolFlag{Name: "to-summary", Usage: "append markdown output to GITHUB_STEP_SUMMARY"}, + }, + Action: func(c *cli.Context) error { + args := c.Args() + if len(args) < 2 { + return cli.NewExitError("two files required", 1) + } + docA, _, err := loadFileWithDefault(args[0], def) + if err != nil { + return err + } + docB, _, err := loadFileWithDefault(args[1], def) + if err != nil { + return err + } + entries := services.StructDiff(docA, docB, c.StringSlice("ignore")) + + var out string + switch c.String("output") { + case "json": + out, err = services.FormatDiffJSON(entries) + if err != nil { + return err + } + case "markdown", "md": + out = services.FormatDiffMarkdown(entries) + default: + out = services.FormatDiffText(entries) + } + + if c.Bool("to-summary") { + md := services.FormatDiffMarkdown(entries) + if err := services.AppendToGitHubSummary(md); err != nil { + return err + } + } + fmt.Print(out) + + if c.Bool("exit-code") && len(entries) > 0 { + return cli.NewExitError("", 1) + } + return nil }, } } @@ -51,18 +107,30 @@ func dataGetCmd(def services.DataFormat) cli.Command { cli.StringFlag{Name: "path, p", Usage: "jq-style path expression (e.g. .image.tag)"}, cli.BoolFlag{Name: "raw, r", Usage: "if the result is a string, print it raw (no JSON quotes)"}, cli.StringFlag{Name: "from", Usage: "input format override (default: detect from extension)"}, + docSelectorFlag(), outputKeyFlag(), }, Action: func(c *cli.Context) error { - doc, _, err := readDoc(c, def) - if err != nil { - return err - } path := c.String("path") if path == "" { return cli.NewExitError("--path required", 1) } - val, err := services.JSONGet(doc, path) + var val interface{} + var err error + if sel := c.String("doc"); sel != "" { + data, readErr := readAllInput(c) + if readErr != nil { + return readErr + } + val, err = services.MultiDocGet(data, sel, path) + } else { + var doc interface{} + doc, _, err = readDoc(c, def) + if err != nil { + return err + } + val, err = services.JSONGet(doc, path) + } if err != nil { return err } @@ -84,6 +152,7 @@ func dataSetCmd(def services.DataFormat) cli.Command { cli.BoolFlag{Name: "in-place, i", Usage: "write back to the file (default: stdout)"}, cli.BoolFlag{Name: "pretty", Usage: "pretty-print output"}, cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, change only the target (yaml, json)"}, + docSelectorFlag(), }, Action: func(c *cli.Context) error { file, err := firstArgOrErr(c, "FILE") @@ -103,6 +172,12 @@ func dataSetCmd(def services.DataFormat) cli.Command { newVal = c.String("value") } + if sel := c.String("doc"); sel != "" { + return writeRawEdit(file, c.Bool("in-place"), func(data []byte) ([]byte, error) { + return services.MultiDocSet(data, sel, path, newVal, c.Bool("preserve")) + }) + } + if c.Bool("preserve") { return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) { return services.SetPreserving(data, format, path, newVal) @@ -132,6 +207,7 @@ func dataDelCmd(def services.DataFormat) cli.Command { cli.BoolFlag{Name: "in-place, i"}, cli.BoolFlag{Name: "pretty"}, cli.BoolFlag{Name: "preserve, P", Usage: "surgical edit: keep comments/formatting, remove only the target (yaml, json)"}, + docSelectorFlag(), }, Action: func(c *cli.Context) error { file, err := firstArgOrErr(c, "FILE") @@ -143,6 +219,12 @@ func dataDelCmd(def services.DataFormat) cli.Command { return cli.NewExitError("--path required", 1) } + if sel := c.String("doc"); sel != "" { + return writeRawEdit(file, c.Bool("in-place"), func(data []byte) ([]byte, error) { + return services.MultiDocDel(data, sel, path, c.Bool("preserve")) + }) + } + if c.Bool("preserve") { return writePreserved(c, file, def, c.Bool("in-place"), func(data []byte, format services.DataFormat) ([]byte, error) { return services.DelPreserving(data, format, path) @@ -196,7 +278,7 @@ func dataMergeCmd(def services.DataFormat) cli.Command { return err } if out != "" { - return os.WriteFile(out, data, 0644) + return os.WriteFile(out, data, 0o644) } fmt.Print(string(data)) if format == services.FormatJSON { @@ -364,7 +446,7 @@ func writeResult(c *cli.Context, srcPath string, doc interface{}, def services.D return err } if inPlace { - return os.WriteFile(srcPath, data, 0644) + return os.WriteFile(srcPath, data, 0o644) } fmt.Print(string(data)) if format == services.FormatJSON { @@ -373,6 +455,32 @@ func writeResult(c *cli.Context, srcPath string, doc interface{}, def services.D return nil } +// docSelectorFlag returns the --doc flag for multi-document YAML streams. +func docSelectorFlag() cli.StringFlag { + return cli.StringFlag{ + Name: "doc, d", + Usage: "select a document in a multi-doc YAML stream: index (0) or field match (kind=Deployment,metadata.name=api)", + } +} + +// writeRawEdit reads a file's raw bytes, applies edit, and writes the result +// back in place (atomically) or to stdout. +func writeRawEdit(srcPath string, inPlace bool, edit func([]byte) ([]byte, error)) error { + data, err := os.ReadFile(srcPath) + if err != nil { + return err + } + out, err := edit(data) + if err != nil { + return err + } + if inPlace { + return atomicWriteFile(srcPath, out) + } + fmt.Print(string(out)) + return nil +} + // writePreserved reads the source file's raw bytes, applies a formatting- // preserving edit, and either writes back in place or prints to stdout. Unlike // writeResult it never round-trips through Decode/Encode, so the file is changed @@ -402,7 +510,7 @@ func writePreserved(c *cli.Context, srcPath string, def services.DataFormat, inP // kill mid-write leaves the original file fully intact rather than truncated. // The original file's permission bits are preserved. func atomicWriteFile(path string, data []byte) error { - mode := os.FileMode(0644) + mode := os.FileMode(0o644) if info, err := os.Stat(path); err == nil { mode = info.Mode().Perm() } diff --git a/actions/lock.go b/actions/lock.go new file mode 100644 index 0000000..d8d2566 --- /dev/null +++ b/actions/lock.go @@ -0,0 +1,80 @@ +package actions + +import ( + "time" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// LockCommand returns the file-based mutex command group. +func LockCommand() cli.Command { + lockFlags := []cli.Flag{ + cli.StringFlag{Name: "file, f", Usage: "lock file path (required)"}, + cli.DurationFlag{Name: "timeout, t", Value: 60 * time.Second, Usage: "how long to wait for the lock"}, + cli.DurationFlag{Name: "stale", Usage: "treat a lock older than this as abandoned and take it over (0 = never)"}, + } + return cli.Command{ + Name: "lock", + Usage: "serialize pipeline steps with a file-based mutex", + Subcommands: []cli.Command{ + { + Name: "acquire", + Usage: "take the lock, waiting up to --timeout", + Flags: lockFlags, + Action: func(c *cli.Context) error { + path, err := lockPath(c) + if err != nil { + return err + } + return services.AcquireLock(path, c.Duration("timeout"), c.Duration("stale")) + }, + }, + { + Name: "release", + Usage: "release the lock (safe to call when not held)", + Flags: lockFlags[:1], + Action: func(c *cli.Context) error { + path, err := lockPath(c) + if err != nil { + return err + } + return services.ReleaseLock(path) + }, + }, + { + Name: "run", + Usage: "acquire the lock, run a command, release afterwards", + ArgsUsage: "-- COMMAND [ARGS...]", + Flags: lockFlags, + Action: func(c *cli.Context) error { + path, err := lockPath(c) + if err != nil { + return err + } + args := c.Args() + if len(args) == 0 { + return cli.NewExitError("command required after --", 1) + } + code, err := services.RunWithLock(path, c.Duration("timeout"), c.Duration("stale"), args) + if err != nil { + return err + } + if code != 0 { + return cli.NewExitError("", code) + } + return nil + }, + }, + }, + } +} + +func lockPath(c *cli.Context) (string, error) { + path := c.String("file") + if path == "" { + return "", cli.NewExitError("--file required", 1) + } + return path, nil +} diff --git a/actions/render.go b/actions/render.go index fe323fe..cfccff6 100644 --- a/actions/render.go +++ b/actions/render.go @@ -20,6 +20,8 @@ func RenderCommand() cli.Command { 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.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"}, }, Action: func(c *cli.Context) error { tmplPath := c.String("template") @@ -30,6 +32,22 @@ func RenderCommand() cli.Command { return cli.NewExitError("template file required (positional or --template)", 1) } + if c.Bool("envsubst") { + data, err := os.ReadFile(tmplPath) + if err != nil { + return err + } + out, err := services.Envsubst(string(data), services.EnvsubstOptions{Strict: c.Bool("strict")}) + if err != nil { + return err + } + if outPath := c.String("output"); outPath != "" { + return os.WriteFile(outPath, []byte(out), 0o644) + } + fmt.Print(out) + return nil + } + values := make(map[string]interface{}) for _, vp := range c.StringSlice("values") { v, err := services.LoadValues(vp) @@ -47,7 +65,7 @@ func RenderCommand() cli.Command { return err } if outPath := c.String("output"); outPath != "" { - return os.WriteFile(outPath, []byte(out), 0644) + return os.WriteFile(outPath, []byte(out), 0o644) } fmt.Print(out) return nil diff --git a/actions/report.go b/actions/report.go new file mode 100644 index 0000000..9517d1b --- /dev/null +++ b/actions/report.go @@ -0,0 +1,123 @@ +package actions + +import ( + "fmt" + "io" + "strconv" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// ReportCommand returns the test/coverage report command group. +func ReportCommand() cli.Command { + return cli.Command{ + Name: "report", + Usage: "parse test and coverage reports into summaries and CI gates", + Subcommands: []cli.Command{ + { + Name: "junit", + Usage: "summarize a JUnit XML report (markdown, counts, failure gate)", + ArgsUsage: "[FILE]", + Flags: []cli.Flag{ + cli.StringFlag{Name: "output, o", Value: "markdown", Usage: "output format: markdown, json"}, + cli.IntFlag{Name: "slowest", Value: 5, Usage: "include the N slowest tests in markdown (0 to disable)"}, + cli.BoolFlag{Name: "fail-on-failures", Usage: "exit 1 when the report contains failures or errors"}, + cli.BoolFlag{Name: "to-summary", Usage: "append markdown to GITHUB_STEP_SUMMARY"}, + cli.StringFlag{Name: "to-github-output", Usage: "write total/passed/failures/errors/skipped to $GITHUB_OUTPUT with this key prefix"}, + }, + Action: func(c *cli.Context) error { + data, err := readReportInput(c) + if err != nil { + return err + } + report, err := services.ParseJUnit(data) + if err != nil { + return err + } + + var out string + if c.String("output") == "json" { + out, err = services.FormatJUnitJSON(report) + if err != nil { + return err + } + } else { + out = services.FormatJUnitMarkdown(report, c.Int("slowest")) + } + + if c.Bool("to-summary") { + md := services.FormatJUnitMarkdown(report, c.Int("slowest")) + if err := services.AppendToGitHubSummary(md); err != nil { + return err + } + } + if prefix := c.String("to-github-output"); prefix != "" { + counts := map[string]int{ + "total": report.Total, "passed": report.Passed, + "failures": report.Failures, "errors": report.Errors, + "skipped": report.Skipped, + } + for key, v := range counts { + if err := services.WriteToGitHubOutputValue(prefix+"_"+key, strconv.Itoa(v)); err != nil { + return err + } + } + } + fmt.Print(out) + + if c.Bool("fail-on-failures") && report.Failures+report.Errors > 0 { + return cli.NewExitError(fmt.Sprintf("%d test(s) failed", report.Failures+report.Errors), 1) + } + return nil + }, + }, + { + Name: "coverage", + Usage: "extract total coverage from lcov/cobertura and gate on a threshold", + ArgsUsage: "[FILE]", + Flags: []cli.Flag{ + cli.StringFlag{Name: "format, f", Value: "auto", Usage: "report format: auto, lcov, cobertura"}, + cli.Float64Flag{Name: "min", Value: -1, Usage: "minimum coverage percentage; exit 1 below it"}, + cli.BoolFlag{Name: "to-summary", Usage: "append markdown to GITHUB_STEP_SUMMARY"}, + outputKeyFlag(), + }, + Action: func(c *cli.Context) error { + data, err := readReportInput(c) + if err != nil { + return err + } + pct, err := services.ParseCoverage(data, c.String("format")) + if err != nil { + return err + } + min := c.Float64("min") + + if c.Bool("to-summary") { + if err := services.AppendToGitHubSummary(services.FormatCoverageMarkdown(pct, min)); err != nil { + return err + } + } + if err := emitString(c, fmt.Sprintf("%.1f", pct)); err != nil { + return err + } + + if min >= 0 && pct < min { + return cli.NewExitError(fmt.Sprintf("coverage %.1f%% below threshold %.1f%%", pct, min), 1) + } + return nil + }, + }, + }, + } +} + +func readReportInput(c *cli.Context) ([]byte, error) { + r, err := readerFromArgOrStdin(c) + if err != nil { + return nil, err + } + defer func() { _ = r.Close() }() + return io.ReadAll(r) +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 93ab9ad..e1bae8f 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -32,6 +32,9 @@ Full reference for every pipekit command and flag. For end-to-end pipeline recip - [`time`](#time) — timestamps, formatting, arithmetic - [`port` · `uuid` · `random`](#misc) — small generators - [`doctor`](#doctor) — environment diagnostics +- [`report`](#report) — JUnit / coverage parsing and gates +- [`annotate`](#annotate) — CI code annotations +- [`lock`](#lock) — file-based mutex for pipeline steps - [Exit codes](#exit-codes) --- @@ -321,6 +324,9 @@ pipekit assert path /etc/myapp/config.yaml /var/lib/myapp # Directory exists and contains entries pipekit assert dir-not-empty ./build/output + +# All files declare the same version (monorepo release guard) +pipekit assert versions-match package.json helm/Chart.yaml ``` Comparison operators: `gt`, `lt`, `eq`, `gte`, `lte` (and `>`, `<`, `==`, `>=`, `<=`). @@ -626,13 +632,17 @@ Validate and describe CI artifacts before upload or release. # Fail early if expected build outputs are missing pipekit artifact assert "dist/pipekit-linux-*" "dist/checksums.txt" +# Enforce a size budget (bundle bloat gate) +pipekit artifact assert "dist/bundle.js" --max-size 5MB +pipekit artifact assert "dist/pipekit-*" --min-size 1MB --max-size 50MB + # Generate a JSON manifest with path, size, and sha256 pipekit artifact manifest "dist/pipekit-*" --pretty --output dist/artifacts.json ``` | Subcommand | Description | |---|---| -| `artifact assert PATH_OR_GLOB...` | Fail unless each path/glob resolves to at least one file | +| `artifact assert PATH_OR_GLOB...` | Fail unless each path/glob resolves to at least one file; `--max-size` / `--min-size` add a size budget (`5MB`, `100KiB`, `512`) | | `artifact manifest PATH_OR_GLOB...` | Emit JSON artifact metadata | @@ -1055,6 +1065,60 @@ Ideal for hand-maintained files like Helm `values.yaml`. +
+--doc — address one document in a multi-doc YAML stream + +```sh +# Select by field match (k8s manifests: kind + name) +pipekit yaml get manifests.yaml --doc kind=Service --path '.spec.ports[0].port' +pipekit yaml set manifests.yaml --doc kind=Deployment,metadata.name=api \ + --path '.spec.replicas' --json-value 5 --preserve --in-place + +# Or by zero-based document index +pipekit yaml del manifests.yaml --doc 2 --path '.spec.legacy' --in-place +``` + +`get`, `set`, and `del` accept `--doc ` for `---`-separated streams. +The selector is either a document index or comma-separated `dotpath=value` +matches, which must resolve to exactly one document (ambiguity is an error, so +`kind=Deployment` alone fails if the stream has two Deployments). Edits touch +**only the selected document's bytes** — sibling documents and separators stay +byte-identical, and `--preserve` keeps comments/formatting inside the edited +document too. This is the yq pain point pipekit removes: no `select(di == …)` +gymnastics, no reformatted siblings. + +
+ +
+json diff / yaml diff — semantic diff (order and formatting ignored) + +```sh +# What actually changed between two rendered Helm value sets? +pipekit yaml diff old-values.yaml new-values.yaml +# ~ .image.tag: "v1.2.2" -> "v1.2.3" +# + .autoscaling.enabled: true +# - .legacyFlag: false + +# Gate: exit 1 when files differ (like git diff --exit-code) +pipekit json diff expected.json actual.json --exit-code + +# Markdown table into the step summary / a PR comment +pipekit yaml diff base.yaml overlay.yaml --output markdown --to-summary + +# Machine-readable +pipekit yaml diff a.yaml b.yaml --output json + +# Skip noisy paths (repeatable prefix match) +pipekit yaml diff live.yaml desired.yaml --ignore .metadata.generation --ignore .status +``` + +Compares the *decoded* documents, so key order, quoting, indentation, and +comments never produce a diff — only real value changes do. Cross-format works +too (`pipekit json diff config.json config.yaml`). Output formats: `text` +(default, `+`/`-`/`~` lines), `json`, `markdown`. + +
+
json merge — deep-merge files (helm-style overrides) @@ -1103,6 +1167,10 @@ pipekit render deployment.yaml.tpl \ # Inline template through stdin echo 'Hello {{ .Values.name | default "world" }}' \ | pipekit transform template + +# envsubst mode — drop-in replacement for the gettext binary +pipekit render config.tmpl --envsubst --output config.yaml # $VAR, ${VAR}, ${VAR:-default}, $$ escapes +pipekit render config.tmpl --envsubst --strict # fail listing unset vars without defaults ``` **Available functions:** `default`, `env`, `envOr`, `b64enc`, `b64dec`, `sha256sum`, `sha1sum`, `md5sum`, `regexReplace`, `regexMatch`, `replace`, `lower`, `upper`, `trim`, `trimPrefix`, `trimSuffix`, `contains`, `hasPrefix`, `hasSuffix`, `split`, `join`, `quote`, `squote`, `indent`, `nindent`, `ternary`, `toJson`, `toYaml`, `fromJson`, `fromYaml`, `now`, `date`, `list`, `dict`. @@ -1180,6 +1248,15 @@ pipekit http post https://uploads.example.com/artifacts \ --file artifact=dist/app.tar.zst \ --expect-status 200 +# Follow Link rel="next" pagination and merge every page (GitHub-style APIs) +pipekit http get "https://api.github.com/repos/org/repo/tags?per_page=100" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --paginate \ + --jq '.[].name' + +# Items nested inside an object? Point --items at the array +pipekit http get https://api.example.com/builds --paginate --items .data --max-pages 20 + # Chain dependent requests with captures and interpolation pipekit http chain flow.yaml --expect-status 200 --verbose @@ -1352,6 +1429,107 @@ Detects: GitHub Actions, GitLab CI, Buildkite, CircleCI, Jenkins, plus a generic --- +## report + +Parse test and coverage reports into markdown summaries and CI gates — no more python one-liners over JUnit XML. + +
+Examples + +```sh +# Markdown summary (counts, failed tests, slowest tests) + failure gate +pipekit report junit test-results.xml --to-summary --fail-on-failures + +# Counts to GITHUB_OUTPUT (tests_total, tests_passed, tests_failures, …) +pipekit report junit test-results.xml --to-github-output tests + +# JSON for further processing +pipekit report junit test-results.xml --output json | pipekit json get --path .failures + +# Coverage percentage from lcov or cobertura (format auto-detected) +pipekit report coverage coverage/lcov.info +# 84.5 + +# Threshold gate + step summary line +pipekit report coverage coverage.xml --min 80 --to-summary +``` + +| Subcommand | Description | +|---|---| +| `report junit [FILE]` | Parse JUnit XML (nested suites supported); `--slowest N`, `--fail-on-failures`, `--to-summary`, `--to-github-output PREFIX`, `--output markdown\|json` | +| `report coverage [FILE]` | Total line coverage from `lcov` or `cobertura` (auto-detect); `--min PCT` exits 1 below threshold; `--to-summary`, `--to-github-output` | + +
+ +--- + +## annotate + +Emit code annotations that GitHub renders inline on the PR (workflow commands); on other platforms they fall back to grep-able `file:line: level: message` lines. Platform is auto-detected (`--platform` to force). + +
+Examples + +```sh +# One-off annotation +pipekit annotate error --file src/app.go --line 42 --title "nil deref" "response can be nil here" +# ::error file=src/app.go,line=42,title=nil deref::response can be nil here + +pipekit annotate warning "deploy skipped: no changes detected" + +# Convert any linter's JSON findings into inline annotations +eslint . --format json | jq '[.[] | .messages[] as $m | {file: .filePath, line: $m.line, message: $m.message, severity: (if $m.severity == 2 then "error" else "warning" end)}]' \ + | pipekit annotate from-json --fail-on-errors + +# Field names configurable — no jq needed when the shape is flat +shellcheck -f json1 script.sh | pipekit json get --path .comments \ + | pipekit annotate from-json --line-key line --severity-key level --fail-on-errors +``` + +Severities normalize onto GitHub's three levels: `error`/`fatal`/`critical` → error, `warning`/`minor` → warning, everything else → notice. + +| Subcommand | Description | +|---|---| +| `annotate error\|warning\|notice MESSAGE` | Single annotation; `--file`, `--line`, `--end-line`, `--col`, `--title` | +| `annotate from-json [FILE]` | JSON findings array → annotations; `--file-key`, `--line-key`, `--message-key`, `--severity-key`, `--title-key`, `--fail-on-errors` | + +
+ +--- + +## lock + +Serialize pipeline steps across concurrent jobs sharing a runner or workspace — a portable `flock` replacement with timeout and stale-lock takeover. + +
+Examples + +```sh +# Acquire → work → release +pipekit lock acquire --file /tmp/deploy.lock --timeout 5m +helm upgrade --install myapp ./chart +pipekit lock release --file /tmp/deploy.lock + +# Or wrap the command (lock is released even if it fails) +pipekit lock run --file /tmp/deploy.lock --timeout 5m --stale 30m -- \ + terraform apply -auto-approve +``` + +The lock file records holder pid + timestamp for debugging. `--stale 30m` +treats a lock older than 30 minutes as abandoned (crashed job) and takes it +over; without `--stale` a leftover lock is never stolen. `lock release` on a +missing file succeeds, so it's safe in `always()` cleanup steps. + +| Subcommand | Description | +|---|---| +| `lock acquire --file PATH` | Take the lock, waiting up to `--timeout` (default 60s) | +| `lock release --file PATH` | Remove the lock (idempotent) | +| `lock run --file PATH -- CMD…` | Acquire, run, always release; propagates the command's exit code | + +
+ +--- + ## Exit codes | Code | Meaning | diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 18cd232..0063862 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -16,6 +16,8 @@ For per-command flag reference see **[COMMANDS.md](COMMANDS.md)**. - [Step summaries that look good](#step-summaries) - [GitLab CI](#gitlab-ci) - [Jenkins](#jenkins) +- [v0.2 patterns: json, render, exec](#v02-patterns-json-render-exec) +- [v0.3 patterns: reports, annotations, diffs, locks](#v03-patterns-reports-annotations-diffs-locks) - [Combining commands](#combining-commands) --- @@ -575,6 +577,125 @@ jobs: --- +## v0.3 patterns: reports, annotations, diffs, locks + +
+Test summary + coverage gate without python + +```yaml +- name: Test + run: go test ./... 2>&1 | go-junit-report > junit.xml + +- name: Report + if: always() + run: | + pipekit report junit junit.xml --to-summary --fail-on-failures + pipekit report coverage coverage/lcov.info --min 80 --to-summary +``` + +Replaces: a python script parsing JUnit XML, a `bc`-based coverage +percentage calculation, and hand-rolled `$GITHUB_STEP_SUMMARY` heredocs. + +
+ +
+What changed in the rendered manifests? (semantic diff → PR comment) + +```sh +helm template myapp ./chart -f values-prod.yaml > /tmp/new.yaml +git show origin/main:rendered/prod.yaml > /tmp/old.yaml + +pipekit yaml diff /tmp/old.yaml /tmp/new.yaml \ + --ignore .metadata.labels.chart \ + --output markdown \ +| gh pr comment "$PR" --body-file - +``` + +Key order, indentation, and comments never show up as changes — only real +value drift does. + +
+ +
+Bump one document inside a k8s manifest stream (yq can't do this cleanly) + +```sh +pipekit yaml set manifests.yaml \ + --doc kind=Deployment,metadata.name=api \ + --path '.spec.template.spec.containers[0].image' \ + --value "ghcr.io/org/api:$TAG" \ + --preserve --in-place +``` + +Only the selected Deployment changes; the Service and every comment in the +file stay byte-identical. + +
+ +
+Linter findings as inline PR annotations + +```sh +mylinter --json \ + | pipekit annotate from-json \ + --file-key path --line-key lineNumber --message-key text --severity-key level \ + --fail-on-errors +``` + +Works for any tool that emits a JSON findings array — map field names with +the `--*-key` flags instead of reshaping with jq. + +
+ +
+Serialize deploys on a shared runner + +```sh +pipekit lock run --file /tmp/prod-deploy.lock --timeout 10m --stale 30m -- \ + helm upgrade --install myapp ./chart +``` + +
+ +
+Fetch every page of a paginated API + +```sh +# All tags, not just the first 30 +pipekit http get "https://api.github.com/repos/org/repo/tags?per_page=100" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --paginate --jq '.[0].name' +``` + +
+ +
+Migrate envsubst templates without rewriting them + +```sh +IMAGE=app:v2 pipekit render k8s/deploy.tmpl.yaml --envsubst --strict --output deploy.yaml +``` + +`--strict` fails listing every unset variable instead of silently rendering +empty strings. + +
+ +
+Monorepo release guards + +```sh +# package.json, Chart.yaml and pyproject.toml must agree before tagging +pipekit assert versions-match package.json helm/Chart.yaml pyproject.toml + +# Keep the CLI binary under budget +pipekit artifact assert "dist/mycli-*" --max-size 50MB +``` + +
+ +--- + ## Combining commands The real value comes from chaining a few commands together. A few patterns: diff --git a/integration/timesavers_test.go b/integration/timesavers_test.go new file mode 100644 index 0000000..a9cb978 --- /dev/null +++ b/integration/timesavers_test.go @@ -0,0 +1,152 @@ +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTempFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writing %s: %v", name, err) + } + return path +} + +func TestE2E_YAMLDiff(t *testing.T) { + a := writeTempFile(t, "a.yaml", "image: app:v1\nreplicas: 2\n") + b := writeTempFile(t, "b.yaml", "replicas: 3\nimage: app:v1\n") + + stdout, _, code := runPipekit(t, []string{"yaml", "diff", a, b, "--exit-code"}, "") + if code != 1 { + t.Errorf("exit = %d, want 1 with --exit-code and differences", code) + } + if !strings.Contains(stdout, "~ .replicas: 2 -> 3") { + t.Errorf("stdout = %q", stdout) + } + + _, _, code = runPipekit(t, []string{"yaml", "diff", a, a, "--exit-code"}, "") + if code != 0 { + t.Errorf("identical files exit = %d, want 0", code) + } +} + +func TestE2E_YAMLMultiDocSetPreserve(t *testing.T) { + manifest := "# lead comment\nkind: Deployment\nmetadata:\n name: api\nspec:\n replicas: 2 # sync with HPA\n---\nkind: Service\nmetadata:\n name: api\n" + path := writeTempFile(t, "m.yaml", manifest) + + _, stderr, code := runPipekit(t, []string{ + "yaml", "set", path, "--doc", "kind=Deployment", "--path", ".spec.replicas", + "--json-value", "5", "--preserve", "--in-place", + }, "") + if code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr) + } + data, _ := os.ReadFile(path) + got := string(data) + if !strings.Contains(got, "replicas: 5 # sync with HPA") { + t.Errorf("edit lost comment or value:\n%s", got) + } + if !strings.Contains(got, "# lead comment") || !strings.Contains(got, "kind: Service\nmetadata:\n name: api\n") { + t.Errorf("sibling content changed:\n%s", got) + } +} + +func TestE2E_ReportJUnit(t *testing.T) { + junit := `` + path := writeTempFile(t, "junit.xml", junit) + + stdout, _, code := runPipekit(t, []string{"report", "junit", path, "--fail-on-failures"}, "") + if code != 1 { + t.Errorf("exit = %d, want 1 with failures", code) + } + if !strings.Contains(stdout, "2 tests") || !strings.Contains(stdout, "`bad`") { + t.Errorf("stdout = %q", stdout) + } +} + +func TestE2E_ReportCoverage(t *testing.T) { + lcov := "SF:a.go\nLF:10\nLH:9\nend_of_record\n" + path := writeTempFile(t, "cov.info", lcov) + + stdout, _, code := runPipekit(t, []string{"report", "coverage", path, "--min", "80"}, "") + if code != 0 || strings.TrimSpace(stdout) != "90.0" { + t.Errorf("exit = %d stdout = %q, want 0 / 90.0", code, stdout) + } + + _, _, code = runPipekit(t, []string{"report", "coverage", path, "--min", "95"}, "") + if code != 1 { + t.Errorf("exit = %d, want 1 below threshold", code) + } +} + +func TestE2E_AnnotateFromJSON(t *testing.T) { + findings := `[{"file": "a.go", "line": 3, "severity": "error", "message": "bad thing"}]` + stdout, _, code := runPipekit(t, + []string{"annotate", "from-json", "--platform", "github-actions"}, findings) + if code != 0 { + t.Fatalf("exit = %d", code) + } + if !strings.Contains(stdout, "::error file=a.go,line=3::bad thing") { + t.Errorf("stdout = %q", stdout) + } +} + +func TestE2E_RenderEnvsubst(t *testing.T) { + tmpl := writeTempFile(t, "config.tmpl", "image: ${IMAGE}\nregion: ${REGION:-eu-west-1}\n") + stdout, _, code := runPipekit(t, []string{"render", "--envsubst", tmpl}, "", "IMAGE=app:v9") + if code != 0 { + t.Fatalf("exit = %d", code) + } + if !strings.Contains(stdout, "image: app:v9") || !strings.Contains(stdout, "region: eu-west-1") { + t.Errorf("stdout = %q", stdout) + } + + _, stderr, code := runPipekit(t, []string{"render", "--envsubst", "--strict", tmpl}, "") + if code == 0 || !strings.Contains(stderr, "IMAGE") { + t.Errorf("strict mode should fail mentioning IMAGE: exit=%d stderr=%q", code, stderr) + } +} + +func TestE2E_LockRun(t *testing.T) { + lock := filepath.Join(t.TempDir(), "ci.lock") + _, _, code := runPipekit(t, []string{"lock", "run", "--file", lock, "--", "true"}, "") + if code != 0 { + t.Errorf("lock run true exit = %d", code) + } + if _, err := os.Stat(lock); !os.IsNotExist(err) { + t.Error("lock file should be released") + } +} + +func TestE2E_AssertVersionsMatch(t *testing.T) { + pkg := writeTempFile(t, "package.json", `{"version": "2.0.0"}`) + chart := writeTempFile(t, "Chart.yaml", "version: 2.0.0\n") + + stdout, _, code := runPipekit(t, []string{"assert", "versions-match", pkg, chart}, "") + if code != 0 || strings.TrimSpace(stdout) != "2.0.0" { + t.Errorf("exit = %d stdout = %q", code, stdout) + } + + mismatched := writeTempFile(t, "VERSION", "3.0.0\n") + _, _, code = runPipekit(t, []string{"assert", "versions-match", pkg, mismatched}, "") + if code == 0 { + t.Error("mismatched versions should fail") + } +} + +func TestE2E_ArtifactSizeBudget(t *testing.T) { + bin := writeTempFile(t, "app.bin", strings.Repeat("x", 2048)) + + _, _, code := runPipekit(t, []string{"artifact", "assert", bin, "--max-size", "4KB"}, "") + if code != 0 { + t.Errorf("within budget exit = %d", code) + } + _, stderr, code := runPipekit(t, []string{"artifact", "assert", bin, "--max-size", "1KB"}, "") + if code == 0 || !strings.Contains(stderr, "size budget") { + t.Errorf("over budget: exit=%d stderr=%q", code, stderr) + } +} diff --git a/main.go b/main.go index 9748cb4..8a6b6c6 100644 --- a/main.go +++ b/main.go @@ -58,6 +58,9 @@ func main() { actions.UUIDCommand(), actions.RandomCommand(), actions.DoctorCommand(), + actions.ReportCommand(), + actions.AnnotateCommand(), + actions.LockCommand(), { Name: "build-info", Usage: "show build version information", diff --git a/services/annotate_service.go b/services/annotate_service.go new file mode 100644 index 0000000..471cefc --- /dev/null +++ b/services/annotate_service.go @@ -0,0 +1,176 @@ +package services + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// Annotation is a single code annotation (linter finding, test failure, …). +type Annotation struct { + Level string // error, warning, notice + File string + Line int + EndLine int + Col int + Title string + Message string +} + +// AnnotationKeys maps generic finding-JSON field names onto Annotation +// fields, so any linter's JSON output can be converted without jq gymnastics. +type AnnotationKeys struct { + File string + Line string + Col string + Message string + Severity string + Title string +} + +// DefaultAnnotationKeys returns the field names most linters use. +func DefaultAnnotationKeys() AnnotationKeys { + return AnnotationKeys{ + File: "file", + Line: "line", + Col: "col", + Message: "message", + Severity: "severity", + Title: "title", + } +} + +// NormalizeAnnotationLevel maps common severity spellings onto the three +// levels GitHub understands. +func NormalizeAnnotationLevel(level string) string { + switch strings.ToLower(level) { + case "error", "err", "fatal", "critical", "blocker", "major": + return "error" + case "warning", "warn", "minor": + return "warning" + default: + return "notice" + } +} + +// FormatAnnotation renders an annotation for the given CI platform. +// On github-actions it emits a workflow command that GitHub turns into an +// inline PR annotation; everywhere else it falls back to a grep-able +// file:line: level: message line. +func FormatAnnotation(a Annotation, platform string) string { + level := NormalizeAnnotationLevel(a.Level) + if platform == "github-actions" { + var props []string + if a.File != "" { + props = append(props, "file="+escapeGitHubProperty(a.File)) + } + if a.Line > 0 { + props = append(props, fmt.Sprintf("line=%d", a.Line)) + } + if a.EndLine > 0 { + props = append(props, fmt.Sprintf("endLine=%d", a.EndLine)) + } + if a.Col > 0 { + props = append(props, fmt.Sprintf("col=%d", a.Col)) + } + if a.Title != "" { + props = append(props, "title="+escapeGitHubProperty(a.Title)) + } + propStr := "" + if len(props) > 0 { + propStr = " " + strings.Join(props, ",") + } + return fmt.Sprintf("::%s%s::%s", level, propStr, escapeGitHubData(a.Message)) + } + + loc := a.File + if a.Line > 0 { + loc = fmt.Sprintf("%s:%d", loc, a.Line) + if a.Col > 0 { + loc = fmt.Sprintf("%s:%d", loc, a.Col) + } + } + msg := a.Message + if a.Title != "" { + msg = a.Title + ": " + msg + } + if loc != "" { + return fmt.Sprintf("%s: %s: %s", loc, level, msg) + } + return fmt.Sprintf("%s: %s", level, msg) +} + +// escapeGitHubData escapes a workflow-command message payload. +func escapeGitHubData(s string) string { + s = strings.ReplaceAll(s, "%", "%25") + s = strings.ReplaceAll(s, "\r", "%0D") + s = strings.ReplaceAll(s, "\n", "%0A") + return s +} + +// escapeGitHubProperty escapes a workflow-command property value. +func escapeGitHubProperty(s string) string { + s = escapeGitHubData(s) + s = strings.ReplaceAll(s, ":", "%3A") + s = strings.ReplaceAll(s, ",", "%2C") + return s +} + +// ParseAnnotationsJSON converts a JSON array of finding objects into +// annotations using the given field-name mapping. Findings missing the +// message field are skipped. +func ParseAnnotationsJSON(data []byte, keys AnnotationKeys) ([]Annotation, error) { + var raw []map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing findings JSON (want an array of objects): %w", err) + } + annotations := make([]Annotation, 0, len(raw)) + for _, item := range raw { + msg := stringField(item, keys.Message) + if msg == "" { + continue + } + annotations = append(annotations, Annotation{ + Level: NormalizeAnnotationLevel(stringField(item, keys.Severity)), + File: stringField(item, keys.File), + Line: intField(item, keys.Line), + Col: intField(item, keys.Col), + Title: stringField(item, keys.Title), + Message: msg, + }) + } + return annotations, nil +} + +func stringField(m map[string]interface{}, key string) string { + if key == "" { + return "" + } + switch v := m[key].(type) { + case string: + return v + case float64: + return strings.TrimSuffix(fmt.Sprintf("%v", v), ".0") + default: + return "" + } +} + +func intField(m map[string]interface{}, key string) int { + if key == "" { + return 0 + } + switch v := m[key].(type) { + case float64: + return int(v) + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0 + } + return n + default: + return 0 + } +} diff --git a/services/annotate_service_test.go b/services/annotate_service_test.go new file mode 100644 index 0000000..dc2be8e --- /dev/null +++ b/services/annotate_service_test.go @@ -0,0 +1,88 @@ +package services + +import ( + "testing" +) + +func TestFormatAnnotation_GitHub(t *testing.T) { + a := Annotation{ + Level: "error", File: "src/app.go", Line: 10, Col: 5, EndLine: 12, + Title: "lint: unused", Message: "x declared\nbut not used", + } + got := FormatAnnotation(a, "github-actions") + want := "::error file=src/app.go,line=10,endLine=12,col=5,title=lint%3A unused::x declared%0Abut not used" + if got != want { + t.Errorf("FormatAnnotation() =\n%s\nwant\n%s", got, want) + } +} + +func TestFormatAnnotation_GitHubNoProps(t *testing.T) { + got := FormatAnnotation(Annotation{Level: "warning", Message: "heads up"}, "github-actions") + if got != "::warning::heads up" { + t.Errorf("FormatAnnotation() = %q", got) + } +} + +func TestFormatAnnotation_Plain(t *testing.T) { + a := Annotation{Level: "warning", File: "main.go", Line: 3, Col: 7, Message: "shadowed var"} + got := FormatAnnotation(a, "none") + if got != "main.go:3:7: warning: shadowed var" { + t.Errorf("FormatAnnotation() = %q", got) + } + + got = FormatAnnotation(Annotation{Level: "fatal", Message: "boom"}, "gitlab-ci") + if got != "error: boom" { + t.Errorf("FormatAnnotation() no-file = %q", got) + } +} + +func TestNormalizeAnnotationLevel(t *testing.T) { + cases := map[string]string{ + "ERROR": "error", "critical": "error", "warn": "warning", + "info": "notice", "": "notice", "style": "notice", + } + for in, want := range cases { + if got := NormalizeAnnotationLevel(in); got != want { + t.Errorf("NormalizeAnnotationLevel(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseAnnotationsJSON(t *testing.T) { + data := `[ + {"file": "a.go", "line": 3, "col": 1, "severity": "error", "message": "bad"}, + {"file": "b.go", "line": 9, "severity": "info", "message": "meh", "title": "note"}, + {"file": "c.go", "line": 1, "severity": "error"} + ]` + anns, err := ParseAnnotationsJSON([]byte(data), DefaultAnnotationKeys()) + if err != nil { + t.Fatalf("ParseAnnotationsJSON() error = %v", err) + } + if len(anns) != 2 { + t.Fatalf("got %d annotations, want 2 (message-less finding skipped): %v", len(anns), anns) + } + if anns[0].Level != "error" || anns[0].File != "a.go" || anns[0].Line != 3 { + t.Errorf("anns[0] = %+v", anns[0]) + } + if anns[1].Level != "notice" || anns[1].Title != "note" { + t.Errorf("anns[1] = %+v", anns[1]) + } +} + +func TestParseAnnotationsJSON_CustomKeys(t *testing.T) { + data := `[{"path": "x.py", "lineNumber": "42", "text": "E501 line too long", "level": "warning"}]` + keys := AnnotationKeys{File: "path", Line: "lineNumber", Message: "text", Severity: "level"} + anns, err := ParseAnnotationsJSON([]byte(data), keys) + if err != nil { + t.Fatalf("ParseAnnotationsJSON() error = %v", err) + } + if len(anns) != 1 || anns[0].File != "x.py" || anns[0].Line != 42 || anns[0].Level != "warning" { + t.Errorf("anns = %+v", anns) + } +} + +func TestParseAnnotationsJSON_NotArray(t *testing.T) { + if _, err := ParseAnnotationsJSON([]byte(`{"file": "a"}`), DefaultAnnotationKeys()); err == nil { + t.Error("expected error for non-array input") + } +} diff --git a/services/artifact_service.go b/services/artifact_service.go index 7bf236c..a50a7f9 100644 --- a/services/artifact_service.go +++ b/services/artifact_service.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "sort" + "strconv" + "strings" "github.com/bmatcuk/doublestar/v4" ) @@ -100,3 +102,69 @@ func FormatArtifactManifestJSON(entries []ArtifactEntry, pretty bool) (string, e } return string(data), nil } + +// ParseSize converts a human-readable size ("5MB", "100KiB", "1.5G", "512") +// into bytes. Decimal (KB/MB/GB) and binary (KiB/MiB/GiB) units are accepted; +// bare numbers are bytes. +func ParseSize(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty size") + } + i := 0 + for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { + i++ + } + num, err := strconv.ParseFloat(s[:i], 64) + if err != nil { + return 0, fmt.Errorf("parsing size %q: %w", s, err) + } + unit := strings.TrimSpace(strings.ToUpper(s[i:])) + multipliers := map[string]float64{ + "": 1, "B": 1, + "K": 1e3, "KB": 1e3, "KIB": 1 << 10, + "M": 1e6, "MB": 1e6, "MIB": 1 << 20, + "G": 1e9, "GB": 1e9, "GIB": 1 << 30, + } + mult, ok := multipliers[unit] + if !ok { + return 0, fmt.Errorf("unknown size unit %q in %q", unit, s) + } + return int64(num * mult), nil +} + +// FormatSize renders bytes with the most convenient decimal unit. +func FormatSize(n int64) string { + switch { + case n >= 1e9: + return fmt.Sprintf("%.1fGB", float64(n)/1e9) + case n >= 1e6: + return fmt.Sprintf("%.1fMB", float64(n)/1e6) + case n >= 1e3: + return fmt.Sprintf("%.1fKB", float64(n)/1e3) + default: + return fmt.Sprintf("%dB", n) + } +} + +// AssertArtifactSizes fails when any matched file is outside the given size +// budget. min/max <= 0 disables that bound. +func AssertArtifactSizes(patterns []string, min, max int64) error { + entries, err := ArtifactManifest(patterns) + if err != nil { + return err + } + var violations []string + for _, e := range entries { + if max > 0 && e.Size > max { + violations = append(violations, fmt.Sprintf("%s is %s (max %s)", e.Path, FormatSize(e.Size), FormatSize(max))) + } + if min > 0 && e.Size < min { + violations = append(violations, fmt.Sprintf("%s is %s (min %s)", e.Path, FormatSize(e.Size), FormatSize(min))) + } + } + if len(violations) > 0 { + return fmt.Errorf("size budget exceeded:\n %s", strings.Join(violations, "\n ")) + } + return nil +} diff --git a/services/artifact_service_test.go b/services/artifact_service_test.go index c9fc766..8c7c607 100644 --- a/services/artifact_service_test.go +++ b/services/artifact_service_test.go @@ -9,15 +9,15 @@ import ( func TestArtifactManifest(t *testing.T) { dir := t.TempDir() dist := filepath.Join(dir, "dist") - if err := os.MkdirAll(dist, 0755); err != nil { + if err := os.MkdirAll(dist, 0o755); err != nil { t.Fatal(err) } a := filepath.Join(dist, "app-linux-amd64") b := filepath.Join(dist, "app-darwin-arm64") - if err := os.WriteFile(a, []byte("linux"), 0644); err != nil { + if err := os.WriteFile(a, []byte("linux"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(b, []byte("darwin"), 0644); err != nil { + if err := os.WriteFile(b, []byte("darwin"), 0o644); err != nil { t.Fatal(err) } @@ -41,3 +41,50 @@ func TestAssertArtifactsNoMatch(t *testing.T) { t.Fatal("expected no-match error") } } + +func TestParseSize(t *testing.T) { + tests := []struct { + in string + want int64 + }{ + {"512", 512}, + {"5MB", 5_000_000}, + {"5M", 5_000_000}, + {"100KB", 100_000}, + {"1KiB", 1024}, + {"2MiB", 2 << 20}, + {"1.5GB", 1_500_000_000}, + {"1 mb", 1_000_000}, + } + for _, tt := range tests { + got, err := ParseSize(tt.in) + if err != nil { + t.Errorf("ParseSize(%q) error = %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want) + } + } + for _, bad := range []string{"", "abc", "5XB"} { + if _, err := ParseSize(bad); err == nil { + t.Errorf("ParseSize(%q) expected error", bad) + } + } +} + +func TestAssertArtifactSizes(t *testing.T) { + dir := t.TempDir() + big := filepath.Join(dir, "big.bin") + os.WriteFile(big, make([]byte, 2048), 0o644) + + if err := AssertArtifactSizes([]string{big}, 0, 4096); err != nil { + t.Errorf("within budget should pass: %v", err) + } + if err := AssertArtifactSizes([]string{big}, 0, 1024); err == nil { + t.Error("over max-size should fail") + } + if err := AssertArtifactSizes([]string{big}, 4096, 0); err == nil { + t.Error("under min-size should fail") + } +} diff --git a/services/http_service.go b/services/http_service.go index b2b6d24..2c27b31 100644 --- a/services/http_service.go +++ b/services/http_service.go @@ -353,3 +353,81 @@ func BuildMultipartBody(fileFields []string) ([]byte, string, error) { } return b.Bytes(), w.FormDataContentType(), nil } + +// ParseLinkNext extracts the rel="next" target from an RFC 5988 Link header, +// resolved against the page that served it. Returns "" when there is no next +// page. +func ParseLinkNext(header, baseURL string) string { + for _, part := range strings.Split(header, ",") { + segments := strings.Split(part, ";") + if len(segments) < 2 { + continue + } + target := strings.TrimSpace(segments[0]) + if !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") { + continue + } + isNext := false + for _, param := range segments[1:] { + param = strings.TrimSpace(param) + if v, ok := strings.CutPrefix(param, "rel="); ok { + if strings.Trim(v, `"`) == "next" { + isNext = true + } + } + } + if !isNext { + continue + } + next := strings.TrimSuffix(strings.TrimPrefix(target, "<"), ">") + base, err := url.Parse(baseURL) + if err != nil { + return next + } + ref, err := url.Parse(next) + if err != nil { + return next + } + return base.ResolveReference(ref).String() + } + return "" +} + +// ExecuteHTTPPaginated GETs a URL and follows Link rel="next" headers, +// merging the items from every page. itemsPath selects the array inside each +// response body ("" means the body itself is the array). Returns the merged +// items and the number of pages fetched. +func ExecuteHTTPPaginated(opts HTTPRequestOptions, itemsPath string, maxPages int) ([]interface{}, int, error) { + if maxPages <= 0 { + maxPages = 50 + } + var items []interface{} + pages := 0 + for opts.URL != "" && pages < maxPages { + res, err := ExecuteHTTPRequest(opts) + if err != nil { + return nil, pages, err + } + pages++ + + var pageItems interface{} + if itemsPath != "" { + pageItems, err = ExtractHTTPJSON(res.Body, itemsPath) + if err != nil { + return nil, pages, fmt.Errorf("page %d: %w", pages, err) + } + } else { + if err := json.Unmarshal(res.Body, &pageItems); err != nil { + return nil, pages, fmt.Errorf("page %d: parsing body: %w", pages, err) + } + } + arr, ok := pageItems.([]interface{}) + if !ok { + return nil, pages, fmt.Errorf("page %d: response is not a JSON array (use --items to point at the array field)", pages) + } + items = append(items, arr...) + + opts.URL = ParseLinkNext(res.Headers.Get("Link"), opts.URL) + } + return items, pages, nil +} diff --git a/services/http_service_test.go b/services/http_service_test.go index 2154303..09b279c 100644 --- a/services/http_service_test.go +++ b/services/http_service_test.go @@ -2,6 +2,7 @@ package services import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -137,3 +138,69 @@ func TestParseHTTPHeadersRejectsInvalid(t *testing.T) { t.Fatalf("expected invalid header error, got %v", err) } } + +func TestParseLinkNext(t *testing.T) { + header := `; rel="next", ; rel="last"` + got := ParseLinkNext(header, "https://api.example.com/items?page=2") + if got != "https://api.example.com/items?page=3" { + t.Errorf("ParseLinkNext() = %q", got) + } + + if got := ParseLinkNext(`; rel="next"`, "https://api.example.com/items"); got != "https://api.example.com/items?page=2" { + t.Errorf("ParseLinkNext() relative = %q", got) + } + + if got := ParseLinkNext(`; rel="prev"`, "https://api.example.com"); got != "" { + t.Errorf("ParseLinkNext() without next = %q, want empty", got) + } + if got := ParseLinkNext("", "https://api.example.com"); got != "" { + t.Errorf("ParseLinkNext(empty) = %q, want empty", got) + } +} + +func TestExecuteHTTPPaginated(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + switch page { + case "", "1": + w.Header().Set("Link", "<"+srv.URL+"/items?page=2>; rel=\"next\"") + fmt.Fprint(w, `[{"id": 1}, {"id": 2}]`) + case "2": + w.Header().Set("Link", "<"+srv.URL+"/items?page=3>; rel=\"next\"") + fmt.Fprint(w, `[{"id": 3}]`) + default: + 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 != 3 || len(items) != 3 { + t.Errorf("pages = %d items = %d, want 3 pages / 3 items", pages, len(items)) + } +} + +func TestExecuteHTTPPaginated_ItemsPathAndMaxPages(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Link", "<"+srv.URL+">; rel=\"next\"") // endless pagination + fmt.Fprint(w, `{"data": [{"id": 1}], "total": 100}`) + })) + defer srv.Close() + + items, pages, err := ExecuteHTTPPaginated(HTTPRequestOptions{URL: srv.URL}, ".data", 4) + if err != nil { + t.Fatalf("ExecuteHTTPPaginated() error = %v", err) + } + if pages != 4 || len(items) != 4 { + t.Errorf("pages = %d items = %d, want capped at 4", pages, len(items)) + } + + if _, _, err := ExecuteHTTPPaginated(HTTPRequestOptions{URL: srv.URL}, ".total", 1); err == nil { + t.Error("expected error when items path is not an array") + } +} diff --git a/services/lock_service.go b/services/lock_service.go new file mode 100644 index 0000000..9cba7a6 --- /dev/null +++ b/services/lock_service.go @@ -0,0 +1,106 @@ +package services + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "time" +) + +// lockInfo is what gets written into the lock file for debuggability. +type lockInfo struct { + PID int `json:"pid"` + Acquired string `json:"acquired"` + Host string `json:"host,omitempty"` +} + +// AcquireLock takes a file-based mutex. It retries until timeout elapses. +// When stale > 0, an existing lock file older than stale is treated as +// abandoned and taken over; stale == 0 never steals a lock. +func AcquireLock(path string, timeout, stale time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if err := tryCreateLock(path); err == nil { + return nil + } + + if stale > 0 { + if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) > stale { + // Best-effort takeover: remove and race for it on the next attempt. + _ = os.Remove(path) + continue + } + } + + if time.Now().After(deadline) { + holder := describeLockHolder(path) + return fmt.Errorf("could not acquire lock %s within %s%s", path, timeout, holder) + } + time.Sleep(200 * time.Millisecond) + } +} + +func tryCreateLock(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + host, _ := os.Hostname() + data, _ := json.Marshal(lockInfo{ + PID: os.Getpid(), + Acquired: time.Now().UTC().Format(time.RFC3339), + Host: host, + }) + _, err = f.Write(append(data, '\n')) + return err +} + +func describeLockHolder(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var info lockInfo + if json.Unmarshal(data, &info) != nil { + return "" + } + return fmt.Sprintf(" (held by pid %d since %s)", info.PID, info.Acquired) +} + +// ReleaseLock removes the lock file. A missing file is not an error, so +// release is safe to call from an always-run cleanup step. +func ReleaseLock(path string) error { + err := os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err +} + +// RunWithLock acquires the lock, runs the command with inherited stdio, and +// releases the lock afterwards (also on command failure). Returns the +// command's exit code. +func RunWithLock(path string, timeout, stale time.Duration, args []string) (int, error) { + if len(args) == 0 { + return 1, fmt.Errorf("no command given") + } + if err := AcquireLock(path, timeout, stale); err != nil { + return 1, err + } + defer func() { _ = ReleaseLock(path) }() + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil { + return 0, nil + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode(), nil + } + return 1, err +} diff --git a/services/lock_service_test.go b/services/lock_service_test.go new file mode 100644 index 0000000..6cd33eb --- /dev/null +++ b/services/lock_service_test.go @@ -0,0 +1,82 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAcquireRelease(t *testing.T) { + lock := filepath.Join(t.TempDir(), "deploy.lock") + if err := AcquireLock(lock, time.Second, 0); err != nil { + t.Fatalf("AcquireLock() error = %v", err) + } + data, err := os.ReadFile(lock) + if err != nil { + t.Fatalf("lock file missing: %v", err) + } + if len(data) == 0 { + t.Error("lock file should record holder info") + } + if err := ReleaseLock(lock); err != nil { + t.Fatalf("ReleaseLock() error = %v", err) + } + if _, err := os.Stat(lock); !os.IsNotExist(err) { + t.Error("lock file should be gone after release") + } +} + +func TestAcquire_Contention(t *testing.T) { + lock := filepath.Join(t.TempDir(), "deploy.lock") + if err := AcquireLock(lock, time.Second, 0); err != nil { + t.Fatalf("first AcquireLock() error = %v", err) + } + start := time.Now() + err := AcquireLock(lock, 300*time.Millisecond, 0) + if err == nil { + t.Fatal("second AcquireLock() should fail while lock is held") + } + if time.Since(start) < 300*time.Millisecond { + t.Errorf("AcquireLock() gave up before the timeout: %v", time.Since(start)) + } +} + +func TestAcquire_StaleTakeover(t *testing.T) { + lock := filepath.Join(t.TempDir(), "deploy.lock") + if err := AcquireLock(lock, time.Second, 0); err != nil { + t.Fatalf("AcquireLock() error = %v", err) + } + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(lock, old, old); err != nil { + t.Fatalf("Chtimes() error = %v", err) + } + if err := AcquireLock(lock, time.Second, 10*time.Minute); err != nil { + t.Errorf("AcquireLock() should steal a stale lock: %v", err) + } +} + +func TestReleaseLock_Missing(t *testing.T) { + if err := ReleaseLock(filepath.Join(t.TempDir(), "nope.lock")); err != nil { + t.Errorf("ReleaseLock() on missing file = %v, want nil", err) + } +} + +func TestRunWithLock(t *testing.T) { + lock := filepath.Join(t.TempDir(), "run.lock") + code, err := RunWithLock(lock, time.Second, 0, []string{"true"}) + if err != nil || code != 0 { + t.Errorf("RunWithLock(true) = %d, %v", code, err) + } + if _, statErr := os.Stat(lock); !os.IsNotExist(statErr) { + t.Error("lock should be released after the command exits") + } + + code, err = RunWithLock(lock, time.Second, 0, []string{"false"}) + if err != nil || code == 0 { + t.Errorf("RunWithLock(false) = %d, %v; want non-zero exit, nil error", code, err) + } + if _, statErr := os.Stat(lock); !os.IsNotExist(statErr) { + t.Error("lock should be released even when the command fails") + } +} diff --git a/services/multidoc_service.go b/services/multidoc_service.go new file mode 100644 index 0000000..4c39400 --- /dev/null +++ b/services/multidoc_service.go @@ -0,0 +1,216 @@ +package services + +import ( + "bytes" + "fmt" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// YAMLDocSpan is one document inside a multi-document YAML stream, addressed +// by its byte range in the original data so edits can be spliced back without +// touching sibling documents. +type YAMLDocSpan struct { + Start int + End int + Doc interface{} // decoded content, nil for empty documents +} + +// SplitYAMLDocs splits a YAML stream on `---` document markers and decodes +// each document. Spans cover the document bodies only, never the marker +// lines, so re-joining edited spans preserves the original separators. +func SplitYAMLDocs(data []byte) ([]YAMLDocSpan, error) { + var spans []YAMLDocSpan + lineStart := 0 + docStart := 0 + + flush := func(end int) error { + body := data[docStart:end] + var doc interface{} + if err := yaml.Unmarshal(body, &doc); err != nil { + return fmt.Errorf("parsing document at byte %d: %w", docStart, err) + } + spans = append(spans, YAMLDocSpan{Start: docStart, End: end, Doc: normalizeMaps(doc)}) + return nil + } + + for lineStart <= len(data) { + lineEnd := len(data) + if idx := bytes.IndexByte(data[lineStart:], '\n'); idx >= 0 { + lineEnd = lineStart + idx + 1 + } + line := data[lineStart:lineEnd] + trimmed := strings.TrimRight(string(line), "\r\n") + if trimmed == "---" || strings.HasPrefix(trimmed, "--- ") { + if err := flush(lineStart); err != nil { + return nil, err + } + docStart = lineEnd + } + if lineEnd == len(data) { + break + } + lineStart = lineEnd + } + if err := flush(len(data)); err != nil { + return nil, err + } + return spans, nil +} + +// SelectYAMLDoc resolves a --doc selector against a stream. The selector is +// either a zero-based index over non-empty documents ("1"), or a +// comma-separated list of dotpath=value matches ("kind=Deployment,metadata.name=api"). +// Exactly one document must match. +func SelectYAMLDoc(data []byte, selector string) (YAMLDocSpan, error) { + spans, err := SplitYAMLDocs(data) + if err != nil { + return YAMLDocSpan{}, err + } + var real []YAMLDocSpan + for _, s := range spans { + if s.Doc != nil { + real = append(real, s) + } + } + if len(real) == 0 { + return YAMLDocSpan{}, fmt.Errorf("no YAML documents found") + } + + if idx, err := strconv.Atoi(strings.TrimSpace(selector)); err == nil { + if idx < 0 || idx >= len(real) { + return YAMLDocSpan{}, fmt.Errorf("document index %d out of range (stream has %d documents)", idx, len(real)) + } + return real[idx], nil + } + + matchers, err := parseDocSelector(selector) + if err != nil { + return YAMLDocSpan{}, err + } + var matches []YAMLDocSpan + for _, s := range real { + if docMatches(s.Doc, matchers) { + matches = append(matches, s) + } + } + switch len(matches) { + case 0: + return YAMLDocSpan{}, fmt.Errorf("no document matches selector %q", selector) + case 1: + return matches[0], nil + default: + return YAMLDocSpan{}, fmt.Errorf("selector %q matches %d documents; add more fields (e.g. metadata.name=...)", selector, len(matches)) + } +} + +type docMatcher struct { + path string + value string +} + +func parseDocSelector(selector string) ([]docMatcher, error) { + parts := strings.Split(selector, ",") + matchers := make([]docMatcher, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + kv := strings.SplitN(p, "=", 2) + if len(kv) != 2 || kv[0] == "" { + return nil, fmt.Errorf("bad selector part %q: want dotpath=value or a document index", p) + } + path := kv[0] + if !strings.HasPrefix(path, ".") { + path = "." + path + } + matchers = append(matchers, docMatcher{path: path, value: kv[1]}) + } + if len(matchers) == 0 { + return nil, fmt.Errorf("empty selector") + } + return matchers, nil +} + +func docMatches(doc interface{}, matchers []docMatcher) bool { + for _, m := range matchers { + val, err := JSONGet(doc, m.path) + if err != nil || val == nil { + return false + } + if fmt.Sprintf("%v", val) != m.value { + return false + } + } + return true +} + +// MultiDocGet extracts a value at path from the document picked by selector. +func MultiDocGet(data []byte, selector, path string) (interface{}, error) { + span, err := SelectYAMLDoc(data, selector) + if err != nil { + return nil, err + } + return JSONGet(span.Doc, path) +} + +// MultiDocEdit applies edit to the selected document's bytes and splices the +// result back, leaving every other document byte-identical. The edit callback +// receives only the selected document's body. +func MultiDocEdit(data []byte, selector string, edit func(doc []byte) ([]byte, error)) ([]byte, error) { + span, err := SelectYAMLDoc(data, selector) + if err != nil { + return nil, err + } + edited, err := edit(data[span.Start:span.End]) + if err != nil { + return nil, err + } + out := make([]byte, 0, len(data)-(span.End-span.Start)+len(edited)) + out = append(out, data[:span.Start]...) + out = append(out, edited...) + out = append(out, data[span.End:]...) + return out, nil +} + +// MultiDocSet sets path=value in the selected document. With preserve, the +// edit is surgical (comments and formatting kept); otherwise the selected +// document is re-encoded. Sibling documents are never rewritten. +func MultiDocSet(data []byte, selector, path string, value interface{}, preserve bool) ([]byte, error) { + return MultiDocEdit(data, selector, func(doc []byte) ([]byte, error) { + if preserve { + return SetPreserving(doc, FormatYAML, path, value) + } + return reencodeYAMLDoc(doc, func(d interface{}) (interface{}, error) { + return JSONSet(d, path, value) + }) + }) +} + +// MultiDocDel removes path from the selected document; see MultiDocSet for +// preserve semantics. +func MultiDocDel(data []byte, selector, path string, preserve bool) ([]byte, error) { + return MultiDocEdit(data, selector, func(doc []byte) ([]byte, error) { + if preserve { + return DelPreserving(doc, FormatYAML, path) + } + return reencodeYAMLDoc(doc, func(d interface{}) (interface{}, error) { + return JSONDel(d, path) + }) + }) +} + +func reencodeYAMLDoc(doc []byte, mutate func(interface{}) (interface{}, error)) ([]byte, error) { + decoded, err := Decode(doc, FormatYAML) + if err != nil { + return nil, err + } + updated, err := mutate(decoded) + if err != nil { + return nil, err + } + return Encode(updated, FormatYAML, true) +} diff --git a/services/multidoc_service_test.go b/services/multidoc_service_test.go new file mode 100644 index 0000000..e6b4982 --- /dev/null +++ b/services/multidoc_service_test.go @@ -0,0 +1,177 @@ +package services + +import ( + "strings" + "testing" +) + +const k8sStream = `# Deployment for the api service +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api # inline comment +spec: + replicas: 2 +--- +apiVersion: v1 +kind: Service +metadata: + name: api +spec: + ports: + - port: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: worker +spec: + replicas: 1 +` + +func TestSplitYAMLDocs(t *testing.T) { + spans, err := SplitYAMLDocs([]byte(k8sStream)) + if err != nil { + t.Fatalf("SplitYAMLDocs() error = %v", err) + } + if len(spans) != 3 { + t.Fatalf("got %d spans, want 3", len(spans)) + } + for i, s := range spans { + if s.Doc == nil { + t.Errorf("span %d decoded to nil", i) + } + } +} + +func TestSplitYAMLDocs_LeadingMarker(t *testing.T) { + data := "---\na: 1\n---\nb: 2\n" + spans, err := SplitYAMLDocs([]byte(data)) + if err != nil { + t.Fatalf("SplitYAMLDocs() error = %v", err) + } + real := 0 + for _, s := range spans { + if s.Doc != nil { + real++ + } + } + if real != 2 { + t.Errorf("got %d non-empty docs, want 2 (leading --- yields an empty span)", real) + } +} + +func TestSelectYAMLDoc_ByIndex(t *testing.T) { + span, err := SelectYAMLDoc([]byte(k8sStream), "1") + if err != nil { + t.Fatalf("SelectYAMLDoc() error = %v", err) + } + kind, _ := JSONGet(span.Doc, ".kind") + if kind != "Service" { + t.Errorf("doc 1 kind = %v, want Service", kind) + } +} + +func TestSelectYAMLDoc_ByFields(t *testing.T) { + span, err := SelectYAMLDoc([]byte(k8sStream), "kind=Deployment,metadata.name=worker") + if err != nil { + t.Fatalf("SelectYAMLDoc() error = %v", err) + } + name, _ := JSONGet(span.Doc, ".metadata.name") + if name != "worker" { + t.Errorf("selected doc name = %v, want worker", name) + } +} + +func TestSelectYAMLDoc_Ambiguous(t *testing.T) { + if _, err := SelectYAMLDoc([]byte(k8sStream), "kind=Deployment"); err == nil { + t.Error("expected ambiguity error for kind=Deployment (2 matches)") + } + if _, err := SelectYAMLDoc([]byte(k8sStream), "kind=CronJob"); err == nil { + t.Error("expected no-match error for kind=CronJob") + } + if _, err := SelectYAMLDoc([]byte(k8sStream), "9"); err == nil { + t.Error("expected out-of-range error for index 9") + } + if _, err := SelectYAMLDoc([]byte(k8sStream), "kindDeployment"); err == nil { + t.Error("expected parse error for selector without =") + } +} + +func TestMultiDocGet(t *testing.T) { + val, err := MultiDocGet([]byte(k8sStream), "kind=Service", ".spec.ports[0].port") + if err != nil { + t.Fatalf("MultiDocGet() error = %v", err) + } + if got := toFloat(val); got != 80 { + t.Errorf("MultiDocGet() = %v, want 80", val) + } +} + +func toFloat(v interface{}) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + default: + return -1 + } +} + +func TestMultiDocSet_PreserveKeepsSiblingsAndComments(t *testing.T) { + out, err := MultiDocSet([]byte(k8sStream), "kind=Deployment,metadata.name=api", ".spec.replicas", 5, true) + if err != nil { + t.Fatalf("MultiDocSet() error = %v", err) + } + got := string(out) + + if !strings.Contains(got, "replicas: 5") { + t.Errorf("edit missing:\n%s", got) + } + if !strings.Contains(got, "# Deployment for the api service") || + !strings.Contains(got, "name: api # inline comment") { + t.Errorf("comments lost:\n%s", got) + } + // Sibling docs must be byte-identical. + for _, sibling := range []string{ + "apiVersion: v1\nkind: Service\nmetadata:\n name: api\nspec:\n ports:\n - port: 80\n", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: worker\nspec:\n replicas: 1\n", + } { + if !strings.Contains(got, sibling) { + t.Errorf("sibling document rewritten:\n%s", got) + } + } + // Still 3 documents. + if strings.Count(got, "\n---\n")+strings.Count(got, "\n---\r\n") != 2 { + t.Errorf("document separators changed:\n%s", got) + } +} + +func TestMultiDocSet_NoPreserve(t *testing.T) { + out, err := MultiDocSet([]byte(k8sStream), "1", ".spec.ports[0].port", 8080, false) + if err != nil { + t.Fatalf("MultiDocSet() error = %v", err) + } + got := string(out) + if !strings.Contains(got, "port: 8080") { + t.Errorf("edit missing:\n%s", got) + } + if !strings.Contains(got, "# Deployment for the api service") { + t.Errorf("untouched sibling lost its comment:\n%s", got) + } +} + +func TestMultiDocDel(t *testing.T) { + out, err := MultiDocDel([]byte(k8sStream), "kind=Deployment,metadata.name=worker", ".spec.replicas", true) + if err != nil { + t.Fatalf("MultiDocDel() error = %v", err) + } + got := string(out) + if strings.Contains(got, "replicas: 1") { + t.Errorf("delete did not remove target:\n%s", got) + } + if !strings.Contains(got, "replicas: 2") { + t.Errorf("delete touched the wrong document:\n%s", got) + } +} diff --git a/services/render_service.go b/services/render_service.go index f73b7ad..68655a2 100644 --- a/services/render_service.go +++ b/services/render_service.go @@ -264,3 +264,76 @@ func buildTemplateData(values map[string]interface{}) map[string]interface{} { "Env": envMap, } } + +// EnvsubstOptions configures Envsubst. +type EnvsubstOptions struct { + // Strict fails on ${VAR} references with no value and no default. + Strict bool + // Lookup resolves a variable name; defaults to os.LookupEnv. + Lookup func(string) (string, bool) +} + +var envsubstRef = regexp.MustCompile(`\$(\w+)|\$\{(\w+)(?::-([^}]*))?\}`) + +// Envsubst replaces $VAR and ${VAR} references with environment values, +// supporting ${VAR:-default} fallbacks — a portable replacement for the +// gettext envsubst binary. $$ escapes a literal dollar sign. +func Envsubst(input string, opts EnvsubstOptions) (string, error) { + lookup := opts.Lookup + if lookup == nil { + lookup = os.LookupEnv + } + + var missing []string + var b strings.Builder + rest := input + for len(rest) > 0 { + dollar := strings.IndexByte(rest, '$') + if dollar < 0 { + b.WriteString(rest) + break + } + b.WriteString(rest[:dollar]) + rest = rest[dollar:] + + if strings.HasPrefix(rest, "$$") { + b.WriteByte('$') + rest = rest[2:] + continue + } + + loc := envsubstRef.FindStringSubmatchIndex(rest) + if loc == nil || loc[0] != 0 { + b.WriteByte('$') + rest = rest[1:] + continue + } + name := firstSubmatch(rest, loc, 1, 2) + value, ok := lookup(name) + if !ok && loc[6] >= 0 { // ${VAR:-default} + value = rest[loc[6]:loc[7]] + ok = true + } + if !ok && opts.Strict { + missing = append(missing, name) + } + // Like gettext envsubst, an unset variable without a default renders + // as empty (unless strict already flagged it). + b.WriteString(value) + rest = rest[loc[1]:] + } + + if len(missing) > 0 { + return "", fmt.Errorf("unset variable(s): %s", strings.Join(missing, ", ")) + } + return b.String(), nil +} + +func firstSubmatch(s string, loc []int, groups ...int) string { + for _, g := range groups { + if loc[2*g] >= 0 { + return s[loc[2*g]:loc[2*g+1]] + } + } + return "" +} diff --git a/services/render_service_test.go b/services/render_service_test.go index 20517e0..1edb322 100644 --- a/services/render_service_test.go +++ b/services/render_service_test.go @@ -97,7 +97,7 @@ func TestRenderTemplateString_Basics(t *testing.T) { func TestRenderTemplateFile(t *testing.T) { dir := t.TempDir() tmpl := filepath.Join(dir, "values.yaml.tpl") - os.WriteFile(tmpl, []byte("image:\n tag: {{ .Values.tag }}\n"), 0644) + os.WriteFile(tmpl, []byte("image:\n tag: {{ .Values.tag }}\n"), 0o644) got, err := RenderTemplateFile(tmpl, map[string]interface{}{"tag": "v1.2.3"}) if err != nil { @@ -126,7 +126,7 @@ func TestApplySetOverrides(t *testing.T) { func TestLoadValues(t *testing.T) { dir := t.TempDir() yamlFile := filepath.Join(dir, "v.yaml") - os.WriteFile(yamlFile, []byte("image:\n tag: v1.0.0\n"), 0644) + os.WriteFile(yamlFile, []byte("image:\n tag: v1.0.0\n"), 0o644) got, err := LoadValues(yamlFile) if err != nil { t.Fatalf("error: %v", err) @@ -135,3 +135,53 @@ func TestLoadValues(t *testing.T) { t.Errorf("got: %v", got) } } + +func TestEnvsubst(t *testing.T) { + lookup := func(name string) (string, bool) { + vals := map[string]string{"IMAGE": "app:v2", "ENV": "prod"} + v, ok := vals[name] + return v, ok + } + + tests := []struct { + name string + input string + want string + }{ + {"braced", "image: ${IMAGE}", "image: app:v2"}, + {"bare", "env: $ENV", "env: prod"}, + {"default used", "region: ${REGION:-eu-west-1}", "region: eu-west-1"}, + {"default ignored when set", "env: ${ENV:-dev}", "env: prod"}, + {"unset renders empty", "x: [${MISSING}]", "x: []"}, + {"escaped dollar", "cost: $$5 for ${ENV}", "cost: $5 for prod"}, + {"lone dollar", "a $ sign", "a $ sign"}, + {"empty default", "v: ${MISSING:-}", "v: "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Envsubst(tt.input, EnvsubstOptions{Lookup: lookup}) + if err != nil { + t.Fatalf("Envsubst() error = %v", err) + } + if got != tt.want { + t.Errorf("Envsubst(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestEnvsubst_Strict(t *testing.T) { + lookup := func(string) (string, bool) { return "", false } + _, err := Envsubst("a: ${FOO}\nb: $BAR\nc: ${OK:-x}", EnvsubstOptions{Strict: true, Lookup: lookup}) + if err == nil { + t.Fatal("Envsubst() strict should fail on unset vars") + } + for _, name := range []string{"FOO", "BAR"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q should mention %s", err, name) + } + } + if strings.Contains(err.Error(), "OK") { + t.Errorf("error %q should not mention OK (has default)", err) + } +} diff --git a/services/report_service.go b/services/report_service.go new file mode 100644 index 0000000..dc31cde --- /dev/null +++ b/services/report_service.go @@ -0,0 +1,281 @@ +package services + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "sort" + "strconv" + "strings" +) + +// JUnitCase is a single test case pulled from a JUnit XML report. +type JUnitCase struct { + Name string `json:"name"` + ClassName string `json:"classname,omitempty"` + Time float64 `json:"time"` + Status string `json:"status"` // passed, failed, error, skipped + Message string `json:"message,omitempty"` +} + +// JUnitReport aggregates one or more JUnit test suites. +type JUnitReport struct { + Total int `json:"total"` + Passed int `json:"passed"` + Failures int `json:"failures"` + Errors int `json:"errors"` + Skipped int `json:"skipped"` + Duration float64 `json:"duration"` + Failed []JUnitCase `json:"failed,omitempty"` + Cases []JUnitCase `json:"-"` +} + +type junitXMLResult struct { + Message string `xml:"message,attr"` + Body string `xml:",chardata"` +} + +type junitXMLCase struct { + Name string `xml:"name,attr"` + ClassName string `xml:"classname,attr"` + Time string `xml:"time,attr"` + Failure *junitXMLResult `xml:"failure"` + Error *junitXMLResult `xml:"error"` + Skipped *junitXMLResult `xml:"skipped"` +} + +type junitXMLSuite struct { + Name string `xml:"name,attr"` + Suites []junitXMLSuite `xml:"testsuite"` + Cases []junitXMLCase `xml:"testcase"` +} + +type junitXMLSuites struct { + Suites []junitXMLSuite `xml:"testsuite"` +} + +// ParseJUnit parses a JUnit XML report. Both and a bare +// root are accepted; nested suites are flattened. +func ParseJUnit(data []byte) (*JUnitReport, error) { + root := struct { + XMLName xml.Name + }{} + if err := xml.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("parsing JUnit XML: %w", err) + } + + var suites []junitXMLSuite + switch root.XMLName.Local { + case "testsuites": + var doc junitXMLSuites + if err := xml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parsing JUnit XML: %w", err) + } + suites = doc.Suites + case "testsuite": + var doc junitXMLSuite + if err := xml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parsing JUnit XML: %w", err) + } + suites = []junitXMLSuite{doc} + default: + return nil, fmt.Errorf("unexpected root element <%s>: want or ", root.XMLName.Local) + } + + report := &JUnitReport{} + for _, s := range suites { + collectJUnitSuite(s, report) + } + return report, nil +} + +func collectJUnitSuite(s junitXMLSuite, report *JUnitReport) { + for _, nested := range s.Suites { + collectJUnitSuite(nested, report) + } + for _, c := range s.Cases { + kase := JUnitCase{ + Name: c.Name, + ClassName: c.ClassName, + Time: parseJUnitTime(c.Time), + Status: "passed", + } + switch { + case c.Error != nil: + kase.Status = "error" + kase.Message = junitMessage(c.Error) + report.Errors++ + report.Failed = append(report.Failed, kase) + case c.Failure != nil: + kase.Status = "failed" + kase.Message = junitMessage(c.Failure) + report.Failures++ + report.Failed = append(report.Failed, kase) + case c.Skipped != nil: + kase.Status = "skipped" + report.Skipped++ + default: + report.Passed++ + } + report.Total++ + report.Duration += kase.Time + report.Cases = append(report.Cases, kase) + } +} + +func junitMessage(r *junitXMLResult) string { + msg := strings.TrimSpace(r.Message) + if msg == "" { + msg = strings.TrimSpace(r.Body) + } + if idx := strings.IndexByte(msg, '\n'); idx >= 0 { + msg = msg[:idx] + } + return msg +} + +func parseJUnitTime(s string) float64 { + s = strings.ReplaceAll(strings.TrimSpace(s), ",", "") + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return v +} + +// SlowestJUnitCases returns the n slowest cases, slowest first. +func SlowestJUnitCases(report *JUnitReport, n int) []JUnitCase { + cases := make([]JUnitCase, len(report.Cases)) + copy(cases, report.Cases) + sort.SliceStable(cases, func(i, j int) bool { return cases[i].Time > cases[j].Time }) + if n > len(cases) { + n = len(cases) + } + return cases[:n] +} + +// FormatJUnitMarkdown renders a report as markdown for step summaries and +// PR comments. slowest > 0 appends a slowest-tests table. +func FormatJUnitMarkdown(report *JUnitReport, slowest int) string { + var b strings.Builder + status := "✅" + if report.Failures+report.Errors > 0 { + status = "❌" + } + fmt.Fprintf(&b, "%s **%d tests** — %d passed, %d failed, %d errors, %d skipped (%.2fs)\n", + status, report.Total, report.Passed, report.Failures, report.Errors, report.Skipped, report.Duration) + + if len(report.Failed) > 0 { + b.WriteString("\n| Test | Status | Message |\n|---|---|---|\n") + for _, c := range report.Failed { + fmt.Fprintf(&b, "| `%s` | %s | %s |\n", + escapeMarkdownCell(junitCaseID(c)), c.Status, escapeMarkdownCell(c.Message)) + } + } + + if slowest > 0 && report.Total > 0 { + b.WriteString("\n
Slowest tests\n\n| Test | Duration |\n|---|---|\n") + for _, c := range SlowestJUnitCases(report, slowest) { + fmt.Fprintf(&b, "| `%s` | %.2fs |\n", escapeMarkdownCell(junitCaseID(c)), c.Time) + } + b.WriteString("\n
\n") + } + return b.String() +} + +func junitCaseID(c JUnitCase) string { + if c.ClassName != "" { + return c.ClassName + "/" + c.Name + } + return c.Name +} + +// FormatJUnitJSON renders the aggregate counts and failed cases as JSON. +func FormatJUnitJSON(report *JUnitReport) (string, error) { + data, err := json.Marshal(report) + if err != nil { + return "", err + } + return string(data) + "\n", nil +} + +// ParseCoverage extracts a total line-coverage percentage (0–100) from a +// coverage report. format is lcov, cobertura, or auto (sniff the content). +func ParseCoverage(data []byte, format string) (float64, error) { + switch strings.ToLower(format) { + case "lcov": + return parseLcov(data) + case "cobertura", "xml": + return parseCobertura(data) + case "", "auto": + trimmed := strings.TrimSpace(string(data)) + if strings.HasPrefix(trimmed, "<") { + return parseCobertura(data) + } + return parseLcov(data) + default: + return 0, fmt.Errorf("unknown coverage format: %s (use lcov, cobertura, auto)", format) + } +} + +func parseLcov(data []byte) (float64, error) { + var found, hit int + seen := false + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "LF:"): + v, err := strconv.Atoi(line[3:]) + if err != nil { + return 0, fmt.Errorf("parsing lcov LF line %q: %w", line, err) + } + found += v + seen = true + case strings.HasPrefix(line, "LH:"): + v, err := strconv.Atoi(line[3:]) + if err != nil { + return 0, fmt.Errorf("parsing lcov LH line %q: %w", line, err) + } + hit += v + seen = true + } + } + if !seen { + return 0, fmt.Errorf("no LF/LH records found: not an lcov report?") + } + if found == 0 { + return 0, nil + } + return float64(hit) / float64(found) * 100, nil +} + +func parseCobertura(data []byte) (float64, error) { + var doc struct { + XMLName xml.Name + LineRate string `xml:"line-rate,attr"` + } + if err := xml.Unmarshal(data, &doc); err != nil { + return 0, fmt.Errorf("parsing cobertura XML: %w", err) + } + if doc.XMLName.Local != "coverage" || doc.LineRate == "" { + return 0, fmt.Errorf("not a cobertura report: want root") + } + rate, err := strconv.ParseFloat(doc.LineRate, 64) + if err != nil { + return 0, fmt.Errorf("parsing line-rate %q: %w", doc.LineRate, err) + } + return rate * 100, nil +} + +// FormatCoverageMarkdown renders a one-line coverage summary with an +// optional threshold verdict (min < 0 means no threshold). +func FormatCoverageMarkdown(pct, min float64) string { + if min >= 0 { + status := "✅" + if pct < min { + status = "❌" + } + return fmt.Sprintf("%s **Coverage: %.1f%%** (threshold %.1f%%)\n", status, pct, min) + } + return fmt.Sprintf("**Coverage: %.1f%%**\n", pct) +} diff --git a/services/report_service_test.go b/services/report_service_test.go new file mode 100644 index 0000000..e1a1822 --- /dev/null +++ b/services/report_service_test.go @@ -0,0 +1,142 @@ +package services + +import ( + "math" + "strings" + "testing" +) + +const junitSample = ` + + + + + stack trace here + + + + + + + + + boom +more detail + + + +` + +func TestParseJUnit_Counts(t *testing.T) { + report, err := ParseJUnit([]byte(junitSample)) + if err != nil { + t.Fatalf("ParseJUnit() error = %v", err) + } + if report.Total != 4 || report.Passed != 1 || report.Failures != 1 || report.Errors != 1 || report.Skipped != 1 { + t.Errorf("counts = %+v, want total=4 passed=1 failures=1 errors=1 skipped=1", report) + } + if math.Abs(report.Duration-3.75) > 1e-9 { + t.Errorf("Duration = %v, want 3.75", report.Duration) + } + if len(report.Failed) != 2 { + t.Fatalf("Failed = %v, want 2 cases", report.Failed) + } + if report.Failed[0].Message != "expected 2, got 3" { + t.Errorf("failure message = %q", report.Failed[0].Message) + } + if report.Failed[1].Message != "boom" { + t.Errorf("error message = %q, want first line only", report.Failed[1].Message) + } +} + +func TestParseJUnit_BareSuiteRoot(t *testing.T) { + data := `` + report, err := ParseJUnit([]byte(data)) + if err != nil { + t.Fatalf("ParseJUnit() error = %v", err) + } + if report.Total != 1 || report.Passed != 1 { + t.Errorf("report = %+v, want 1 passed", report) + } +} + +func TestParseJUnit_BadRoot(t *testing.T) { + if _, err := ParseJUnit([]byte(``)); err == nil { + t.Error("ParseJUnit() expected error for non-junit root") + } +} + +func TestSlowestJUnitCases(t *testing.T) { + report, err := ParseJUnit([]byte(junitSample)) + if err != nil { + t.Fatalf("ParseJUnit() error = %v", err) + } + slowest := SlowestJUnitCases(report, 2) + if len(slowest) != 2 || slowest[0].Name != "TestErr" || slowest[1].Name != "TestBroken" { + t.Errorf("SlowestJUnitCases() = %v", slowest) + } +} + +func TestFormatJUnitMarkdown(t *testing.T) { + report, err := ParseJUnit([]byte(junitSample)) + if err != nil { + t.Fatalf("ParseJUnit() error = %v", err) + } + out := FormatJUnitMarkdown(report, 2) + for _, want := range []string{"❌", "**4 tests**", "`alpha/TestBroken`", "expected 2, got 3", "Slowest tests"} { + if !strings.Contains(out, want) { + t.Errorf("FormatJUnitMarkdown() missing %q in:\n%s", want, out) + } + } + + pass := &JUnitReport{Total: 2, Passed: 2} + if out := FormatJUnitMarkdown(pass, 0); !strings.Contains(out, "✅") { + t.Errorf("FormatJUnitMarkdown() all-pass missing ✅:\n%s", out) + } +} + +func TestParseCoverage_Lcov(t *testing.T) { + lcov := "TN:\nSF:/src/a.go\nLF:10\nLH:8\nend_of_record\nSF:/src/b.go\nLF:10\nLH:6\nend_of_record\n" + pct, err := ParseCoverage([]byte(lcov), "auto") + if err != nil { + t.Fatalf("ParseCoverage() error = %v", err) + } + if math.Abs(pct-70.0) > 1e-9 { + t.Errorf("ParseCoverage() = %v, want 70", pct) + } +} + +func TestParseCoverage_Cobertura(t *testing.T) { + xmlData := `` + pct, err := ParseCoverage([]byte(xmlData), "auto") + if err != nil { + t.Fatalf("ParseCoverage() error = %v", err) + } + if math.Abs(pct-84.5) > 1e-9 { + t.Errorf("ParseCoverage() = %v, want 84.5", pct) + } +} + +func TestParseCoverage_Errors(t *testing.T) { + if _, err := ParseCoverage([]byte("not a report"), "auto"); err == nil { + t.Error("expected error for garbage input") + } + if _, err := ParseCoverage([]byte(``), "auto"); err == nil { + t.Error("expected error for non-cobertura XML") + } + if _, err := ParseCoverage([]byte(""), "nope"); err == nil { + t.Error("expected error for unknown format") + } +} + +func TestFormatCoverageMarkdown(t *testing.T) { + if out := FormatCoverageMarkdown(75.0, 80.0); !strings.Contains(out, "❌") { + t.Errorf("below threshold should be ❌: %s", out) + } + if out := FormatCoverageMarkdown(85.0, 80.0); !strings.Contains(out, "✅") { + t.Errorf("above threshold should be ✅: %s", out) + } + if out := FormatCoverageMarkdown(85.0, -1); strings.Contains(out, "threshold") { + t.Errorf("no threshold should omit verdict: %s", out) + } +} diff --git a/services/structdiff_service.go b/services/structdiff_service.go new file mode 100644 index 0000000..588ee3b --- /dev/null +++ b/services/structdiff_service.go @@ -0,0 +1,172 @@ +package services + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// DiffEntry describes a single structural difference between two documents. +type DiffEntry struct { + Path string `json:"path"` + Kind string `json:"kind"` // added, removed, changed + Old interface{} `json:"old,omitempty"` + New interface{} `json:"new,omitempty"` +} + +// StructDiff compares two decoded documents structurally — key order and +// formatting play no role — and returns the differences sorted by path. +// ignorePaths entries prune the comparison: any difference at or below an +// ignored path is dropped (prefix match on the jq-style path). +func StructDiff(a, b interface{}, ignorePaths []string) []DiffEntry { + var entries []DiffEntry + walkDiff(normalizeMaps(a), normalizeMaps(b), ".", &entries) + + if len(ignorePaths) > 0 { + kept := entries[:0] + for _, e := range entries { + if !pathIgnored(e.Path, ignorePaths) { + kept = append(kept, e) + } + } + entries = kept + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + return entries +} + +func pathIgnored(path string, ignore []string) bool { + for _, ig := range ignore { + ig = strings.TrimSuffix(ig, ".") + if !strings.HasPrefix(ig, ".") { + ig = "." + ig + } + if path == ig || strings.HasPrefix(path, ig+".") || strings.HasPrefix(path, ig+"[") { + return true + } + } + return false +} + +func walkDiff(a, b interface{}, path string, out *[]DiffEntry) { + am, aIsMap := a.(map[string]interface{}) + bm, bIsMap := b.(map[string]interface{}) + if aIsMap && bIsMap { + keys := map[string]struct{}{} + for k := range am { + keys[k] = struct{}{} + } + for k := range bm { + keys[k] = struct{}{} + } + for k := range keys { + childPath := joinDiffPath(path, k) + av, aok := am[k] + bv, bok := bm[k] + switch { + case !aok: + *out = append(*out, DiffEntry{Path: childPath, Kind: "added", New: bv}) + case !bok: + *out = append(*out, DiffEntry{Path: childPath, Kind: "removed", Old: av}) + default: + walkDiff(av, bv, childPath, out) + } + } + return + } + + as, aIsSlice := a.([]interface{}) + bs, bIsSlice := b.([]interface{}) + if aIsSlice && bIsSlice { + max := len(as) + if len(bs) > max { + max = len(bs) + } + for i := 0; i < max; i++ { + childPath := fmt.Sprintf("%s[%d]", strings.TrimSuffix(path, "."), i) + switch { + case i >= len(as): + *out = append(*out, DiffEntry{Path: childPath, Kind: "added", New: bs[i]}) + case i >= len(bs): + *out = append(*out, DiffEntry{Path: childPath, Kind: "removed", Old: as[i]}) + default: + walkDiff(as[i], bs[i], childPath, out) + } + } + return + } + + if !jsonEqual(a, b) { + *out = append(*out, DiffEntry{Path: strings.TrimSuffix(path, "."), Kind: "changed", Old: a, New: b}) + } +} + +func joinDiffPath(base, key string) string { + if base == "." { + return "." + key + } + return base + "." + key +} + +// FormatDiffText renders entries in a compact +/-/~ line format. +func FormatDiffText(entries []DiffEntry) string { + var b strings.Builder + for _, e := range entries { + switch e.Kind { + case "added": + fmt.Fprintf(&b, "+ %s: %s\n", e.Path, compactJSON(e.New)) + case "removed": + fmt.Fprintf(&b, "- %s: %s\n", e.Path, compactJSON(e.Old)) + default: + fmt.Fprintf(&b, "~ %s: %s -> %s\n", e.Path, compactJSON(e.Old), compactJSON(e.New)) + } + } + return b.String() +} + +// FormatDiffMarkdown renders entries as a GitHub-flavored markdown table, +// ready for a step summary or PR comment. +func FormatDiffMarkdown(entries []DiffEntry) string { + if len(entries) == 0 { + return "No differences.\n" + } + var b strings.Builder + b.WriteString("| Path | Change | Old | New |\n|---|---|---|---|\n") + for _, e := range entries { + old, new := "", "" + if e.Kind != "added" { + old = "`" + escapeMarkdownCell(compactJSON(e.Old)) + "`" + } + if e.Kind != "removed" { + new = "`" + escapeMarkdownCell(compactJSON(e.New)) + "`" + } + fmt.Fprintf(&b, "| `%s` | %s | %s | %s |\n", escapeMarkdownCell(e.Path), e.Kind, old, new) + } + return b.String() +} + +// FormatDiffJSON renders entries as a JSON array. +func FormatDiffJSON(entries []DiffEntry) (string, error) { + if entries == nil { + entries = []DiffEntry{} + } + data, err := json.Marshal(entries) + if err != nil { + return "", err + } + return string(data) + "\n", nil +} + +func compactJSON(v interface{}) string { + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(data) +} + +func escapeMarkdownCell(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} diff --git a/services/structdiff_service_test.go b/services/structdiff_service_test.go new file mode 100644 index 0000000..1eb59ad --- /dev/null +++ b/services/structdiff_service_test.go @@ -0,0 +1,127 @@ +package services + +import ( + "strings" + "testing" +) + +func decodeForTest(t *testing.T, data string, format DataFormat) interface{} { + t.Helper() + doc, err := Decode([]byte(data), format) + if err != nil { + t.Fatalf("Decode() error = %v", err) + } + return doc +} + +func TestStructDiff_NoDifferences(t *testing.T) { + a := decodeForTest(t, `{"b": 2, "a": 1}`, FormatJSON) + b := decodeForTest(t, "a: 1\nb: 2\n", FormatYAML) + entries := StructDiff(a, b, nil) + if len(entries) != 0 { + t.Errorf("StructDiff() = %v, want empty", entries) + } +} + +func TestStructDiff_ChangedAddedRemoved(t *testing.T) { + a := decodeForTest(t, `{"replicas": 2, "image": "app:v1", "old": true}`, FormatJSON) + b := decodeForTest(t, `{"replicas": 3, "image": "app:v1", "fresh": "yes"}`, FormatJSON) + entries := StructDiff(a, b, nil) + if len(entries) != 3 { + t.Fatalf("StructDiff() returned %d entries, want 3: %v", len(entries), entries) + } + byPath := map[string]DiffEntry{} + for _, e := range entries { + byPath[e.Path] = e + } + if e := byPath[".replicas"]; e.Kind != "changed" { + t.Errorf(".replicas kind = %q, want changed", e.Kind) + } + if e := byPath[".fresh"]; e.Kind != "added" { + t.Errorf(".fresh kind = %q, want added", e.Kind) + } + if e := byPath[".old"]; e.Kind != "removed" { + t.Errorf(".old kind = %q, want removed", e.Kind) + } +} + +func TestStructDiff_NestedAndArrays(t *testing.T) { + a := decodeForTest(t, `{"spec": {"env": [{"name": "A", "value": "1"}, {"name": "B"}]}}`, FormatJSON) + b := decodeForTest(t, `{"spec": {"env": [{"name": "A", "value": "2"}]}}`, FormatJSON) + entries := StructDiff(a, b, nil) + if len(entries) != 2 { + t.Fatalf("StructDiff() returned %d entries, want 2: %v", len(entries), entries) + } + if entries[0].Path != ".spec.env[0].value" || entries[0].Kind != "changed" { + t.Errorf("entry[0] = %+v, want changed .spec.env[0].value", entries[0]) + } + if entries[1].Path != ".spec.env[1]" || entries[1].Kind != "removed" { + t.Errorf("entry[1] = %+v, want removed .spec.env[1]", entries[1]) + } +} + +func TestStructDiff_TypeChange(t *testing.T) { + a := decodeForTest(t, `{"port": "8080"}`, FormatJSON) + b := decodeForTest(t, `{"port": 8080}`, FormatJSON) + entries := StructDiff(a, b, nil) + if len(entries) != 1 || entries[0].Kind != "changed" { + t.Fatalf("StructDiff() = %v, want one changed entry", entries) + } +} + +func TestStructDiff_IgnorePaths(t *testing.T) { + a := decodeForTest(t, `{"metadata": {"generation": 1}, "spec": {"replicas": 2}}`, FormatJSON) + b := decodeForTest(t, `{"metadata": {"generation": 5}, "spec": {"replicas": 3}}`, FormatJSON) + + entries := StructDiff(a, b, []string{".metadata"}) + if len(entries) != 1 { + t.Fatalf("StructDiff() with ignore returned %d entries, want 1: %v", len(entries), entries) + } + if entries[0].Path != ".spec.replicas" { + t.Errorf("remaining path = %q, want .spec.replicas", entries[0].Path) + } + + // Ignore without leading dot should behave identically. + entries = StructDiff(a, b, []string{"metadata.generation"}) + if len(entries) != 1 || entries[0].Path != ".spec.replicas" { + t.Errorf("StructDiff() with dotless ignore = %v, want only .spec.replicas", entries) + } +} + +func TestFormatDiffText(t *testing.T) { + entries := []DiffEntry{ + {Path: ".a", Kind: "changed", Old: 1.0, New: 2.0}, + {Path: ".b", Kind: "added", New: "x"}, + {Path: ".c", Kind: "removed", Old: true}, + } + out := FormatDiffText(entries) + for _, want := range []string{"~ .a: 1 -> 2", "+ .b: \"x\"", "- .c: true"} { + if !strings.Contains(out, want) { + t.Errorf("FormatDiffText() missing %q in:\n%s", want, out) + } + } +} + +func TestFormatDiffMarkdown(t *testing.T) { + entries := []DiffEntry{{Path: ".image", Kind: "changed", Old: "app:v1", New: "app:v2"}} + out := FormatDiffMarkdown(entries) + if !strings.Contains(out, "| Path | Change | Old | New |") { + t.Errorf("FormatDiffMarkdown() missing header:\n%s", out) + } + if !strings.Contains(out, "| `.image` | changed | `\"app:v1\"` | `\"app:v2\"` |") { + t.Errorf("FormatDiffMarkdown() missing row:\n%s", out) + } + if got := FormatDiffMarkdown(nil); got != "No differences.\n" { + t.Errorf("FormatDiffMarkdown(nil) = %q", got) + } +} + +func TestFormatDiffJSON_EmptyIsArray(t *testing.T) { + out, err := FormatDiffJSON(nil) + if err != nil { + t.Fatalf("FormatDiffJSON() error = %v", err) + } + if strings.TrimSpace(out) != "[]" { + t.Errorf("FormatDiffJSON(nil) = %q, want []", out) + } +} diff --git a/services/version_service.go b/services/version_service.go index 2f036c2..5da8d54 100644 --- a/services/version_service.go +++ b/services/version_service.go @@ -76,7 +76,7 @@ func VersionBump(source, bumpType, preRelease, buildMeta string) (string, error) if err != nil { return "", err } - if err := os.WriteFile(source, []byte(newContent), 0644); err != nil { + if err := os.WriteFile(source, []byte(newContent), 0o644); err != nil { return "", fmt.Errorf("writing %s: %w", source, err) } @@ -104,7 +104,7 @@ func VersionSet(source, newVersion string) error { if err != nil { return err } - return os.WriteFile(source, []byte(newContent), 0644) + return os.WriteFile(source, []byte(newContent), 0o644) } // replaceVersionInFile rewrites only the version captured by the file's @@ -173,33 +173,31 @@ func VersionNext(source string) (string, error) { return "", fmt.Errorf("parsing version %q: %w", currentStr, err) } - // Get commits since last tag - out, err := exec.Command("git", "log", "--oneline", "--no-decorate", fmt.Sprintf("v%s..HEAD", currentStr)).Output() + // Get commit subjects since the last release tag. + revRange := fmt.Sprintf("v%s..HEAD", currentStr) + out, err := exec.Command("git", "log", "--format=%s", revRange).Output() if err != nil { // Try without v prefix - out, _ = exec.Command("git", "log", "--oneline", "--no-decorate", fmt.Sprintf("%s..HEAD", currentStr)).Output() + revRange = fmt.Sprintf("%s..HEAD", currentStr) + out, _ = exec.Command("git", "log", "--format=%s", revRange).Output() } - commits := strings.TrimSpace(string(out)) - if commits == "" { + subjectsRaw := strings.TrimSpace(string(out)) + if subjectsRaw == "" { return currentStr, nil } + subjects := strings.Split(subjectsRaw, "\n") - // Analyze conventional commits - hasBreaking := strings.Contains(commits, "BREAKING CHANGE") || strings.Contains(commits, "!:") - hasFeat := false - for _, line := range strings.Split(commits, "\n") { - if strings.Contains(line, "feat:") || strings.Contains(line, "feat(") { - hasFeat = true - } - } + // BREAKING CHANGE footers live in commit bodies, which --format=%s hides. + bodies, _ := exec.Command("git", "log", "--format=%b", revRange).Output() var next semver.Version - if hasBreaking { + switch AnalyzeConventionalCommits(subjects, string(bodies)) { + case "major": next = current.IncMajor() - } else if hasFeat { + case "minor": next = current.IncMinor() - } else { + default: next = current.IncPatch() } @@ -234,16 +232,16 @@ func FormatVersion(version, format string) string { } var versionPatterns = map[string]*regexp.Regexp{ - "package.json": regexp.MustCompile(`"version"\s*:\s*"([^"]+)"`), - "Cargo.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`), - "pyproject.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`), - "go.mod": regexp.MustCompile(`(?m)^// version:\s*(.+)$`), - "VERSION": regexp.MustCompile(`(?m)^(.+)$`), - "version.txt": regexp.MustCompile(`(?m)^(.+)$`), - "Chart.yaml": regexp.MustCompile(`(?m)^version:\s*(.+)$`), - "setup.py": regexp.MustCompile(`version\s*=\s*['"]([^'"]+)['"]`), - "build.gradle": regexp.MustCompile(`(?m)^version\s*=\s*['"]([^'"]+)['"]`), - "pom.xml": regexp.MustCompile(`([^<]+)`), + "package.json": regexp.MustCompile(`"version"\s*:\s*"([^"]+)"`), + "Cargo.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`), + "pyproject.toml": regexp.MustCompile(`(?m)^version\s*=\s*"([^"]+)"`), + "go.mod": regexp.MustCompile(`(?m)^// version:\s*(.+)$`), + "VERSION": regexp.MustCompile(`(?m)^(.+)$`), + "version.txt": regexp.MustCompile(`(?m)^(.+)$`), + "Chart.yaml": regexp.MustCompile(`(?m)^version:\s*(.+)$`), + "setup.py": regexp.MustCompile(`version\s*=\s*['"]([^'"]+)['"]`), + "build.gradle": regexp.MustCompile(`(?m)^version\s*=\s*['"]([^'"]+)['"]`), + "pom.xml": regexp.MustCompile(`([^<]+)`), } func extractVersion(filename, content string) (string, error) { @@ -290,3 +288,60 @@ func autoDetectVersionFile() string { } return "" } + +// AssertVersionsMatch extracts the version from each file and fails unless +// they are all identical — a monorepo guard against half-bumped releases. +func AssertVersionsMatch(files []string) (string, error) { + if len(files) < 2 { + return "", fmt.Errorf("at least two files required") + } + first := "" + for i, f := range files { + v, err := VersionGet(f) + if err != nil { + return "", err + } + if i == 0 { + first = v + continue + } + if v != first { + return "", fmt.Errorf("version mismatch: %s has %s, %s has %s", files[0], first, f, v) + } + } + return first, nil +} + +// conventionalSubject matches "type(scope)!: subject" conventional-commit +// subjects, anchored to the line start. +var conventionalSubject = regexp.MustCompile(`^(\w+)(\([^)]*\))?(!)?:`) + +// AnalyzeConventionalCommits inspects commit subjects (and full bodies for +// BREAKING CHANGE footers) and returns the bump they imply: major, minor, +// patch, or none. +func AnalyzeConventionalCommits(subjects []string, bodies string) string { + if strings.Contains(bodies, "BREAKING CHANGE:") || strings.Contains(bodies, "BREAKING-CHANGE:") { + return "major" + } + bump := "none" + for _, subject := range subjects { + m := conventionalSubject.FindStringSubmatch(strings.TrimSpace(subject)) + if m == nil { + continue + } + if m[3] == "!" { + return "major" + } + switch strings.ToLower(m[1]) { + case "feat": + if bump != "major" { + bump = "minor" + } + case "fix", "perf", "refactor", "revert": + if bump == "none" { + bump = "patch" + } + } + } + return bump +} diff --git a/services/version_service_test.go b/services/version_service_test.go index 1cbe696..e6823a1 100644 --- a/services/version_service_test.go +++ b/services/version_service_test.go @@ -9,7 +9,7 @@ import ( func TestVersionGet_PackageJSON(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "package.json") - os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644) + os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644) version, err := VersionGet(f) if err != nil { @@ -23,7 +23,7 @@ func TestVersionGet_PackageJSON(t *testing.T) { func TestVersionGet_CargoToml(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "Cargo.toml") - os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0644) + os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0o644) version, err := VersionGet(f) if err != nil { @@ -37,7 +37,7 @@ func TestVersionGet_CargoToml(t *testing.T) { func TestVersionGet_VersionFile(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "VERSION") - os.WriteFile(f, []byte("3.0.0\n"), 0644) + os.WriteFile(f, []byte("3.0.0\n"), 0o644) version, err := VersionGet(f) if err != nil { @@ -51,7 +51,7 @@ func TestVersionGet_VersionFile(t *testing.T) { func TestVersionBump_Patch(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "package.json") - os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644) + os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644) newVersion, err := VersionBump(f, "patch", "", "") if err != nil { @@ -71,7 +71,7 @@ func TestVersionBump_Patch(t *testing.T) { func TestVersionBump_Minor(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "package.json") - os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644) + os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644) newVersion, err := VersionBump(f, "minor", "", "") if err != nil { @@ -85,7 +85,7 @@ func TestVersionBump_Minor(t *testing.T) { func TestVersionBump_Major(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "package.json") - os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644) + os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644) newVersion, err := VersionBump(f, "major", "", "") if err != nil { @@ -99,7 +99,7 @@ func TestVersionBump_Major(t *testing.T) { func TestVersionBump_WithPreRelease(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "package.json") - os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0644) + os.WriteFile(f, []byte(`{"name": "myapp", "version": "1.2.3"}`), 0o644) newVersion, err := VersionBump(f, "patch", "alpha.1", "") if err != nil { @@ -161,7 +161,7 @@ func TestVersionBump_DoesNotRewriteDependencyPin(t *testing.T) { "dependencies": { "react": "1.2.3" }, "version": "1.2.3" }` - os.WriteFile(f, []byte(original), 0644) + os.WriteFile(f, []byte(original), 0o644) newVersion, err := VersionBump(f, "patch", "", "") if err != nil { @@ -183,7 +183,7 @@ func TestVersionBump_DoesNotRewriteDependencyPin(t *testing.T) { func TestVersionSet(t *testing.T) { tmpDir := t.TempDir() f := filepath.Join(tmpDir, "Cargo.toml") - os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0644) + os.WriteFile(f, []byte("[package]\nname = \"myapp\"\nversion = \"0.5.1\"\n"), 0o644) if err := VersionSet(f, "1.0.0"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -206,3 +206,55 @@ func containsHelper(s, substr string) bool { } return false } + +func TestAssertVersionsMatch(t *testing.T) { + dir := t.TempDir() + pkg := filepath.Join(dir, "package.json") + chart := filepath.Join(dir, "Chart.yaml") + os.WriteFile(pkg, []byte(`{"name": "app", "version": "1.2.3"}`), 0o644) + os.WriteFile(chart, []byte("apiVersion: v2\nname: app\nversion: 1.2.3\n"), 0o644) + + version, err := AssertVersionsMatch([]string{pkg, chart}) + if err != nil { + t.Fatalf("AssertVersionsMatch() error = %v", err) + } + if version != "1.2.3" { + t.Errorf("version = %q, want 1.2.3", version) + } + + os.WriteFile(chart, []byte("apiVersion: v2\nname: app\nversion: 1.3.0\n"), 0o644) + if _, err := AssertVersionsMatch([]string{pkg, chart}); err == nil { + t.Error("AssertVersionsMatch() should fail on mismatch") + } + + if _, err := AssertVersionsMatch([]string{pkg}); err == nil { + t.Error("AssertVersionsMatch() should require at least two files") + } +} + +func TestAnalyzeConventionalCommits(t *testing.T) { + tests := []struct { + name string + subjects []string + bodies string + want string + }{ + {"feature", []string{"feat: add pagination"}, "", "minor"}, + {"scoped feature", []string{"feat(http): add pagination"}, "", "minor"}, + {"fix only", []string{"fix: handle nil", "docs: readme"}, "", "patch"}, + {"breaking bang", []string{"feat(api)!: drop v1"}, "", "major"}, + {"breaking footer", []string{"fix: tweak"}, "BREAKING CHANGE: renamed flags", "major"}, + {"breaking hyphen footer", []string{"chore: deps"}, "BREAKING-CHANGE: removed cmd", "major"}, + {"no conventional commits", []string{"updated stuff"}, "", "none"}, + {"feat mentioned mid-subject is not a feat", []string{"docs: describe feat: syntax"}, "", "none"}, + {"bang mid-subject is not breaking", []string{"fix: handle msg ending in !: really"}, "", "patch"}, + {"perf counts as patch", []string{"perf: faster parse"}, "", "patch"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AnalyzeConventionalCommits(tt.subjects, tt.bodies); got != tt.want { + t.Errorf("AnalyzeConventionalCommits(%v) = %q, want %q", tt.subjects, got, tt.want) + } + }) + } +}