@silverassist/agents-toolkit 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/bin/cli.js +6 -5
- package/package.json +1 -1
- package/src/index.js +6 -1
- package/templates/agents/AGENTS.codex.md +2 -0
- package/templates/agents/AGENTS.md +9 -5
- package/templates/shared/instructions/caching.instructions.md +37 -13
- package/templates/shared/instructions/tsdoc-standards.instructions.md +182 -0
- package/templates/shared/prompts/README.md +22 -10
- package/templates/shared/prompts/create-github-pr.prompt.md +47 -3
- package/templates/shared/prompts/finalize-github-pr.prompt.md +7 -1
- package/templates/shared/prompts/resolve-github-reviews.prompt.md +323 -0
- package/templates/shared/skills/README.md +21 -3
- package/templates/shared/skills/ai-seo-optimization/SKILL.md +11 -2
- package/templates/shared/skills/core-review/SKILL.md +251 -0
- package/templates/shared/skills/domain-driven-design/SKILL.md +8 -3
- package/templates/shared/skills/github-review-management/SKILL.md +215 -0
- package/templates/shared/skills/nextjs-caching/SKILL.md +100 -31
- package/templates/shared/skills/release-management/SKILL.md +25 -5
- package/templates/shared/skills/testing-patterns/SKILL.md +38 -0
- package/templates/shared/skills/tsdoc-standards/SKILL.md +225 -0
|
@@ -25,9 +25,9 @@ This skill provides guidelines for organizing code following Domain-Driven Desig
|
|
|
25
25
|
|
|
26
26
|
### ❌ DON'T
|
|
27
27
|
|
|
28
|
-
- Create generic folders like
|
|
28
|
+
- Create generic folders like `src/helpers/`, `src/services/`, or `src/utils/` directly under `src/` (a scoped `src/lib/utils/` for minimal cross-domain primitives is allowed)
|
|
29
29
|
- Mix different domain concerns in the same folder
|
|
30
|
-
- Create
|
|
30
|
+
- Create folder hierarchies deeper than 3 levels below `src/` (e.g., `src/components/auth/login-form/` is allowed; `src/components/auth/login-form/subform/` is not)
|
|
31
31
|
- Use abbreviations in folder names
|
|
32
32
|
|
|
33
33
|
## Project Structure
|
|
@@ -228,7 +228,7 @@ Ask these questions to identify domains:
|
|
|
228
228
|
### ❌ Generic Folder Anti-Patterns
|
|
229
229
|
|
|
230
230
|
```
|
|
231
|
-
# ❌ BAD: Generic folders
|
|
231
|
+
# ❌ BAD: Generic folders at src/ root
|
|
232
232
|
src/
|
|
233
233
|
├── components/
|
|
234
234
|
├── helpers/ # What kind of helpers?
|
|
@@ -237,6 +237,11 @@ src/
|
|
|
237
237
|
└── types/ # Types should live with their domain
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
+
### Shared TypeScript Types
|
|
241
|
+
|
|
242
|
+
- If a type is used by **3 or more domains**, place it in `src/lib/types/` — the shared-types exception lives under the allowed `src/lib/` scope, **not** a generic `src/types/` root (which the anti-pattern above forbids). Export it from that folder's own barrel (`src/lib/types/index.ts`) and import it as `@/lib/types`. A centrally shared primitive has **no owning domain**, so do not route it through a domain barrel — that would invert the intended dependency direction (domains → shared, never shared → a domain).
|
|
243
|
+
- Otherwise, **colocate the type with its primary domain** (e.g., `src/lib/payment/types.ts`, `src/components/auth/types.ts`) and re-export it through that domain's barrel when other domains need it.
|
|
244
|
+
|
|
240
245
|
### ✅ Domain-Oriented Structure
|
|
241
246
|
|
|
242
247
|
```
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: github-review-management
|
|
3
|
+
description: Fetch, reply to, resolve, and close GitHub pull-request review threads with the gh CLI and GraphQL API. Use when handling Copilot or human PR reviews end-to-end.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Silver Assist — GitHub Review Management
|
|
7
|
+
|
|
8
|
+
Reference knowledge for clearing GitHub pull-request reviews programmatically. This skill
|
|
9
|
+
backs the `/resolve-github-reviews` prompt: it explains the data model, which API does what,
|
|
10
|
+
the exact commands, and the Copilot-specific edge cases. Use it when a PR has review threads
|
|
11
|
+
(Copilot or human) that need to be addressed, replied to, resolved, and verified to `0`.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
- A PR has open Copilot or human review comments to clear before merge
|
|
16
|
+
- You need to **resolve** review threads (not just reply) — which REST cannot do
|
|
17
|
+
- Debugging why a reply or resolve call fails (404 on replies, permissions on the mutation)
|
|
18
|
+
- Handling Copilot suppressed / low-confidence notes or `isOutdated` threads
|
|
19
|
+
- Automating the "reply → resolve → assert 0 unresolved" loop across per-commit review rounds
|
|
20
|
+
|
|
21
|
+
## The data model (get this right first)
|
|
22
|
+
|
|
23
|
+
GitHub exposes three distinct things — conflating them is the usual source of bugs:
|
|
24
|
+
|
|
25
|
+
| Concept | What it is | API surface |
|
|
26
|
+
|---------|-----------|-------------|
|
|
27
|
+
| **Review (submission)** | The top-level verdict on a PR: `APPROVED` / `CHANGES_REQUESTED` / `COMMENTED` | `gh pr review`, REST `/pulls/{n}/reviews` |
|
|
28
|
+
| **Review comment** | A single inline comment anchored to a file/line (has a `databaseId`) | REST `/pulls/{n}/comments` |
|
|
29
|
+
| **Review thread** | An inline comment **plus its replies**, carrying the `isResolved` flag | **GraphQL `reviewThreads` only** |
|
|
30
|
+
|
|
31
|
+
Only **threads** have `isResolved`, and **only GraphQL can flip it**. The REST comments API
|
|
32
|
+
lists and replies to comments but has no concept of resolving.
|
|
33
|
+
|
|
34
|
+
## Which API does what
|
|
35
|
+
|
|
36
|
+
| Operation | API | Command |
|
|
37
|
+
|-----------|-----|---------|
|
|
38
|
+
| List unresolved threads | GraphQL | `reviewThreads(first:100){ nodes{ id isResolved path line comments(first:1){ nodes{ databaseId } } } }` |
|
|
39
|
+
| Reply to a thread | REST | `POST /pulls/{n}/comments/{comment_id}/replies` |
|
|
40
|
+
| Reply fallback (404) | REST | `POST /pulls/{n}/comments` with `in_reply_to={comment_id}` |
|
|
41
|
+
| Acknowledge suppressed notes | REST | `gh pr comment {n} --body "…"` (PR-level) |
|
|
42
|
+
| **Resolve a thread** | **GraphQL** | `resolveReviewThread(input:{threadId:$id})` |
|
|
43
|
+
| Verify 0 unresolved | GraphQL | re-query `reviewThreads`, count `isResolved == false` |
|
|
44
|
+
|
|
45
|
+
## Resolve locations
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
REPO_SLUG=$(gh repo view --json nameWithOwner -q .nameWithOwner) # owner/repo
|
|
49
|
+
OWNER=${REPO_SLUG%/*}; REPO=${REPO_SLUG#*/}
|
|
50
|
+
PR=$(gh pr view --json number -q .number) # current branch's PR
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 1. List unresolved threads (paginated)
|
|
54
|
+
|
|
55
|
+
A PR can have more than 100 threads, so a single `first:100` page is not enough. Walk the
|
|
56
|
+
connection with `pageInfo { endCursor hasNextPage }` and collect every node before filtering.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
> /tmp/review-threads.jsonl
|
|
60
|
+
CURSOR=null
|
|
61
|
+
while : ; do
|
|
62
|
+
# First page: there is no cursor yet ($CURSOR is the string "null"). Passing it as
|
|
63
|
+
# `-f after="null"` would send the literal string "null" — an invalid cursor, not a
|
|
64
|
+
# "start from the beginning" signal. Omit `after` on this pass so the query starts at the
|
|
65
|
+
# beginning; pass the real endCursor afterward. (gh's typed `-F after=null` instead sends
|
|
66
|
+
# a real GraphQL null.)
|
|
67
|
+
if [ "$CURSOR" = "null" ]; then
|
|
68
|
+
AFTER_ARGS=()
|
|
69
|
+
else
|
|
70
|
+
AFTER_ARGS=(-f after="$CURSOR")
|
|
71
|
+
fi
|
|
72
|
+
PAGE=$(gh api graphql -F owner="$OWNER" -F repo="$REPO" -F pr="$PR" "${AFTER_ARGS[@]}" -f query='
|
|
73
|
+
query($owner:String!, $repo:String!, $pr:Int!, $after:String) {
|
|
74
|
+
repository(owner:$owner, name:$repo) {
|
|
75
|
+
pullRequest(number:$pr) {
|
|
76
|
+
reviewThreads(first:100, after:$after) {
|
|
77
|
+
pageInfo { endCursor hasNextPage }
|
|
78
|
+
nodes {
|
|
79
|
+
id
|
|
80
|
+
isResolved
|
|
81
|
+
isOutdated
|
|
82
|
+
path
|
|
83
|
+
line
|
|
84
|
+
comments(first:1) { nodes { databaseId body author { login } } }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}')
|
|
90
|
+
# Fail fast: a GraphQL/auth/network error must not be mistaken for "no threads".
|
|
91
|
+
if ! echo "$PAGE" | jq -e '.data.repository.pullRequest.reviewThreads' >/dev/null 2>&1; then
|
|
92
|
+
echo "ERROR: GraphQL request failed or returned an unexpected shape:" >&2
|
|
93
|
+
echo "$PAGE" >&2
|
|
94
|
+
exit 1
|
|
95
|
+
fi
|
|
96
|
+
echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> /tmp/review-threads.jsonl
|
|
97
|
+
HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
|
|
98
|
+
CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
|
|
99
|
+
[ "$HAS_NEXT" = "true" ] || break
|
|
100
|
+
done
|
|
101
|
+
|
|
102
|
+
jq -r '
|
|
103
|
+
select(.isResolved == false)
|
|
104
|
+
| "\(.id)\t\(.comments.nodes[0].databaseId)\t\(.path):\(.line)"
|
|
105
|
+
' /tmp/review-threads.jsonl
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Each row gives `threadId` (to resolve) and `commentId` = `databaseId` (to reply). A `null`
|
|
109
|
+
`databaseId` marks a suppressed note — see below.
|
|
110
|
+
|
|
111
|
+
## 2. Reply to a thread
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Primary: reply directly on the thread.
|
|
115
|
+
gh api "repos/$OWNER/$REPO/pulls/$PR/comments/$COMMENT_ID/replies" \
|
|
116
|
+
-f body="Fixed in <sha>: <what changed>."
|
|
117
|
+
|
|
118
|
+
# Fallback when the replies endpoint 404s: link via in_reply_to.
|
|
119
|
+
gh api "repos/$OWNER/$REPO/pulls/$PR/comments" \
|
|
120
|
+
-f body="Fixed in <sha>: <what changed>." \
|
|
121
|
+
-F in_reply_to="$COMMENT_ID"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## 3. Resolve the thread (GraphQL — the only way)
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
gh api graphql -f id="$THREAD_ID" -f query='
|
|
128
|
+
mutation($id:ID!) {
|
|
129
|
+
resolveReviewThread(input:{threadId:$id}) {
|
|
130
|
+
thread { isResolved }
|
|
131
|
+
}
|
|
132
|
+
}'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Batch all addressed threads:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
for THREAD_ID in $THREAD_IDS; do
|
|
139
|
+
gh api graphql -f id="$THREAD_ID" -f query='mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}){ thread { isResolved } } }'
|
|
140
|
+
done
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
> To reopen a thread (rarely needed), the counterpart mutation is `unresolveReviewThread`.
|
|
144
|
+
|
|
145
|
+
## 4. Verify 0 unresolved (the close-the-loop check)
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
> /tmp/review-threads-remaining.jsonl
|
|
149
|
+
CURSOR=null
|
|
150
|
+
while : ; do
|
|
151
|
+
if [ "$CURSOR" = "null" ]; then
|
|
152
|
+
AFTER_ARGS=()
|
|
153
|
+
else
|
|
154
|
+
AFTER_ARGS=(-f after="$CURSOR")
|
|
155
|
+
fi
|
|
156
|
+
PAGE=$(gh api graphql -F owner="$OWNER" -F repo="$REPO" -F pr="$PR" "${AFTER_ARGS[@]}" -f query='
|
|
157
|
+
query($owner:String!, $repo:String!, $pr:Int!, $after:String) {
|
|
158
|
+
repository(owner:$owner, name:$repo) {
|
|
159
|
+
pullRequest(number:$pr) {
|
|
160
|
+
reviewThreads(first:100, after:$after) {
|
|
161
|
+
pageInfo { endCursor hasNextPage }
|
|
162
|
+
nodes { isResolved }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}')
|
|
167
|
+
# Fail fast: a GraphQL/auth/network error must not be mistaken for "0 remaining".
|
|
168
|
+
if ! echo "$PAGE" | jq -e '.data.repository.pullRequest.reviewThreads' >/dev/null 2>&1; then
|
|
169
|
+
echo "ERROR: GraphQL request failed or returned an unexpected shape:" >&2
|
|
170
|
+
echo "$PAGE" >&2
|
|
171
|
+
exit 1
|
|
172
|
+
fi
|
|
173
|
+
echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> /tmp/review-threads-remaining.jsonl
|
|
174
|
+
HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
|
|
175
|
+
CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
|
|
176
|
+
[ "$HAS_NEXT" = "true" ] || break
|
|
177
|
+
done
|
|
178
|
+
|
|
179
|
+
REMAINING=$(jq -s '[.[] | select(.isResolved == false)] | length' /tmp/review-threads-remaining.jsonl)
|
|
180
|
+
if [ "$REMAINING" -eq 0 ]; then
|
|
181
|
+
echo "✅ all resolved"
|
|
182
|
+
else
|
|
183
|
+
echo "❌ $REMAINING remaining"
|
|
184
|
+
exit 1
|
|
185
|
+
fi
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
> If `isResolved` remains `false` immediately after a successful `resolveReviewThread`
|
|
189
|
+
> mutation, wait 2–3 seconds and re-query — GitHub's GraphQL API can exhibit brief
|
|
190
|
+
> consistency lag after bulk resolves, so a thread already resolved server-side may
|
|
191
|
+
> still report as open on the next read.
|
|
192
|
+
|
|
193
|
+
## Copilot-specific handling
|
|
194
|
+
|
|
195
|
+
- **Suppressed / low-confidence notes** — Copilot posts some findings without an inline comment
|
|
196
|
+
(`databaseId` is `null`), so there is **no inline comment to reply to**. The thread itself still
|
|
197
|
+
has an `id` and is resolvable — acknowledge the note with a single PR-level comment
|
|
198
|
+
(`gh pr comment`), then resolve the thread by its `id` like any other.
|
|
199
|
+
- **`isOutdated` threads** — when the underlying code moves, the thread is flagged `isOutdated`
|
|
200
|
+
but remains **unresolved**. It never clears itself; resolve it explicitly once handled.
|
|
201
|
+
- **Per-commit review rounds** — Copilot re-reviews after each push, potentially opening new
|
|
202
|
+
threads. Re-run list → reply → resolve → verify until the count is `0`. Trigger a fresh pass
|
|
203
|
+
with `gh pr comment $PR --body "@copilot review"` (unquoted `$PR` — it is the integer PR
|
|
204
|
+
number set earlier via `gh pr view --json number -q .number`, and `gh pr comment` takes
|
|
205
|
+
it as a positional argument, not a string flag value).
|
|
206
|
+
|
|
207
|
+
## Common failures
|
|
208
|
+
|
|
209
|
+
| Symptom | Cause | Fix |
|
|
210
|
+
|---------|-------|-----|
|
|
211
|
+
| Resolve mutation returns a permissions error | Token lacks `repo` scope | Re-auth: `gh auth refresh -s repo` |
|
|
212
|
+
| `POST …/replies` → 404 | Thread doesn't accept direct replies | Use `in_reply_to` fallback |
|
|
213
|
+
| Reply "works" but thread stays open | Replied via REST, never resolved | Run the GraphQL `resolveReviewThread` |
|
|
214
|
+
| `pr` variable rejected by GraphQL | Passed as a string | Use `-F pr="$PR"` (typed) not `-f pr=...` |
|
|
215
|
+
| Count never reaches 0 | New round opened threads | Re-run the loop after each push |
|
|
@@ -5,7 +5,7 @@ description: Caching strategy for Next.js (App Router) frontends consuming the C
|
|
|
5
5
|
|
|
6
6
|
# Next.js Caching Skill
|
|
7
7
|
|
|
8
|
-
Canonical caching strategy for Silver Side Next.js (App Router) frontends. Use it when touching data
|
|
8
|
+
Canonical caching strategy for Silver Assist Side Next.js (App Router) frontends. Use it when touching data
|
|
9
9
|
fetching, route segment configs, the asset/page proxy, image config, or the on-demand revalidate
|
|
10
10
|
route — and when diagnosing "why isn't this page cached / why is it serving stale".
|
|
11
11
|
|
|
@@ -18,8 +18,14 @@ route — and when diagnosing "why isn't this page cached / why is it serving st
|
|
|
18
18
|
## Core principle
|
|
19
19
|
|
|
20
20
|
**Cache by intent (read vs. mutation), never by the HTTP method.** Some reads must use `POST` because
|
|
21
|
-
they take a body (e.g. CCDS `geo-search`).
|
|
22
|
-
|
|
21
|
+
they take a body (e.g. CCDS `geo-search`). Their *data* still has to cache like a `GET` — send
|
|
22
|
+
`next: { revalidate, tags }`, never `no-store`. Mutations must never cache.
|
|
23
|
+
|
|
24
|
+
**But caching the data is not the same as making the page static.** A POST read caches its response
|
|
25
|
+
cross-request, yet Next.js still renders the *route* dynamically (`private, no-store`). The data-fetch
|
|
26
|
+
cache (layer 2) and the route's static/dynamic classification (layers 1/5) are **independent** — see
|
|
27
|
+
the two sections below. This is the WEB-1069 gotcha and the reason city/community pages needed an edge
|
|
28
|
+
override even though their fetches were already cached.
|
|
23
29
|
|
|
24
30
|
---
|
|
25
31
|
|
|
@@ -41,20 +47,54 @@ whole route into **dynamic rendering**, which emits `cache-control: private, no-
|
|
|
41
47
|
|
|
42
48
|
---
|
|
43
49
|
|
|
44
|
-
##
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
**
|
|
57
|
-
|
|
50
|
+
## POST reads: the data caches, but the route stays dynamic (the key gotcha)
|
|
51
|
+
|
|
52
|
+
Two independent facts, both true:
|
|
53
|
+
|
|
54
|
+
1. **The data fetch caches.** From `next/dist/server/lib/patch-fetch.js`: a `fetch` is uncacheable
|
|
55
|
+
only when it has no `next`/`cache` options *and* the segment `revalidate` is `0`. So a POST **with**
|
|
56
|
+
`next: { revalidate }` IS cached cross-request — the **request body is part of the cache key**
|
|
57
|
+
(`generateCacheKey` hashes `init.body`), so different filter bodies cache independently. This is
|
|
58
|
+
real and bounds origin/CCDS load; it is why the WordPress GraphQL POST caches fine.
|
|
59
|
+
|
|
60
|
+
2. **The route still renders dynamically.** Empirically (WEB-1069, Next 16), a page whose data comes
|
|
61
|
+
from a POST read prerenders as `ƒ` (Dynamic) and serves `cache-control: private, no-store` — **even
|
|
62
|
+
with** `next: { revalidate, tags }` on the fetch. Next treats the POST as request-time data for
|
|
63
|
+
prerendering, so ISR (layer 1) never engages. GET reads do not have this problem — they ISR
|
|
64
|
+
natively (`s-maxage`, `x-nextjs-cache: HIT`).
|
|
65
|
+
|
|
66
|
+
**Implication:** `next: { revalidate, tags }` on a POST read is **necessary** (data cache) but **not
|
|
67
|
+
sufficient** to make the page CDN-cacheable. You must additionally pick a rendering/edge strategy
|
|
68
|
+
(next section). `revalidate` + `tags` alone will NOT flip a POST-read page from `private, no-store` to
|
|
69
|
+
`public`.
|
|
70
|
+
|
|
71
|
+
## Making a POST-read page cacheable (rendering / edge layer)
|
|
72
|
+
|
|
73
|
+
Pick one (ordered by risk, lowest first):
|
|
74
|
+
|
|
75
|
+
1. **CDN edge override (current, lowest risk).** `src/proxy.ts` matches the city/community paths and
|
|
76
|
+
sets `Cache-Control: public, s-maxage=2592000, stale-while-revalidate=2592000` — the **same**
|
|
77
|
+
header the ISR pages emit (see `expireTime` under ISR tiers), so the CDN policy is uniform across
|
|
78
|
+
static and dynamic routes. The origin stays dynamic; the CDN caches the deterministic-per-URL
|
|
79
|
+
response. Per-repo regex, no build-time risk — this is what WEB-1069 shipped. **Requires:** a
|
|
80
|
+
per-repo path regex in `src/proxy.ts` that matches exactly the routes you want cached **and**
|
|
81
|
+
those routes must be **public and deterministic per URL** — no auth-, cookie-, or session-
|
|
82
|
+
dependent output. A `public` edge cache serves one stored response to *every* visitor, so a
|
|
83
|
+
personalized route matched here would leak one user's content to others. Never match
|
|
84
|
+
authenticated or personalized paths.
|
|
85
|
+
2. **`export const dynamic = "force-static"` (interim).** Forces the POST read to `force-cache` and
|
|
86
|
+
prerenders the route as real ISR (confirmed via `prerender-manifest.json`). Removed in WEB-1058
|
|
87
|
+
because a CCDS failure at build time cached a **blank page**; safer now that reads throw on 5xx at
|
|
88
|
+
runtime (a failed revalidation keeps the last good cache), but full prerender is sensitive to
|
|
89
|
+
null/bad records. **Requires:** the page is fully null-safe (every field access guarded); the read
|
|
90
|
+
throws on failure **at build/prerender time too** (not only during runtime revalidation) so a failed
|
|
91
|
+
read aborts generation instead of prerendering a cacheable blank/error page — the exact WEB-1058
|
|
92
|
+
regression; AND the client throws on 5xx at runtime (so ISR keeps the previous version instead of
|
|
93
|
+
caching an error). Re-validate this per repository before adopting the strategy.
|
|
94
|
+
3. **`cacheComponents: true` + `use cache` (strategic).** Wrap the POST read in `use cache` so its data
|
|
95
|
+
lands in the static shell (PPR). Next-recommended long term; larger migration. **Requires:** a
|
|
96
|
+
deliberate PPR migration for the whole route segment; do **not** mix ad hoc with route-segment
|
|
97
|
+
`export const revalidate` (the two models conflict).
|
|
58
98
|
|
|
59
99
|
---
|
|
60
100
|
|
|
@@ -76,7 +116,7 @@ export async function fetchData<T>({
|
|
|
76
116
|
method = "GET",
|
|
77
117
|
body = null,
|
|
78
118
|
revalidateTag,
|
|
79
|
-
revalidate =
|
|
119
|
+
revalidate = 2592000, // 30d time-based fallback; pair with tags for surgical invalidation
|
|
80
120
|
mutation = false,
|
|
81
121
|
}: FetchOptions): Promise<T | null> {
|
|
82
122
|
const isMutation = mutation || method === "PUT" || method === "DELETE";
|
|
@@ -116,18 +156,33 @@ await fetch(WP_API_URL, {
|
|
|
116
156
|
|
|
117
157
|
## ISR revalidate tiers
|
|
118
158
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
|
123
|
-
|
|
124
|
-
|
|
|
159
|
+
CCDS/WP data changes rarely and is refreshed on-demand via webhooks + tags, so the time-based default
|
|
160
|
+
is intentionally **long** (WEB-1069):
|
|
161
|
+
|
|
162
|
+
| Route / config | Value |
|
|
163
|
+
|----------------|-------|
|
|
164
|
+
| CCDS reads (client default) | `2592000` (30d) |
|
|
165
|
+
| WordPress GraphQL reads (`WP_CACHE_DURATIONS`) | `2592000` (30d) |
|
|
166
|
+
| Listing / detail / city / community / state / landing / WP catch-all | `2592000` (30d) |
|
|
167
|
+
| `next.config` `expireTime` (CDN stale window) | `5184000` (60d) → emits `s-maxage=2592000, stale-while-revalidate=2592000` |
|
|
168
|
+
| `next.config` image `minimumCacheTTL` | `31536000` (1y) — optimized images are content-hashed, never change |
|
|
125
169
|
|
|
126
170
|
- Every cacheable route exports `revalidate`. Do **not** add it to form/personalized routes.
|
|
127
|
-
-
|
|
171
|
+
- **`expireTime` sets the CDN stale window.** Next emits `s-maxage=<revalidate>,
|
|
172
|
+
stale-while-revalidate=<expireTime − revalidate>` for ISR pages, so `expireTime` **must be ≥ the
|
|
173
|
+
largest `revalidate`** or the stale window is invalid (the WEB-1069 bug: `expireTime: 86400` under a
|
|
174
|
+
30d revalidate). Set it to `5184000` (60d) so a 30d page yields the same header the proxy sets on
|
|
175
|
+
dynamic pages: `s-maxage=2592000, stale-while-revalidate=2592000`.
|
|
176
|
+
- The long default is safe because a missed webhook still self-heals within 30d; use `tags` for
|
|
177
|
+
immediate surgical invalidation. Shorter tiers (24h/7d) are appropriate per-route when the data
|
|
178
|
+
source updates more frequently than the 30d default — for example, sources that update daily or
|
|
179
|
+
weekly. Sources that update less frequently than 30d should keep the default.
|
|
180
|
+
- Default to `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then
|
|
128
181
|
preserves the last good cache instead of overwriting it with a broken page. Pair with a client that
|
|
129
182
|
**throws on 5xx at runtime** (so ISR keeps the previous version) but returns an error during the
|
|
130
|
-
build phase (so `generateStaticParams` can skip a bad entry without failing the build).
|
|
183
|
+
build phase (so `generateStaticParams` can skip a bad entry without failing the build). `force-static`
|
|
184
|
+
is still a valid POST-read option (see "Making a POST-read page cacheable") **once** the page is
|
|
185
|
+
fully null-safe and the throw-on-5xx guard is in place.
|
|
131
186
|
|
|
132
187
|
---
|
|
133
188
|
|
|
@@ -142,17 +197,24 @@ revalidateTag(tag); // granular per-state / per-city tags
|
|
|
142
197
|
if (invalidateCDN) await invalidateCloudFrontPaths([path]);
|
|
143
198
|
```
|
|
144
199
|
|
|
200
|
+
If `invalidateCloudFrontPaths` throws, log the error and surface it to the caller — do **not**
|
|
201
|
+
silently swallow it. The Next.js cache will be fresh but the CDN will serve stale until its TTL
|
|
202
|
+
expires; an operator must retry the CloudFront invalidation manually.
|
|
203
|
+
|
|
145
204
|
---
|
|
146
205
|
|
|
147
206
|
## Anti-patterns
|
|
148
207
|
|
|
149
|
-
- ❌ `next: method === "GET" ? {...} : { revalidate: 0 }` — leaves POST reads uncached
|
|
150
|
-
|
|
208
|
+
- ❌ `next: method === "GET" ? {...} : { revalidate: 0 }` — leaves POST reads uncached (origin hit
|
|
209
|
+
every render). Note: even a *correctly* cached POST read still renders the route dynamically — that
|
|
210
|
+
needs an edge override, not just `next` options (see "Making a POST-read page cacheable").
|
|
151
211
|
- ❌ Bare `fetch(url, { method: "POST", body })` for a read — refetched from origin every render.
|
|
152
212
|
- ❌ `next: { tags }` with no `revalidate` — holds stale data or doesn't cache at runtime.
|
|
153
213
|
- ❌ Caching a mutation (`submit`, lead, `PUT`/`DELETE`/`PATCH`).
|
|
154
214
|
- ❌ Treating `React.cache()` as cross-request caching (it's request-scoped dedup only).
|
|
155
|
-
- ❌ `dynamic = "force-static"` on a page
|
|
215
|
+
- ❌ `dynamic = "force-static"` on a page that is **not** fully null-safe or lacks throw-on-5xx — a bad
|
|
216
|
+
CCDS record/response at build time caches a broken/blank page (the WEB-1058 regression). It is a
|
|
217
|
+
valid option only once those guards exist.
|
|
156
218
|
- ❌ Mixing `cacheComponents: true` (`"use cache"`) with route-segment `export const revalidate` —
|
|
157
219
|
that is a separate, deliberate migration; do not introduce it ad hoc.
|
|
158
220
|
|
|
@@ -180,5 +242,12 @@ curl -sI https://<host>/<care-type>/<state>/<city> | grep -i cache-control
|
|
|
180
242
|
```
|
|
181
243
|
|
|
182
244
|
In the build output, `●` (or "Static"/"ISR") means prerendered; `ƒ` ("Dynamic") means it renders per
|
|
183
|
-
request
|
|
184
|
-
|
|
245
|
+
request. Which one to expect depends on the strategy chosen in "Making a POST-read page cacheable":
|
|
246
|
+
strategy **2** (`force-static`) prerenders fully, so it shows `●`; strategy **3** (`use cache` / PPR)
|
|
247
|
+
shows `●` only when the whole route is static — if dynamic holes remain it renders as a **partial
|
|
248
|
+
prerender** and Next marks it `◐` (partial), which is still correct output, so do not reject it.
|
|
249
|
+
Strategy **1** (CDN edge override) intentionally leaves the route as `ƒ` — the page is CDN-cached at
|
|
250
|
+
the edge even though Next classifies it as dynamic. Because the symbol alone is ambiguous across
|
|
251
|
+
strategies, verify with the `curl -sI … | grep -i cache-control` check above and defer to the
|
|
252
|
+
installed Next.js version's build-output legend rather than the symbol. `next.config`
|
|
253
|
+
`logging.fetches.fullUrl: true` shows per-fetch cache decisions in dev.
|
|
@@ -41,7 +41,7 @@ plugin-slug/
|
|
|
41
41
|
|
|
42
42
|
### Key Principles
|
|
43
43
|
|
|
44
|
-
1. **Unified scripts** — `build-release.sh` and `release.yml` are identical across all 6 plugins.
|
|
44
|
+
1. **Unified scripts** — `build-release.sh` and `release.yml` are structurally identical across all 6 plugins. The only permitted per-plugin variation is the Silver Assist package validation block at the end of `build-release.sh` (see [Validation Checks](#validation-checks)), which may omit checks for packages the plugin does not depend on.
|
|
45
45
|
2. **Selective copy** — Only runtime files go into the ZIP (no tests, docs, dev configs).
|
|
46
46
|
3. **Auto-detection** — The build script finds the main plugin file, slug, and version automatically.
|
|
47
47
|
4. **Checksums** — Every ZIP gets MD5 and SHA-256 checksums.
|
|
@@ -80,13 +80,21 @@ git push origin v1.3.6
|
|
|
80
80
|
|
|
81
81
|
### Step 1: Update All Versions
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
**Selection is driven by which script the plugin ships — each plugin ships exactly one variant**,
|
|
84
|
+
and `release.yml` auto-detects the same way (it runs `update-version.sh` if present, else
|
|
85
|
+
`update-version-simple.sh`). Manual runs and CI therefore stay consistent *because only one script
|
|
86
|
+
exists per plugin*; never ship both, or CI's preference for `update-version.sh` would silently
|
|
87
|
+
diverge from a manual `update-version-simple.sh` run.
|
|
88
|
+
|
|
89
|
+
- Full variant (`update-version.sh`): `acf-clone-fields`, `silver-assist-post-revalidate`, `silver-assist-security`.
|
|
90
|
+
- Simple variant (`update-version-simple.sh`): `contact-form-to-api`, `leadgen-app-form`, `nextjs-graphql-hooks`.
|
|
91
|
+
- New plugin: ship **only** `update-version-simple.sh` unless it genuinely needs the full variant.
|
|
84
92
|
|
|
85
93
|
```bash
|
|
86
94
|
# Full variant (acf-clone-fields, silver-assist-post-revalidate, silver-assist-security)
|
|
87
95
|
./scripts/update-version.sh 1.2.0 --no-confirm --force
|
|
88
96
|
|
|
89
|
-
# Simple variant (contact-form-to-api, leadgen-app-form, nextjs-graphql-hooks)
|
|
97
|
+
# Simple variant (contact-form-to-api, leadgen-app-form, nextjs-graphql-hooks, new plugins by default)
|
|
90
98
|
./scripts/update-version-simple.sh 1.2.0 --no-confirm --force
|
|
91
99
|
```
|
|
92
100
|
|
|
@@ -165,7 +173,7 @@ gh run watch <run-id> --exit-status
|
|
|
165
173
|
|
|
166
174
|
## Unified Build Script (`scripts/build-release.sh`)
|
|
167
175
|
|
|
168
|
-
This script is **identical** across all Silver Assist plugins. When setting up a new plugin, copy it verbatim.
|
|
176
|
+
This script is **structurally identical** across all Silver Assist plugins. When setting up a new plugin, copy it and change only the dependency-specific asset-validation checks a plugin does not need — the single allowed per-plugin variation, documented below (see the Silver Assist package-asset table and [Key Principles](#key-principles)). Everything else must remain identical; copying it verbatim for a plugin that lacks one of those dependencies would leave an invalid validation check that fails its release build.
|
|
169
177
|
|
|
170
178
|
### What It Does
|
|
171
179
|
|
|
@@ -406,7 +414,7 @@ The build script validates these Silver Assist package assets exist in the final
|
|
|
406
414
|
| Settings Hub CSS | `vendor/silverassist/wp-settings-hub/assets/css/settings-hub.css` |
|
|
407
415
|
| GitHub Updater JS | `vendor/silverassist/wp-github-updater/assets/js/check-updates.js` |
|
|
408
416
|
|
|
409
|
-
If the plugin does NOT
|
|
417
|
+
If the plugin does NOT depend on one of these packages, omit only that package's validation check when creating the plugin's `build-release.sh`. This is the single allowed per-plugin variation of the script (see [Key Principles](#key-principles)); everything else must remain identical to the template above.
|
|
410
418
|
|
|
411
419
|
---
|
|
412
420
|
|
|
@@ -429,6 +437,14 @@ on:
|
|
|
429
437
|
description: 'Version to release (e.g. 1.2.0). Leave empty to use plugin file version.'
|
|
430
438
|
required: false
|
|
431
439
|
|
|
440
|
+
# Note: `workflow_dispatch` still performs a REAL release — the "Create GitHub Release"
|
|
441
|
+
# step below publishes the release and creates the `v<version>` tag if it does not
|
|
442
|
+
# already exist. It is NOT a dry run. Use it only to release a version whose source is
|
|
443
|
+
# already committed on the checked-out ref: the "Update version" step edits files inside
|
|
444
|
+
# the CI checkout without committing them back, so an uncommitted bump would ship a ZIP
|
|
445
|
+
# that differs from the repository. For a true dry run, run the build steps in a separate
|
|
446
|
+
# non-publishing workflow that omits the "Create GitHub Release" step.
|
|
447
|
+
|
|
432
448
|
permissions:
|
|
433
449
|
contents: write
|
|
434
450
|
|
|
@@ -457,6 +473,10 @@ jobs:
|
|
|
457
473
|
else
|
|
458
474
|
VERSION=$(grep -o 'Version: [0-9]\+\.[0-9]\+\.[0-9]\+' *.php 2>/dev/null | head -1 | cut -d' ' -f2)
|
|
459
475
|
fi
|
|
476
|
+
if [ -z "$VERSION" ]; then
|
|
477
|
+
echo "::error::Could not detect version: no tag, no manual input, and no Version: header found in root PHP files."
|
|
478
|
+
exit 1
|
|
479
|
+
fi
|
|
460
480
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
461
481
|
echo "🏷️ Version: $VERSION"
|
|
462
482
|
|
|
@@ -75,6 +75,24 @@ jest.mock('stripe', () => ({
|
|
|
75
75
|
|
|
76
76
|
## Server Action Testing Pattern
|
|
77
77
|
|
|
78
|
+
### Mocking `next/navigation`
|
|
79
|
+
|
|
80
|
+
Server Actions that call `redirect()` or use routing helpers require `next/navigation` to be mocked, since it is not available in the Jest test environment.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// Mock next/navigation BEFORE imports.
|
|
84
|
+
// redirect() must THROW like the real implementation — Next signals a redirect by throwing
|
|
85
|
+
// NEXT_REDIRECT to terminate the action's control flow. A plain `jest.fn()` no-op would let
|
|
86
|
+
// code after redirect() keep running, producing outcomes impossible in production. Throw a
|
|
87
|
+
// sentinel and assert on it (e.g. `await expect(action()).rejects.toThrow(/NEXT_REDIRECT/)`).
|
|
88
|
+
jest.mock('next/navigation', () => ({
|
|
89
|
+
redirect: jest.fn((url) => {
|
|
90
|
+
throw new Error(`NEXT_REDIRECT: ${url}`);
|
|
91
|
+
}),
|
|
92
|
+
useRouter: jest.fn(() => ({ push: jest.fn() })),
|
|
93
|
+
}));
|
|
94
|
+
```
|
|
95
|
+
|
|
78
96
|
### Basic Server Action Test
|
|
79
97
|
|
|
80
98
|
```typescript
|
|
@@ -113,6 +131,26 @@ describe('submitWizardToSalesforce', () => {
|
|
|
113
131
|
expect(result.success).toBe(true);
|
|
114
132
|
expect(result.leadId).toBeDefined();
|
|
115
133
|
});
|
|
134
|
+
|
|
135
|
+
it('should return failure state when the API rejects', async () => {
|
|
136
|
+
// Simulate an error thrown by the mocked dependency
|
|
137
|
+
mockCreateLead.mockRejectedValue(new Error('API error'));
|
|
138
|
+
|
|
139
|
+
const formData = new FormData();
|
|
140
|
+
formData.append('email', 'test@example.com');
|
|
141
|
+
formData.append('firstName', 'John');
|
|
142
|
+
|
|
143
|
+
const result = await submitWizardToSalesforce(
|
|
144
|
+
{ success: false, message: '', timestamp: 0 },
|
|
145
|
+
formData
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Assert the error path for THIS action: it catches the API failure and returns a
|
|
149
|
+
// failure state. Match the action's own contract — actions that instead throw (e.g.
|
|
150
|
+
// via redirect(), see the mock above) must be asserted with `rejects.toThrow(...)`.
|
|
151
|
+
expect(result.success).toBe(false);
|
|
152
|
+
expect(result.message).toContain('error');
|
|
153
|
+
});
|
|
116
154
|
});
|
|
117
155
|
```
|
|
118
156
|
|