Skip to content

Commit 88d7d87

Browse files
feat(cli): add gen-bump command
Supports --all/--name, --bump and --set flags and syncs generator docs' Version row Update dep-checker workflow to post filtered dependency reports to PRs (with job-level permissions and upsert comment). Change AppendStringSet to preserve insertion order instead of sorting. Make dep-checker patch a no-op when normalized versions are equal
1 parent 40ebfc0 commit 88d7d87

5 files changed

Lines changed: 325 additions & 8 deletions

File tree

.github/workflows/dep-checker.yml

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@ on:
99
- cron: "0 9 * * 3"
1010
workflow_dispatch:
1111

12-
# PRs only need read access; schedule/dispatch need write to create branches,
13-
# PRs, and issues. Scoped at job level below.
12+
# PRs need read + pull-requests write (to post/update the dep comment).
13+
# schedule/dispatch need write to create branches, PRs, and issues.
14+
# Scoped at job level below.
1415
permissions:
1516
contents: read
1617

1718
jobs:
1819
# Runs on every PR and every scheduled/manual trigger.
19-
# On PRs: shows the report as a step summary (informational only, never blocks).
20+
# On PRs: shows the report as a step summary, comments filtered results on the PR.
2021
# On schedule/dispatch: feeds the report to the process job which opens PRs.
2122
scan:
2223
name: Scan template dependencies
2324
runs-on: ubuntu-latest
25+
permissions:
26+
contents: read
27+
pull-requests: write
2428
steps:
2529
- uses: actions/checkout@v4
2630

@@ -38,13 +42,78 @@ jobs:
3842
- name: Show scan summary
3943
run: |
4044
./bin/dep-checker report --input=dep-report.json --output=dep-report.md
45+
cat dep-report.md
4146
cat dep-report.md >> $GITHUB_STEP_SUMMARY
4247
48+
- name: Filter report to PR-changed generators
49+
if: github.event_name == 'pull_request'
50+
env:
51+
GH_TOKEN: ${{ github.token }}
52+
PR_NUMBER: ${{ github.event.pull_request.number }}
53+
run: |
54+
set -euo pipefail
55+
56+
# Generator directories touched by this PR.
57+
# Use --paginate + per_page=100 so large PRs (200+ files) are fully covered.
58+
# --jq extracts one generator name per line per page; sort -u deduplicates
59+
# across pages; jq -R/jq -s converts the text list to a JSON array.
60+
CHANGED_GENS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
61+
--paginate \
62+
--jq '[.[] | select(.filename | startswith("generators/")) | .filename | split("/")[1]] | .[]' \
63+
| sort -u \
64+
| jq -R . | jq -s .)
65+
66+
echo "Changed generators: $CHANGED_GENS"
67+
68+
if [ "$CHANGED_GENS" = "[]" ]; then
69+
echo "No generator files changed in this PR — producing empty filtered report."
70+
jq '. + {entries: []}' dep-report.json > pr-dep-report.json
71+
else
72+
jq --argjson gens "$CHANGED_GENS" \
73+
'. + {entries: [.entries[] | select(.generator as $g | ($gens | index($g)) != null)]}' \
74+
dep-report.json > pr-dep-report.json
75+
echo "Filtered to $(jq '.entries | length' pr-dep-report.json) entries from generators: $CHANGED_GENS"
76+
fi
77+
78+
- name: Comment PR with dependency changes
79+
if: github.event_name == 'pull_request'
80+
env:
81+
GH_TOKEN: ${{ github.token }}
82+
PR_NUMBER: ${{ github.event.pull_request.number }}
83+
run: |
84+
set -euo pipefail
85+
86+
ENTRY_COUNT=$(jq '.entries | length' pr-dep-report.json)
87+
if [ "$ENTRY_COUNT" -eq 0 ]; then
88+
echo "No tracked dependencies in the changed generators — skipping dep comment."
89+
exit 0
90+
fi
91+
92+
./bin/dep-checker report --input=pr-dep-report.json --output=pr-dep-report.md
93+
94+
MARKER="<!-- dep-checker-report -->"
95+
BODY="${MARKER}
96+
$(cat pr-dep-report.md)"
97+
98+
# Upsert: update existing marker comment, or create a new one.
99+
EXISTING_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
100+
--jq '[.[] | select(.body | startswith("<!-- dep-checker-report -->"))] | first | .id // empty')
101+
102+
if [ -n "$EXISTING_ID" ]; then
103+
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING_ID" \
104+
--method PATCH \
105+
--field body="$BODY"
106+
echo "Updated dep comment $EXISTING_ID on PR #$PR_NUMBER."
107+
else
108+
gh pr comment "$PR_NUMBER" --body "$BODY" --repo "$GITHUB_REPOSITORY"
109+
echo "Created dep comment on PR #$PR_NUMBER."
110+
fi
111+
43112
- name: Fail PR on major/minor updates
44113
if: github.event_name == 'pull_request'
45114
run: |
46-
if jq -e '.entries[] | select(.outdated and (.update_type == "major" or .update_type == "minor"))' dep-report.json > /dev/null; then
47-
echo "Major/minor dependency updates detected. Failing PR check." >&2
115+
if jq -e '.entries[] | select(.outdated and (.update_type == "major" or .update_type == "minor"))' pr-dep-report.json > /dev/null; then
116+
echo "Major/minor dependency updates detected in PR-changed generators. Failing PR check." >&2
48117
exit 1
49118
fi
50119

internal/cli/command.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ func Dispatch(ctx context.Context, args []string, toolVersion string) int {
5656
case "plugin", "plugins":
5757
return runPlugin(ctx, rest)
5858

59+
case "gen-bump":
60+
return runGenBump(rest)
61+
5962
default:
6063
fmt.Fprintf(os.Stderr, "dot: unknown command %q\n\n", cmd)
6164
printUsage(os.Stderr, toolVersion)
@@ -75,6 +78,8 @@ func printUsage(w io.Writer, version string) {
7578
fmt.Fprintln(w, " dot plugin <list|install|uninstall> Manage installable plugins")
7679
fmt.Fprintln(w, " dot flows List available flows")
7780
fmt.Fprintln(w, " dot generators List registered generators")
81+
fmt.Fprintln(w, " dot gen-bump --all [--bump patch|minor|major] Bump all generator manifest versions")
82+
fmt.Fprintln(w, " dot gen-bump --name NAME [--bump patch|minor|major | --set VERSION]")
7883
fmt.Fprintln(w, " dot version Print the tool version")
7984
fmt.Fprintln(w, " dot help Show this message")
8085
fmt.Fprintln(w)

internal/cli/gen_bump.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package cli
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
var manifestVersionRe = regexp.MustCompile(`(Version:\s*)"(\d+\.\d+\.\d+)"`)
14+
var docVersionRowRe = regexp.MustCompile("(?m)^\\|\\s*Version\\s*\\|\\s*`([^`]+)`\\s*\\|\\s*$")
15+
16+
func runGenBump(args []string) int {
17+
fs := flag.NewFlagSet("gen-bump", flag.ContinueOnError)
18+
all := fs.Bool("all", false, "bump all generators")
19+
name := fs.String("name", "", "bump a specific generator by name (comma-separated for multiple)")
20+
bump := fs.String("bump", "patch", "bump type: patch, minor, or major")
21+
set := fs.String("set", "", "set an explicit version instead of bumping (e.g. 1.2.3)")
22+
if err := fs.Parse(args); err != nil {
23+
return 2
24+
}
25+
26+
if !*all && *name == "" {
27+
fmt.Fprintln(os.Stderr, "dot gen-bump: --all or --name <name> is required")
28+
fs.Usage()
29+
return 2
30+
}
31+
if *all && *name != "" {
32+
fmt.Fprintln(os.Stderr, "dot gen-bump: --all and --name are mutually exclusive")
33+
return 2
34+
}
35+
if *set != "" && !isValidVersion(*set) {
36+
fmt.Fprintf(os.Stderr, "dot gen-bump: --set %q is not a valid semver (expected MAJOR.MINOR.PATCH)\n", *set)
37+
return 2
38+
}
39+
40+
var names []string
41+
if *name != "" {
42+
for _, n := range strings.Split(*name, ",") {
43+
n = strings.TrimSpace(n)
44+
if n != "" {
45+
names = append(names, n)
46+
}
47+
}
48+
} else {
49+
found, err := listGeneratorDirs("generators")
50+
if err != nil {
51+
fmt.Fprintln(os.Stderr, "dot gen-bump:", err)
52+
return 1
53+
}
54+
names = found
55+
}
56+
57+
if len(names) == 0 {
58+
fmt.Fprintln(os.Stderr, "dot gen-bump: no generators found")
59+
return 1
60+
}
61+
62+
label := *bump
63+
if *set != "" {
64+
label = *set
65+
}
66+
PrintHeading(fmt.Sprintf("gen-bump (%s)", label))
67+
68+
anyFailed := false
69+
for _, n := range names {
70+
var err error
71+
if *set != "" {
72+
err = setGenVersion(n, *set)
73+
} else {
74+
err = bumpGenVersion(n, *bump)
75+
}
76+
if err != nil {
77+
fmt.Fprintf(os.Stderr, " error: %s: %v\n", n, err)
78+
anyFailed = true
79+
}
80+
}
81+
82+
if anyFailed {
83+
return 1
84+
}
85+
return 0
86+
}
87+
88+
func listGeneratorDirs(root string) ([]string, error) {
89+
entries, err := os.ReadDir(root)
90+
if err != nil {
91+
return nil, fmt.Errorf("read %s: %w", root, err)
92+
}
93+
var names []string
94+
for _, e := range entries {
95+
if !e.IsDir() {
96+
continue
97+
}
98+
if _, err := os.Stat(filepath.Join(root, e.Name(), "manifest.go")); err == nil {
99+
names = append(names, e.Name())
100+
}
101+
}
102+
return names, nil
103+
}
104+
105+
func bumpGenVersion(name, bumpType string) error {
106+
manifestPath := filepath.Join("generators", name, "manifest.go")
107+
content, err := os.ReadFile(manifestPath)
108+
if err != nil {
109+
return fmt.Errorf("read %s: %w", manifestPath, err)
110+
}
111+
112+
var oldVer, newVer string
113+
var bumpErr error
114+
updated := manifestVersionRe.ReplaceAllStringFunc(string(content), func(match string) string {
115+
if bumpErr != nil {
116+
return match
117+
}
118+
sub := manifestVersionRe.FindStringSubmatch(match)
119+
if sub == nil {
120+
return match
121+
}
122+
oldVer = sub[2]
123+
var bumped string
124+
switch bumpType {
125+
case "major":
126+
bumped, bumpErr = bumpMajorVer(sub[2])
127+
case "minor":
128+
bumped, bumpErr = bumpMinorVer(sub[2])
129+
default:
130+
bumped, bumpErr = bumpPatchVer(sub[2])
131+
}
132+
if bumpErr != nil {
133+
return match
134+
}
135+
newVer = bumped
136+
return sub[1] + `"` + bumped + `"`
137+
})
138+
if bumpErr != nil {
139+
return bumpErr
140+
}
141+
if newVer == "" {
142+
return fmt.Errorf("version field not found in %s", manifestPath)
143+
}
144+
fmt.Printf(" %-40s %s → %s\n", name, oldVer, newVer)
145+
if err := os.WriteFile(manifestPath, []byte(updated), 0644); err != nil {
146+
return err
147+
}
148+
syncGenDocVersion(name, newVer)
149+
return nil
150+
}
151+
152+
func setGenVersion(name, version string) error {
153+
manifestPath := filepath.Join("generators", name, "manifest.go")
154+
content, err := os.ReadFile(manifestPath)
155+
if err != nil {
156+
return fmt.Errorf("read %s: %w", manifestPath, err)
157+
}
158+
159+
var oldVer string
160+
updated := manifestVersionRe.ReplaceAllStringFunc(string(content), func(match string) string {
161+
sub := manifestVersionRe.FindStringSubmatch(match)
162+
if sub == nil {
163+
return match
164+
}
165+
oldVer = sub[2]
166+
return sub[1] + `"` + version + `"`
167+
})
168+
if oldVer == "" {
169+
return fmt.Errorf("version field not found in %s", manifestPath)
170+
}
171+
fmt.Printf(" %-40s %s → %s\n", name, oldVer, version)
172+
if err := os.WriteFile(manifestPath, []byte(updated), 0644); err != nil {
173+
return err
174+
}
175+
syncGenDocVersion(name, version)
176+
return nil
177+
}
178+
179+
// syncGenDocVersion updates the Version row in the generator's doc page if it exists.
180+
func syncGenDocVersion(name, version string) {
181+
docPath := filepath.Join("docs", "contributor", "generators", name+".md")
182+
content, err := os.ReadFile(docPath)
183+
if err != nil {
184+
return
185+
}
186+
updated := docVersionRowRe.ReplaceAllString(string(content), "| Version | `"+version+"` |")
187+
if updated == string(content) {
188+
return
189+
}
190+
_ = os.WriteFile(docPath, []byte(updated), 0644)
191+
}
192+
193+
func isValidVersion(v string) bool {
194+
parts := strings.SplitN(v, ".", 3)
195+
if len(parts) != 3 {
196+
return false
197+
}
198+
for _, p := range parts {
199+
if _, err := strconv.Atoi(p); err != nil {
200+
return false
201+
}
202+
}
203+
return true
204+
}
205+
206+
func bumpMajorVer(v string) (string, error) {
207+
parts := strings.SplitN(v, ".", 3)
208+
if len(parts) != 3 {
209+
return "", fmt.Errorf("unexpected version format %q", v)
210+
}
211+
major, err := strconv.Atoi(parts[0])
212+
if err != nil {
213+
return "", fmt.Errorf("non-numeric major in %q: %w", v, err)
214+
}
215+
return strconv.Itoa(major+1) + ".0.0", nil
216+
}
217+
218+
func bumpMinorVer(v string) (string, error) {
219+
parts := strings.SplitN(v, ".", 3)
220+
if len(parts) != 3 {
221+
return "", fmt.Errorf("unexpected version format %q", v)
222+
}
223+
minor, err := strconv.Atoi(parts[1])
224+
if err != nil {
225+
return "", fmt.Errorf("non-numeric minor in %q: %w", v, err)
226+
}
227+
return parts[0] + "." + strconv.Itoa(minor+1) + ".0", nil
228+
}
229+
230+
func bumpPatchVer(v string) (string, error) {
231+
parts := strings.SplitN(v, ".", 3)
232+
if len(parts) != 3 {
233+
return "", fmt.Errorf("unexpected version format %q", v)
234+
}
235+
patch, err := strconv.Atoi(parts[2])
236+
if err != nil {
237+
return "", fmt.Errorf("non-numeric patch in %q: %w", v, err)
238+
}
239+
return parts[0] + "." + parts[1] + "." + strconv.Itoa(patch+1), nil
240+
}

internal/state/json.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package state
33
import (
44
"encoding/json"
55
"fmt"
6-
"sort"
76
"strings"
87
)
98

@@ -108,7 +107,7 @@ func (d *JSONDoc) DeleteKey(path string) {
108107
}
109108

110109
// AppendStringSet appends string values into the array at the dotted path,
111-
// deduplicating and sorting the result for deterministic output. Intermediate
110+
// deduplicating while preserving insertion order. Intermediate
112111
// objects and the array itself are created if missing — this is the right
113112
// helper when several generators each contribute entries to a shared list
114113
// (e.g. `pnpm.onlyBuiltDependencies`). Returns an error if a non-array value
@@ -159,7 +158,6 @@ func (d *JSONDoc) AppendStringSet(path string, values ...string) error {
159158
out = append(out, v)
160159
}
161160
}
162-
sort.Strings(out)
163161
arr := make([]interface{}, len(out))
164162
for i, s := range out {
165163
arr[i] = s

0 commit comments

Comments
 (0)