Skip to content

Commit 6efc345

Browse files
Merge pull request #10 from sonupreetam/014-skip-lockfile-only-pr
fix: skip automated PR when no doc content changes
2 parents 679ddc2 + 27638ce commit 6efc345

7 files changed

Lines changed: 475 additions & 4 deletions

File tree

.github/workflows/sync-content-check.yml

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,43 @@ jobs:
2323
with:
2424
go-version-file: go.mod
2525

26-
- name: Check for upstream changes
27-
run: go run ./cmd/sync-content --org complytime --config sync-config.yaml --lock .content-lock.json --update-lock --summary sync-summary.md
26+
- name: Update content lock
27+
run: go run ./cmd/sync-content --org complytime --config sync-config.yaml --lock .content-lock.json --update-lock
2828
env:
2929
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3030

31+
- name: Check for doc content changes
32+
id: check
33+
run: go run ./cmd/sync-content --org complytime --config sync-config.yaml --lock .content-lock.json --write --summary sync-summary.md
34+
env:
35+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
37+
- name: Derive PR title
38+
id: title
39+
if: steps.check.outputs.has_changes == 'true'
40+
env:
41+
CHANGED_REPOS: ${{ steps.check.outputs.changed_repos }}
42+
run: |
43+
IFS=',' read -ra repos <<< "$CHANGED_REPOS"
44+
count=${#repos[@]}
45+
if [ "$count" -le 3 ] && [ "$count" -gt 0 ]; then
46+
names=$(printf "%s, " "${repos[@]}"); names="${names%, }"
47+
title="content: sync ${names}"
48+
else
49+
repo_word="repositories"
50+
[ "$count" -eq 1 ] && repo_word="repository"
51+
title="content: sync ${count} ${repo_word}"
52+
fi
53+
echo "pr_title=${title}" >> "$GITHUB_OUTPUT"
54+
3155
- name: Create or update PR
56+
if: steps.check.outputs.has_changes == 'true'
3257
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0
3358
with:
3459
add-paths: .content-lock.json
3560
branch: automated/content-sync-update
3661
commit-message: "content: update upstream documentation lockfile"
37-
title: "content: update upstream documentation"
62+
title: "${{ steps.title.outputs.pr_title }}"
3863
body-path: sync-summary.md
3964
labels: automated, documentation
4065
delete-branch: true

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ Thumbs.db
3434
*.swo
3535
*~
3636

37+
# ─── AI tooling (tool-managed, installed locally via openspec init) ──
38+
# Run `openspec init --tools cursor` to restore these locally.
39+
.cursor/commands/
40+
.cursor/skills/
41+
.cursor/rules/specify-rules.mdc
42+
# OpenSpec working-change state — local only, analogous to .specify/scripts/.
43+
# openspec/config.yaml is tracked (project tool config).
44+
# Canonical specs live in specs/NNN-name/ (SpecKit format, org standard).
45+
openspec/changes/
46+
# SpecKit framework files (scripts and templates are tool-managed).
47+
.specify/scripts/
48+
.specify/templates/
49+
.specify/memory/
50+
3751
# ─── Environment and secrets ─────────────────────────────────────────
3852
.env
3953

cmd/sync-content/sync.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,50 @@ func (r *syncResult) recordChangedRepoFile(repoName, srcPath string) {
9090
r.mu.Unlock()
9191
}
9292

93+
// changedRepos returns an alphabetically sorted, deduplicated list of repo
94+
// names that had content changes: repo-level adds/updates plus repos that had
95+
// individual file-level changes recorded in changedRepoFiles.
96+
func (r *syncResult) changedRepos() []string {
97+
seen := make(map[string]struct{})
98+
for _, name := range r.added {
99+
seen[name] = struct{}{}
100+
}
101+
for _, name := range r.updated {
102+
seen[name] = struct{}{}
103+
}
104+
for name, files := range r.changedRepoFiles {
105+
if len(files) > 0 {
106+
seen[name] = struct{}{}
107+
}
108+
}
109+
repos := make([]string, 0, len(seen))
110+
for name := range seen {
111+
repos = append(repos, name)
112+
}
113+
sort.Strings(repos)
114+
return repos
115+
}
116+
117+
// changedFilesCount returns the total number of individual documentation files
118+
// that were written during this sync run across all repos.
119+
func (r *syncResult) changedFilesCount() int {
120+
total := 0
121+
for _, files := range r.changedRepoFiles {
122+
total += len(files)
123+
}
124+
return total
125+
}
126+
93127
func (r *syncResult) hasChanges() bool {
94-
return len(r.added) > 0 || len(r.updated) > 0 || len(r.removed) > 0
128+
if len(r.added) > 0 || len(r.updated) > 0 || len(r.removed) > 0 {
129+
return true
130+
}
131+
for _, files := range r.changedRepoFiles {
132+
if len(files) > 0 {
133+
return true
134+
}
135+
}
136+
return false
95137
}
96138

97139
func shortSHA(sha string) string {
@@ -153,6 +195,18 @@ func (r *syncResult) toMarkdown() string {
153195
sort.Strings(removed)
154196
sort.Strings(unchanged)
155197

198+
if repos := r.changedRepos(); len(repos) > 0 {
199+
repoWord := "repositories"
200+
if len(repos) == 1 {
201+
repoWord = "repository"
202+
}
203+
names := make([]string, len(repos))
204+
for i, name := range repos {
205+
names[i] = "`" + name + "`"
206+
}
207+
fmt.Fprintf(&b, "**Changed %s**: %s\n\n", repoWord, strings.Join(names, ", "))
208+
}
209+
156210
if len(added) > 0 {
157211
b.WriteString("### New Repositories\n\n")
158212
for _, name := range added {
@@ -737,6 +791,8 @@ func writeGitHubOutputs(result *syncResult) {
737791
}
738792
_, _ = fmt.Fprintf(f, "has_changes=%s\n", hasChanges)
739793
_, _ = fmt.Fprintf(f, "changed_count=%d\n", len(result.added)+len(result.updated))
794+
_, _ = fmt.Fprintf(f, "changed_repos=%s\n", strings.Join(result.changedRepos(), ","))
795+
_, _ = fmt.Fprintf(f, "changed_files_count=%d\n", result.changedFilesCount())
740796
_, _ = fmt.Fprintf(f, "files_processed=%d\n", result.filesProcessed)
741797
_, _ = fmt.Fprintf(f, "error_count=%d\n", result.errors)
742798
}

cmd/sync-content/sync_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,67 @@ func TestToMarkdown_NoChanges(t *testing.T) {
996996
}
997997
}
998998

999+
func TestHasChanges(t *testing.T) {
1000+
tests := []struct {
1001+
name string
1002+
result syncResult
1003+
want bool
1004+
}{
1005+
{
1006+
name: "empty result",
1007+
result: syncResult{},
1008+
want: false,
1009+
},
1010+
{
1011+
name: "only unchanged repos",
1012+
result: syncResult{unchanged: []string{"repo-a"}},
1013+
want: false,
1014+
},
1015+
{
1016+
name: "added repo",
1017+
result: syncResult{added: []string{"new-repo"}},
1018+
want: true,
1019+
},
1020+
{
1021+
name: "updated repo",
1022+
result: syncResult{updated: []string{"existing-repo"}},
1023+
want: true,
1024+
},
1025+
{
1026+
name: "removed repo",
1027+
result: syncResult{removed: []string{"old-repo"}},
1028+
want: true,
1029+
},
1030+
{
1031+
name: "doc file changed without repo-level change",
1032+
result: syncResult{
1033+
unchanged: []string{"some-repo"},
1034+
changedRepoFiles: map[string][]string{
1035+
"some-repo": {"docs/guide.md"},
1036+
},
1037+
},
1038+
want: true,
1039+
},
1040+
{
1041+
name: "changedRepoFiles present but empty slice",
1042+
result: syncResult{
1043+
changedRepoFiles: map[string][]string{
1044+
"some-repo": {},
1045+
},
1046+
},
1047+
want: false,
1048+
},
1049+
}
1050+
1051+
for i := range tests {
1052+
t.Run(tests[i].name, func(t *testing.T) {
1053+
if got := tests[i].result.hasChanges(); got != tests[i].want {
1054+
t.Errorf("hasChanges() = %v, want %v", got, tests[i].want)
1055+
}
1056+
})
1057+
}
1058+
}
1059+
9991060
func TestToMarkdown_FallbackWithoutDetails(t *testing.T) {
10001061
result := &syncResult{
10011062
synced: 1,
@@ -1357,3 +1418,124 @@ func TestParseNameList_RepoFilterOverridesExclude(t *testing.T) {
13571418
t.Error("complyctl should be in includeSet")
13581419
}
13591420
}
1421+
1422+
func TestChangedRepos(t *testing.T) {
1423+
tests := []struct {
1424+
name string
1425+
result syncResult
1426+
want []string
1427+
}{
1428+
{
1429+
name: "empty result",
1430+
result: syncResult{},
1431+
want: []string{},
1432+
},
1433+
{
1434+
name: "repo-level added only",
1435+
result: syncResult{added: []string{"repo-b", "repo-a"}},
1436+
want: []string{"repo-a", "repo-b"},
1437+
},
1438+
{
1439+
name: "repo-level updated only",
1440+
result: syncResult{updated: []string{"repo-c"}},
1441+
want: []string{"repo-c"},
1442+
},
1443+
{
1444+
name: "file-level changes only (no repo-level add/update)",
1445+
result: syncResult{
1446+
unchanged: []string{"repo-x"},
1447+
changedRepoFiles: map[string][]string{
1448+
"repo-x": {"docs/guide.md"},
1449+
},
1450+
},
1451+
want: []string{"repo-x"},
1452+
},
1453+
{
1454+
name: "overlap: repo in both updated and changedRepoFiles",
1455+
result: syncResult{
1456+
updated: []string{"repo-z"},
1457+
changedRepoFiles: map[string][]string{
1458+
"repo-z": {"docs/page.md"},
1459+
"repo-a": {"docs/intro.md"},
1460+
},
1461+
},
1462+
want: []string{"repo-a", "repo-z"},
1463+
},
1464+
{
1465+
name: "changedRepoFiles with empty slice excluded",
1466+
result: syncResult{
1467+
changedRepoFiles: map[string][]string{
1468+
"repo-empty": {},
1469+
},
1470+
},
1471+
want: []string{},
1472+
},
1473+
}
1474+
1475+
for i := range tests {
1476+
t.Run(tests[i].name, func(t *testing.T) {
1477+
got := tests[i].result.changedRepos()
1478+
if got == nil {
1479+
got = []string{}
1480+
}
1481+
if len(got) != len(tests[i].want) {
1482+
t.Fatalf("changedRepos() = %v, want %v", got, tests[i].want)
1483+
}
1484+
for j := range got {
1485+
if got[j] != tests[i].want[j] {
1486+
t.Errorf("changedRepos()[%d] = %q, want %q", j, got[j], tests[i].want[j])
1487+
}
1488+
}
1489+
})
1490+
}
1491+
}
1492+
1493+
func TestChangedFilesCount(t *testing.T) {
1494+
tests := []struct {
1495+
name string
1496+
result syncResult
1497+
want int
1498+
}{
1499+
{
1500+
name: "zero files",
1501+
result: syncResult{},
1502+
want: 0,
1503+
},
1504+
{
1505+
name: "single repo with files",
1506+
result: syncResult{
1507+
changedRepoFiles: map[string][]string{
1508+
"repo-a": {"docs/a.md", "docs/b.md"},
1509+
},
1510+
},
1511+
want: 2,
1512+
},
1513+
{
1514+
name: "multiple repos",
1515+
result: syncResult{
1516+
changedRepoFiles: map[string][]string{
1517+
"repo-a": {"docs/a.md"},
1518+
"repo-b": {"docs/b.md", "docs/c.md", "docs/d.md"},
1519+
},
1520+
},
1521+
want: 4,
1522+
},
1523+
{
1524+
name: "empty slice counts as zero",
1525+
result: syncResult{
1526+
changedRepoFiles: map[string][]string{
1527+
"repo-a": {},
1528+
},
1529+
},
1530+
want: 0,
1531+
},
1532+
}
1533+
1534+
for i := range tests {
1535+
t.Run(tests[i].name, func(t *testing.T) {
1536+
if got := tests[i].result.changedFilesCount(); got != tests[i].want {
1537+
t.Errorf("changedFilesCount() = %d, want %d", got, tests[i].want)
1538+
}
1539+
})
1540+
}
1541+
}

openspec/config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
schema: spec-driven
2+
3+
# Project context (optional)
4+
# This is shown to AI when creating artifacts.
5+
# Add your tech stack, conventions, style guides, domain knowledge, etc.
6+
# Example:
7+
# context: |
8+
# Tech stack: TypeScript, React, Node.js
9+
# We use conventional commits
10+
# Domain: e-commerce platform
11+
12+
# Per-artifact rules (optional)
13+
# Add custom rules for specific artifacts.
14+
# Example:
15+
# rules:
16+
# proposal:
17+
# - Keep proposals under 500 words
18+
# - Always include a "Non-goals" section
19+
# tasks:
20+
# - Break tasks into chunks of max 2 hours

0 commit comments

Comments
 (0)