Skip to content

Commit 427c5a1

Browse files
authored
Merge pull request #4 from AxeForging/feature/anchored-pr-comments
Add anchored markdown comment utilities
2 parents edda6dd + 55dac7d commit 427c5a1

12 files changed

Lines changed: 1067 additions & 37 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
name: Comment Sandbox
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
target_number:
7+
description: "Issue or PR number to update when apply=true"
8+
required: false
9+
default: "5"
10+
anchor:
11+
description: "Hidden pipekit anchor to render/select/update"
12+
required: true
13+
default: "pipekit-comment-sandbox"
14+
apply:
15+
description: "Create or update the comment on the target issue/PR"
16+
required: true
17+
type: boolean
18+
default: false
19+
20+
permissions:
21+
contents: read
22+
issues: write
23+
pull-requests: read
24+
25+
jobs:
26+
sandbox:
27+
runs-on: ubuntu-latest
28+
29+
steps:
30+
- name: Checkout
31+
uses: actions/checkout@v4
32+
with:
33+
fetch-depth: 0
34+
35+
- name: Set up Go
36+
uses: actions/setup-go@v5
37+
with:
38+
go-version: "1.24"
39+
40+
- name: Build pipekit
41+
run: make build
42+
43+
- name: Render sandbox comment
44+
env:
45+
TARGET_NUMBER: ${{ inputs.target_number }}
46+
ANCHOR: ${{ inputs.anchor }}
47+
run: |
48+
cat > sandbox-body.md <<EOF
49+
## pipekit comment sandbox
50+
51+
This comment was rendered by the manual Comment Sandbox workflow.
52+
53+
- workflow: ${GITHUB_WORKFLOW}
54+
- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
55+
- ref: ${GITHUB_REF_NAME}
56+
- sha: ${GITHUB_SHA}
57+
- target: ${TARGET_NUMBER}
58+
- anchor: ${ANCHOR}
59+
60+
EOF
61+
62+
cat > sandbox-data.yaml <<EOF
63+
workflow: ${GITHUB_WORKFLOW}
64+
run_id: ${GITHUB_RUN_ID}
65+
target_number: "${TARGET_NUMBER}"
66+
anchor: "${ANCHOR}"
67+
ref: ${GITHUB_REF_NAME}
68+
sha: ${GITHUB_SHA}
69+
EOF
70+
71+
./dist/pipekit comment fence --language yaml sandbox-data.yaml >> sandbox-body.md
72+
./dist/pipekit comment render \
73+
--anchor "${ANCHOR}" \
74+
--body-file sandbox-body.md \
75+
--output sandbox-comment.md
76+
77+
- name: Inspect rendered comment
78+
run: |
79+
./dist/pipekit comment inspect sandbox-comment.md > sandbox-inspect.json
80+
./dist/pipekit assert file-exists sandbox-inspect.json sandbox-comment.md
81+
./dist/pipekit parse extract-block sandbox-comment.md --language yaml --index 0 --content-only \
82+
> sandbox-block.yaml
83+
test -s sandbox-block.yaml
84+
85+
- name: Exercise select and amend locally
86+
env:
87+
ANCHOR: ${{ inputs.anchor }}
88+
run: |
89+
cat > comments.json <<EOF
90+
[
91+
{"id": 1001, "html_url": "https://example.test/1001", "user": {"login": "someone"}, "body": "plain comment"},
92+
{"id": 1002, "html_url": "https://example.test/1002", "user": {"login": "github-actions[bot]"}, "body": ""}
93+
]
94+
EOF
95+
./dist/pipekit json set comments.json \
96+
--path ".1.body" \
97+
--value "$(cat sandbox-comment.md)" \
98+
--in-place
99+
100+
./dist/pipekit comment select comments.json --anchor "${ANCHOR}" --format id > selected-id.txt
101+
test "$(cat selected-id.txt)" = "1002"
102+
103+
./dist/pipekit comment select comments.json --anchor "${ANCHOR}" --format body > selected-body.md
104+
printf '## amended sandbox body\n\nupdated locally\n' > amended-body.md
105+
./dist/pipekit comment amend selected-body.md \
106+
--anchor "${ANCHOR}" \
107+
--body-file amended-body.md \
108+
--output amended-comment.md
109+
./dist/pipekit comment inspect amended-comment.md > amended-inspect.json
110+
111+
- name: Upsert sandbox issue or PR comment
112+
if: ${{ inputs.apply }}
113+
env:
114+
GH_TOKEN: ${{ github.token }}
115+
TARGET_NUMBER: ${{ inputs.target_number }}
116+
ANCHOR: ${{ inputs.anchor }}
117+
run: |
118+
if [ -z "${TARGET_NUMBER}" ]; then
119+
echo "target_number is required when apply=true" >&2
120+
exit 1
121+
fi
122+
123+
gh api "repos/${GITHUB_REPOSITORY}/issues/${TARGET_NUMBER}/comments" > remote-comments.json
124+
125+
./dist/pipekit comment payload sandbox-comment.md --output comment-payload.json
126+
127+
if ./dist/pipekit comment select remote-comments.json --anchor "${ANCHOR}" --format id > remote-comment-id.txt; then
128+
gh api \
129+
--method PATCH \
130+
"repos/${GITHUB_REPOSITORY}/issues/comments/$(cat remote-comment-id.txt)" \
131+
--input comment-payload.json
132+
else
133+
gh api \
134+
--method POST \
135+
"repos/${GITHUB_REPOSITORY}/issues/${TARGET_NUMBER}/comments" \
136+
--input comment-payload.json
137+
fi

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
# pipekit
2-
31
<div align="center">
2+
<img src="docs/assets/pipekit-wordmark.png" alt="Pipekit — CI/CD pipeline toolkit" width="520">
43
<p>
54
<img src="https://img.shields.io/badge/Go-1.24%2B-00ADD8?style=flat-square&logo=go" alt="Go Version">
65
<img src="https://img.shields.io/badge/OS-Linux%20%7C%20macOS%20%7C%20Windows-darkblue?style=flat-square&logo=windows" alt="OS Support">
@@ -84,6 +83,7 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)**
8483
| `changelog` | Generate release notes from git commit ranges | [](docs/COMMANDS.md#changelog) |
8584
| `config` | Resolve env-specific config maps; map branches to environments | [](docs/COMMANDS.md#config) |
8685
| `parse` | Pull fenced code blocks / YAML / frontmatter out of issue bodies, PR comments, markdown | [](docs/COMMANDS.md#parse) |
86+
| `comment` | Render, inspect, select, and amend hidden-anchor PR comments | [](docs/COMMANDS.md#comment) |
8787
| `json` / `yaml` | Get / set / del / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV | [](docs/COMMANDS.md#json) |
8888
| `render` | Render Go templates with a sprig-like FuncMap and stacked `--values` files | [](docs/COMMANDS.md#render) |
8989
| `exec` | Unified retry + mask + tee + timeout command runner | [](docs/COMMANDS.md#exec) |

actions/comment.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package actions
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
9+
"github.com/AxeForging/pipekit/services"
10+
11+
"github.com/urfave/cli"
12+
)
13+
14+
// CommentCommand returns the markdown comment command group.
15+
func CommentCommand() cli.Command {
16+
return cli.Command{
17+
Name: "comment",
18+
Usage: "render, inspect, and amend anchored markdown comments",
19+
Subcommands: []cli.Command{
20+
{
21+
Name: "anchor",
22+
Usage: "print a hidden pipekit anchor marker",
23+
Action: func(c *cli.Context) error {
24+
name, err := firstArgOrErr(c, "anchor name")
25+
if err != nil {
26+
return err
27+
}
28+
marker, err := services.AnchorMarker(name)
29+
if err != nil {
30+
return cli.NewExitError(err.Error(), 1)
31+
}
32+
fmt.Println(marker)
33+
return nil
34+
},
35+
},
36+
{
37+
Name: "fence",
38+
Usage: "render stdin or a file as a fenced markdown code block",
39+
Flags: []cli.Flag{
40+
cli.StringFlag{Name: "language, l", Usage: "code fence language tag"},
41+
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
42+
},
43+
Action: func(c *cli.Context) error {
44+
body, err := readInputFileOrStdin(c)
45+
if err != nil {
46+
return cli.NewExitError(err.Error(), 1)
47+
}
48+
return writeCommentOutput(c, services.RenderCodeFence(c.String("language"), string(body)))
49+
},
50+
},
51+
{
52+
Name: "render",
53+
Usage: "render a markdown comment body with a hidden anchor",
54+
Flags: []cli.Flag{
55+
cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true},
56+
cli.StringFlag{Name: "body-file", Usage: "read visible markdown body from file"},
57+
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
58+
},
59+
Action: func(c *cli.Context) error {
60+
body, err := readCommentBody(c)
61+
if err != nil {
62+
return cli.NewExitError(err.Error(), 1)
63+
}
64+
out, err := services.RenderAnchoredComment(c.String("anchor"), body)
65+
if err != nil {
66+
return cli.NewExitError(err.Error(), 1)
67+
}
68+
return writeCommentOutput(c, out)
69+
},
70+
},
71+
{
72+
Name: "payload",
73+
Usage: "render stdin or a file as a GitHub comment API payload",
74+
Flags: []cli.Flag{
75+
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
76+
},
77+
Action: func(c *cli.Context) error {
78+
body, err := readInputFileOrStdin(c)
79+
if err != nil {
80+
return cli.NewExitError(err.Error(), 1)
81+
}
82+
out, err := services.GitHubCommentPayload(string(body))
83+
if err != nil {
84+
return cli.NewExitError(err.Error(), 1)
85+
}
86+
return writeCommentOutput(c, out+"\n")
87+
},
88+
},
89+
{
90+
Name: "amend",
91+
Usage: "replace the visible body after a hidden anchor",
92+
Flags: []cli.Flag{
93+
cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true},
94+
cli.StringFlag{Name: "body-file", Usage: "read replacement markdown body from file", Required: true},
95+
cli.StringFlag{Name: "output, o", Usage: "write output to this file"},
96+
},
97+
Action: func(c *cli.Context) error {
98+
existing, err := readInputFileOrStdin(c)
99+
if err != nil {
100+
return cli.NewExitError(err.Error(), 1)
101+
}
102+
body, err := os.ReadFile(c.String("body-file"))
103+
if err != nil {
104+
return cli.NewExitError(fmt.Sprintf("reading body file: %v", err), 1)
105+
}
106+
out, err := services.AmendAnchoredComment(string(existing), c.String("anchor"), string(body))
107+
if err != nil {
108+
return cli.NewExitError(err.Error(), 1)
109+
}
110+
return writeCommentOutput(c, out)
111+
},
112+
},
113+
{
114+
Name: "inspect",
115+
Usage: "inspect anchors and fenced blocks in markdown or GitHub comments JSON",
116+
Action: func(c *cli.Context) error {
117+
r, err := readerFromArgOrStdin(c)
118+
if err != nil {
119+
return cli.NewExitError(err.Error(), 1)
120+
}
121+
defer r.Close()
122+
comments, err := services.InspectComments(r)
123+
if err != nil {
124+
return cli.NewExitError(err.Error(), 1)
125+
}
126+
return encodeCommentJSON(comments)
127+
},
128+
},
129+
{
130+
Name: "select",
131+
Usage: "select the first GitHub comment JSON item containing an anchor",
132+
Flags: []cli.Flag{
133+
cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true},
134+
cli.StringFlag{Name: "format, f", Value: "json", Usage: "output format: json, id, body, url"},
135+
},
136+
Action: func(c *cli.Context) error {
137+
r, err := readerFromArgOrStdin(c)
138+
if err != nil {
139+
return cli.NewExitError(err.Error(), 1)
140+
}
141+
defer r.Close()
142+
comments, err := services.InspectComments(r)
143+
if err != nil {
144+
return cli.NewExitError(err.Error(), 1)
145+
}
146+
comment, ok := services.SelectAnchoredComment(comments, c.String("anchor"))
147+
if !ok {
148+
return cli.NewExitError("no matching anchored comment found", 1)
149+
}
150+
switch c.String("format") {
151+
case "json", "":
152+
return encodeCommentJSON(comment)
153+
case "id":
154+
fmt.Println(comment.ID)
155+
case "body":
156+
fmt.Print(comment.Body)
157+
case "url":
158+
fmt.Println(comment.URL)
159+
default:
160+
return cli.NewExitError("unsupported format: use json, id, body, or url", 1)
161+
}
162+
return nil
163+
},
164+
},
165+
},
166+
}
167+
}
168+
169+
func readCommentBody(c *cli.Context) (string, error) {
170+
if path := c.String("body-file"); path != "" {
171+
data, err := os.ReadFile(path)
172+
if err != nil {
173+
return "", fmt.Errorf("reading body file: %w", err)
174+
}
175+
return string(data), nil
176+
}
177+
data, err := readBytesFromArgOrStdin(c)
178+
if err != nil {
179+
return "", err
180+
}
181+
return string(data), nil
182+
}
183+
184+
func readInputFileOrStdin(c *cli.Context) ([]byte, error) {
185+
r, err := readerFromArgOrStdin(c)
186+
if err != nil {
187+
return nil, err
188+
}
189+
defer r.Close()
190+
return io.ReadAll(r)
191+
}
192+
193+
func writeCommentOutput(c *cli.Context, content string) error {
194+
if path := c.String("output"); path != "" {
195+
return os.WriteFile(path, []byte(content), 0644)
196+
}
197+
fmt.Print(content)
198+
return nil
199+
}
200+
201+
func encodeCommentJSON(v interface{}) error {
202+
data, err := json.Marshal(v)
203+
if err != nil {
204+
return err
205+
}
206+
fmt.Println(string(data))
207+
return nil
208+
}

0 commit comments

Comments
 (0)