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
137 changes: 137 additions & 0 deletions .github/workflows/comment-sandbox.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Comment Sandbox

on:
workflow_dispatch:
inputs:
target_number:
description: "Issue or PR number to update when apply=true"
required: false
default: "5"
anchor:
description: "Hidden pipekit anchor to render/select/update"
required: true
default: "pipekit-comment-sandbox"
apply:
description: "Create or update the comment on the target issue/PR"
required: true
type: boolean
default: false

permissions:
contents: read
issues: write
pull-requests: read

jobs:
sandbox:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"

- name: Build pipekit
run: make build

- name: Render sandbox comment
env:
TARGET_NUMBER: ${{ inputs.target_number }}
ANCHOR: ${{ inputs.anchor }}
run: |
cat > sandbox-body.md <<EOF
## pipekit comment sandbox

This comment was rendered by the manual Comment Sandbox workflow.

- workflow: ${GITHUB_WORKFLOW}
- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
- ref: ${GITHUB_REF_NAME}
- sha: ${GITHUB_SHA}
- target: ${TARGET_NUMBER}
- anchor: ${ANCHOR}

EOF

cat > sandbox-data.yaml <<EOF
workflow: ${GITHUB_WORKFLOW}
run_id: ${GITHUB_RUN_ID}
target_number: "${TARGET_NUMBER}"
anchor: "${ANCHOR}"
ref: ${GITHUB_REF_NAME}
sha: ${GITHUB_SHA}
EOF

./dist/pipekit comment fence --language yaml sandbox-data.yaml >> sandbox-body.md
./dist/pipekit comment render \
--anchor "${ANCHOR}" \
--body-file sandbox-body.md \
--output sandbox-comment.md

- name: Inspect rendered comment
run: |
./dist/pipekit comment inspect sandbox-comment.md > sandbox-inspect.json
./dist/pipekit assert file-exists sandbox-inspect.json sandbox-comment.md
./dist/pipekit parse extract-block sandbox-comment.md --language yaml --index 0 --content-only \
> sandbox-block.yaml
test -s sandbox-block.yaml

- name: Exercise select and amend locally
env:
ANCHOR: ${{ inputs.anchor }}
run: |
cat > comments.json <<EOF
[
{"id": 1001, "html_url": "https://example.test/1001", "user": {"login": "someone"}, "body": "plain comment"},
{"id": 1002, "html_url": "https://example.test/1002", "user": {"login": "github-actions[bot]"}, "body": ""}
]
EOF
./dist/pipekit json set comments.json \
--path ".1.body" \
--value "$(cat sandbox-comment.md)" \
--in-place

./dist/pipekit comment select comments.json --anchor "${ANCHOR}" --format id > selected-id.txt
test "$(cat selected-id.txt)" = "1002"

./dist/pipekit comment select comments.json --anchor "${ANCHOR}" --format body > selected-body.md
printf '## amended sandbox body\n\nupdated locally\n' > amended-body.md
./dist/pipekit comment amend selected-body.md \
--anchor "${ANCHOR}" \
--body-file amended-body.md \
--output amended-comment.md
./dist/pipekit comment inspect amended-comment.md > amended-inspect.json

- name: Upsert sandbox issue or PR comment
if: ${{ inputs.apply }}
env:
GH_TOKEN: ${{ github.token }}
TARGET_NUMBER: ${{ inputs.target_number }}
ANCHOR: ${{ inputs.anchor }}
run: |
if [ -z "${TARGET_NUMBER}" ]; then
echo "target_number is required when apply=true" >&2
exit 1
fi

gh api "repos/${GITHUB_REPOSITORY}/issues/${TARGET_NUMBER}/comments" > remote-comments.json

./dist/pipekit comment payload sandbox-comment.md --output comment-payload.json

if ./dist/pipekit comment select remote-comments.json --anchor "${ANCHOR}" --format id > remote-comment-id.txt; then
gh api \
--method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/$(cat remote-comment-id.txt)" \
--input comment-payload.json
else
gh api \
--method POST \
"repos/${GITHUB_REPOSITORY}/issues/${TARGET_NUMBER}/comments" \
--input comment-payload.json
fi
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# pipekit

<div align="center">
<img src="docs/assets/pipekit-wordmark.png" alt="Pipekit — CI/CD pipeline toolkit" width="520">
<p>
<img src="https://img.shields.io/badge/Go-1.24%2B-00ADD8?style=flat-square&logo=go" alt="Go Version">
<img src="https://img.shields.io/badge/OS-Linux%20%7C%20macOS%20%7C%20Windows-darkblue?style=flat-square&logo=windows" alt="OS Support">
Expand Down Expand Up @@ -84,6 +83,7 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)**
| `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) |
| `exec` | Unified retry + mask + tee + timeout command runner | [↗](docs/COMMANDS.md#exec) |
Expand Down
208 changes: 208 additions & 0 deletions actions/comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package actions

import (
"encoding/json"
"fmt"
"io"
"os"

"github.com/AxeForging/pipekit/services"

"github.com/urfave/cli"
)

// CommentCommand returns the markdown comment command group.
func CommentCommand() cli.Command {
return cli.Command{
Name: "comment",
Usage: "render, inspect, and amend anchored markdown comments",
Subcommands: []cli.Command{
{
Name: "anchor",
Usage: "print a hidden pipekit anchor marker",
Action: func(c *cli.Context) error {
name, err := firstArgOrErr(c, "anchor name")
if err != nil {
return err
}
marker, err := services.AnchorMarker(name)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
fmt.Println(marker)
return nil
},
},
{
Name: "fence",
Usage: "render stdin or a file as a fenced markdown code block",
Flags: []cli.Flag{
cli.StringFlag{Name: "language, l", Usage: "code fence language tag"},
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
},
Action: func(c *cli.Context) error {
body, err := readInputFileOrStdin(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
return writeCommentOutput(c, services.RenderCodeFence(c.String("language"), string(body)))
},
},
{
Name: "render",
Usage: "render a markdown comment body with a hidden anchor",
Flags: []cli.Flag{
cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true},
cli.StringFlag{Name: "body-file", Usage: "read visible markdown body from file"},
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
},
Action: func(c *cli.Context) error {
body, err := readCommentBody(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
out, err := services.RenderAnchoredComment(c.String("anchor"), body)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
return writeCommentOutput(c, out)
},
},
{
Name: "payload",
Usage: "render stdin or a file as a GitHub comment API payload",
Flags: []cli.Flag{
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
},
Action: func(c *cli.Context) error {
body, err := readInputFileOrStdin(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
out, err := services.GitHubCommentPayload(string(body))
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
return writeCommentOutput(c, out+"\n")
},
},
{
Name: "amend",
Usage: "replace the visible body after a hidden anchor",
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},
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
},
Action: func(c *cli.Context) error {
existing, err := readInputFileOrStdin(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)
}
return writeCommentOutput(c, out)
},
},
{
Name: "inspect",
Usage: "inspect anchors and fenced blocks in markdown or GitHub comments JSON",
Action: func(c *cli.Context) error {
r, err := readerFromArgOrStdin(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
defer r.Close()
comments, err := services.InspectComments(r)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
return encodeCommentJSON(comments)
},
},
{
Name: "select",
Usage: "select the first GitHub comment JSON item containing an anchor",
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"},
},
Action: func(c *cli.Context) error {
r, err := readerFromArgOrStdin(c)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
defer r.Close()
comments, err := services.InspectComments(r)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
comment, ok := services.SelectAnchoredComment(comments, c.String("anchor"))
if !ok {
return cli.NewExitError("no matching anchored comment found", 1)
}
switch c.String("format") {
case "json", "":
return encodeCommentJSON(comment)
case "id":
fmt.Println(comment.ID)
case "body":
fmt.Print(comment.Body)
case "url":
fmt.Println(comment.URL)
default:
return cli.NewExitError("unsupported format: use json, id, body, or url", 1)
}
return nil
},
},
},
}
}

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
}
data, err := readBytesFromArgOrStdin(c)
if err != nil {
return "", err
}
return string(data), nil
}

func readInputFileOrStdin(c *cli.Context) ([]byte, error) {
r, err := readerFromArgOrStdin(c)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}

func writeCommentOutput(c *cli.Context, content string) error {
if path := c.String("output"); path != "" {
return os.WriteFile(path, []byte(content), 0644)
}
fmt.Print(content)
return nil
}

func encodeCommentJSON(v interface{}) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
Loading
Loading