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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.12'
go-version: '1.25.13'

- name: Build candidate and install external quality gates
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.12'
go-version: '1.25.13'
cache: true

- name: Install govulncheck
Expand Down
2 changes: 2 additions & 0 deletions actions/annotate.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit annotate

package actions

import (
Expand Down
2 changes: 2 additions & 0 deletions actions/archive.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit archive

package actions

import (
Expand Down
2 changes: 2 additions & 0 deletions actions/artifact.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit artifact

package actions

import (
Expand Down
24 changes: 17 additions & 7 deletions actions/assert.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit assert

package actions

import (
Expand Down Expand Up @@ -49,19 +51,27 @@ func AssertCommand() cli.Command {
},
},
{
Name: "json-path",
Usage: "assert a value at a JSON path matches expectation",
Name: "json-path",
Usage: "assert a value at a JSON path matches expectation (stdin, FILE, or --file)",
ArgsUsage: "[FILE]",
Flags: []cli.Flag{
cli.StringFlag{Name: "file", Usage: "JSON file to check"},
cli.StringFlag{Name: "file", Usage: "JSON file to check (default: positional FILE, else stdin)"},
cli.StringFlag{Name: "path", Usage: "jq-style path expression", Required: true},
cli.StringFlag{Name: "expected", Usage: "expected value", Required: true},
},
Action: func(c *cli.Context) error {
filePath := c.String("file")
if filePath == "" {
return cli.NewExitError("--file is required", 1)
// --file stays supported and still wins; without it the
// input follows the same positional-FILE-or-stdin
// convention as every other input-taking command.
var (
data []byte
err error
)
if filePath := c.String("file"); filePath != "" {
data, err = os.ReadFile(filePath)
} else {
data, err = readAllInput(c)
}
data, err := os.ReadFile(filePath)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
Expand Down
5 changes: 5 additions & 0 deletions actions/cache_key.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// CLI: pipekit cache-key
//
// The command is hyphenated, the filename is not: guessing the CLI
// name from this filename gives `cache_key`, which does not exist.

package actions

import (
Expand Down
2 changes: 2 additions & 0 deletions actions/changelog.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit changelog

package actions

import (
Expand Down
2 changes: 2 additions & 0 deletions actions/checksum.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit checksum

package actions

import (
Expand Down
156 changes: 134 additions & 22 deletions actions/comment.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit comment

package actions

import (
Expand All @@ -11,11 +13,47 @@ import (
"github.com/urfave/cli"
)

// commentGroupDescription is the group-level `pipekit comment --help` body.
// The real flags live one subcommand down, so without this you cannot learn
// the interface from the top of the tree.
const commentGroupDescription = `Input convention: every subcommand that takes a markdown body reads it from
stdin by default, and --body-file PATH overrides that. ` + "`amend`" + ` takes two
inputs (the existing comment and the new body), so whichever one
--body-file does not supply is the one read from stdin.

Synopsis (the flags each subcommand actually takes):

anchor NAME print the hidden marker for NAME
fence [FILE] wrap input in a fenced block (--language, --body-file)
render --anchor NAME body + hidden anchor (--body-file, stdin, or a BODY argument)
payload [FILE] wrap input as {"body": ...} for the GitHub comments API
amend --anchor NAME replace the visible body under an existing anchor
inspect [FILE] list anchors and fenced blocks in markdown or comments JSON
select --anchor NAME pick the comment carrying NAME; EXITS 1 when absent

The sticky comment round-trip. ` + "`select`" + ` exits 1 when the anchor is not
present, and that exit code is the create-vs-update branch:

comments=$(gh api repos/$REPO/issues/$PR/comments)

if id=$(printf '%s' "$comments" | pipekit comment select --anchor ci --format id); then
printf '%s' "$comments" \
| pipekit comment select --anchor ci --format body \
| pipekit comment amend --anchor ci --body-file report.md \
| pipekit comment payload \
| gh api --method PATCH repos/$REPO/issues/comments/$id --input -
else
pipekit comment render --anchor ci --body-file report.md \
| pipekit comment payload \
| gh api --method POST repos/$REPO/issues/$PR/comments --input -
fi`

// CommentCommand returns the markdown comment command group.
func CommentCommand() cli.Command {
return cli.Command{
Name: "comment",
Usage: "render, inspect, and amend anchored markdown comments",
Name: "comment",
Usage: "render, inspect, and amend anchored markdown comments",
Description: commentGroupDescription,
Subcommands: []cli.Command{
{
Name: "anchor",
Expand All @@ -35,13 +73,14 @@ func CommentCommand() cli.Command {
},
{
Name: "fence",
Usage: "render stdin or a file as a fenced markdown code block",
Usage: "render stdin, --body-file, or a file as a fenced markdown code block",
Flags: []cli.Flag{
cli.StringFlag{Name: "language, l", Usage: "code fence language tag"},
bodyFileFlag("read the body from this file instead of stdin"),
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
},
Action: func(c *cli.Context) error {
body, err := readInputFileOrStdin(c)
body, err := readBodyFileOrInput(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
Expand Down Expand Up @@ -70,12 +109,13 @@ func CommentCommand() cli.Command {
},
{
Name: "payload",
Usage: "render stdin or a file as a GitHub comment API payload",
Usage: "render stdin, --body-file, or a file as a GitHub comment API payload",
Flags: []cli.Flag{
bodyFileFlag("read the body from this file instead of stdin"),
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
},
Action: func(c *cli.Context) error {
body, err := readInputFileOrStdin(c)
body, err := readBodyFileOrInput(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
Expand All @@ -87,22 +127,23 @@ func CommentCommand() cli.Command {
},
},
{
Name: "amend",
Usage: "replace the visible body after a hidden anchor",
Name: "amend",
Usage: "replace the visible body after a hidden anchor",
ArgsUsage: "[EXISTING_COMMENT_FILE]",
Description: `amend needs two inputs. --body-file supplies the new body and the
existing comment comes from the positional FILE or stdin; drop
--body-file and it inverts — the existing comment must then be the
positional FILE, leaving stdin to carry the new body.`,
Flags: []cli.Flag{
cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true},
cli.StringFlag{Name: "body-file", Usage: "read replacement markdown body from file", Required: true},
bodyFileFlag("read the replacement body from this file instead of stdin"),
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
},
Action: func(c *cli.Context) error {
existing, err := readInputFileOrStdin(c)
existing, body, err := readAmendInputs(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
body, err := os.ReadFile(c.String("body-file"))
if err != nil {
return cli.NewExitError(fmt.Sprintf("reading body file: %v", err), 1)
}
out, err := services.AmendAnchoredComment(string(existing), c.String("anchor"), string(body))
if err != nil {
return cli.NewExitError(err.Error(), 1)
Expand All @@ -127,8 +168,13 @@ func CommentCommand() cli.Command {
},
},
{
Name: "select",
Usage: "select the first GitHub comment JSON item containing an anchor",
Name: "select",
Usage: "select the first GitHub comment JSON item containing an anchor (exit 1 if absent)",
ArgsUsage: "[COMMENTS_JSON_FILE]",
Description: `Exits 0 and prints the match, or exits 1 when no comment carries the
anchor. That exit code is the create-vs-update branch of a sticky
comment: success means PATCH an existing comment, failure means POST a
new one.`,
Flags: []cli.Flag{
cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true},
cli.StringFlag{Name: "format, f", Value: "json", Usage: "output format: json, id, body, url"},
Expand Down Expand Up @@ -166,13 +212,42 @@ func CommentCommand() cli.Command {
}
}

// bodyFileFlag declares --body-file with a per-subcommand usage string. Every
// body-taking `comment` subcommand carries it, so that one convention —
// "stdin by default, --body-file overrides" — holds across the whole group.
func bodyFileFlag(usage string) cli.StringFlag {
return cli.StringFlag{Name: "body-file", Usage: usage}
}

// bodyFileContents reads --body-file. The bool reports whether the flag was
// set; when it is not, callers fall back to their own established
// positional/stdin behaviour, which differs per subcommand and must not change.
func bodyFileContents(c *cli.Context) ([]byte, bool, error) {
path := c.String("body-file")
if path == "" {
return nil, false, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, true, fmt.Errorf("reading body file: %w", err)
}
return data, true, nil
}

// readBodyFileOrInput is the fence/payload convention: --body-file wins,
// otherwise the positional FILE, otherwise stdin.
func readBodyFileOrInput(c *cli.Context) ([]byte, error) {
if data, set, err := bodyFileContents(c); set {
return data, err
}
return readInputFileOrStdin(c)
}

// readCommentBody is the render convention: --body-file wins, otherwise the
// positional argument is the body TEXT itself (not a path), otherwise stdin.
func readCommentBody(c *cli.Context) (string, error) {
if path := c.String("body-file"); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading body file: %w", err)
}
return string(data), nil
if data, set, err := bodyFileContents(c); set {
return string(data), err
}
data, err := readBytesFromArgOrStdin(c)
if err != nil {
Expand All @@ -181,6 +256,43 @@ func readCommentBody(c *cli.Context) (string, error) {
return string(data), nil
}

// readAmendInputs resolves amend's two inputs. --body-file supplies the body
// and the existing comment comes from the positional FILE or stdin (the
// pre-existing behaviour); without --body-file the roles invert so that stdin
// is free to carry the body.
func readAmendInputs(c *cli.Context) (existing, body []byte, err error) {
if data, set, ferr := bodyFileContents(c); set {
if ferr != nil {
return nil, nil, ferr
}
existing, err = readInputFileOrStdin(c)
if err != nil {
return nil, nil, err
}
return existing, data, nil
}

path := c.Args().First()
if path == "" {
return nil, nil, fmt.Errorf(
"amend needs two inputs: either pass --body-file PATH with the existing comment on stdin, " +
"or pass the existing comment as a positional FILE with the new body on stdin")
}
existing, err = os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("reading %s: %w", path, err)
}
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) != 0 {
return nil, nil, fmt.Errorf("no replacement body: pass --body-file PATH or pipe the new body on stdin")
}
body, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, nil, err
}
return existing, body, nil
}

func readInputFileOrStdin(c *cli.Context) ([]byte, error) {
r, err := readerFromArgOrStdin(c)
if err != nil {
Expand Down
38 changes: 38 additions & 0 deletions actions/common.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// CLI: none
//
// Shared helpers for the action handlers. Declares no CLI command.

package actions

import (
Expand All @@ -11,6 +15,40 @@ import (
"github.com/urfave/cli"
)

// GroupHelpTemplate is urfave/cli v1's SubcommandHelpTemplate with two
// changes, so that a command group can document itself.
//
// The stock template renders `{{if .Description}}{{.Description}}{{else}}
// {{.Usage}}{{end}}` on the NAME line, so giving a group a Description
// silently *replaces* its one-line summary with the whole block. This keeps
// the summary on NAME and gives Description its own section, placed last so
// the subcommand list stays near the top.
//
// A group's own CustomHelpTemplate cannot do this: ShowCommandHelp takes the
// `command == ""` branch for `pipekit <group> --help` and hardcodes
// SubcommandHelpTemplate. Overriding that package variable (which the library
// documents as the customisation point) is the only hook. Groups with no
// Description render exactly as they did before. Leaf commands are unaffected
// — CommandHelpTemplate already has a DESCRIPTION section.
const GroupHelpTemplate = `NAME:
{{.HelpName}} - {{.Usage}}

USAGE:
{{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}} command{{if .VisibleFlags}} [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}

COMMANDS:{{range .VisibleCategories}}{{if .Name}}

{{.Name}}:{{range .VisibleCommands}}
{{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{else}}{{range .VisibleCommands}}
{{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}}{{end}}{{if .VisibleFlags}}

OPTIONS:
{{range .VisibleFlags}}{{.}}
{{end}}{{end}}{{if .Description}}
DESCRIPTION:
{{.Description}}
{{end}}`

// firstArgOrErr returns the first positional argument or a CLI exit error
// using the given argument name in the message.
func firstArgOrErr(c *cli.Context, name string) (string, error) {
Expand Down
2 changes: 2 additions & 0 deletions actions/config.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit config

package actions

import (
Expand Down
2 changes: 2 additions & 0 deletions actions/diff.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit diff

package actions

import (
Expand Down
2 changes: 2 additions & 0 deletions actions/doctor.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// CLI: pipekit doctor

package actions

import (
Expand Down
Loading
Loading