Integrate Actions analyze job with Copilot path and reviewer gate (#32)
* workflow: add analyze job fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Copilot analyze review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
May 18, 2026 at 13:46 UTC
df56c1531407dbbbce18677ea5310a01d35ae0d8
7 files changed
+1014
-2
.github/workflows/crawl-and-publish.yml
+144
-2
@@ -1,4 +1,4 @@
1
-name: Crawl and publish raw data
1
+name: Crawl and publish weekly data
2
3
on:
4
schedule:
@@ -87,7 +87,7 @@ jobs:
87
if: always()
88
uses: actions/upload-artifact@v4
89
with:
90
- name: crawl-raw
90
+ name: raw-data
91
path: data/raw/
92
if-no-files-found: warn
93
@@ -130,3 +130,145 @@ jobs:
130
echo "Push failed after syncing with $DEFAULT_BRANCH."
131
exit 1
132
}
133
+
134
+ analyze:
135
+ needs: crawl
136
+ runs-on: ubuntu-latest
137
+ permissions:
138
+ contents: write
139
+ copilot-requests: write
140
+ models: read
141
+
142
+ steps:
143
+ - name: Check out repository
144
+ uses: actions/checkout@v4
145
+ with:
146
+ fetch-depth: 0
147
+ ref: ${{ github.event.repository.default_branch }}
148
+
149
+ - name: Download raw crawl artifact
150
+ uses: actions/download-artifact@v4
151
+ with:
152
+ name: raw-data
153
+ path: data/raw/
154
+
155
+ - name: Set up Node
156
+ uses: actions/setup-node@v4
157
+ with:
158
+ node-version: '22'
159
+
160
+ - name: Set up Python
161
+ uses: actions/setup-python@v5
162
+ with:
163
+ python-version: '3.12'
164
+
165
+ - name: Prepare analysis context
166
+ id: analysis-context
167
+ run: |
168
+ mkdir -p data/analyzed
169
+ CURRENT_DATETIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
170
+ readarray -t CONTEXT_LINES < <(python3 - <<'PY' "$CURRENT_DATETIME"
171
+ import json
172
+ import sys
173
+ from datetime import UTC, datetime
174
+ from pathlib import Path
175
+
176
+ current_datetime = sys.argv[1]
177
+ run_datetime = datetime.fromisoformat(current_datetime.replace("Z", "+00:00")).astimezone(UTC)
178
+ iso_year, iso_week, _ = run_datetime.isocalendar()
179
+ week = f"{iso_year}-W{iso_week:02d}"
180
+ week_file = Path("data/raw") / f"{week}.json"
181
+ if not week_file.exists():
182
+ raise SystemExit(f"Missing raw payload for current run: {week_file}")
183
+ payload = json.loads(week_file.read_text(encoding="utf-8"))
184
+ if payload.get("week") != week:
185
+ raise SystemExit(f"Raw payload week mismatch: expected {week}, found {payload.get('week')!r}")
186
+ print(f"week_file={week_file.as_posix()}")
187
+ print(f"week={week}")
188
+ print(f"output_file=data/analyzed/{week}-summary.md")
189
+ PY
190
+ )
191
+ {
192
+ printf '%s\n' "${CONTEXT_LINES[@]}"
193
+ echo "current_datetime=$CURRENT_DATETIME"
194
+ } >> "$GITHUB_OUTPUT"
195
+
196
+ - name: Install Copilot CLI
197
+ id: install-copilot
198
+ continue-on-error: true
199
+ run: npm install -g @github/copilot
200
+
201
+ - name: Run analysis
202
+ id: run-analysis
203
+ env:
204
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GH_TOKEN }}
205
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
206
+ run: |
207
+ set -euo pipefail
208
+ OUTPUT_FILE="${{ steps.analysis-context.outputs.output_file }}"
209
+ WEEK_FILE="${{ steps.analysis-context.outputs.week_file }}"
210
+ CURRENT_DATETIME="${{ steps.analysis-context.outputs.current_datetime }}"
211
+
212
+ if command -v copilot >/dev/null 2>&1 && copilot -p "$(python3 scripts/analyze_fallback.py --raw-json "$WEEK_FILE" --output "$OUTPUT_FILE" --current-datetime "$CURRENT_DATETIME" --print-prompt)" \
213
+ -s \
214
+ --no-ask-user \
215
+ --model claude-sonnet-4 \
216
+ --allow-tool=read \
217
+ --allow-tool=write \
218
+ --allow-tool=glob \
219
+ --allow-tool=grep \
220
+ > "$OUTPUT_FILE"; then
221
+ echo "analysis_source=copilot-cli" >> "$GITHUB_OUTPUT"
222
+ else
223
+ echo "Copilot CLI unavailable or failed; falling back to GitHub Models API."
224
+ python3 scripts/analyze_fallback.py \
225
+ --raw-json "$WEEK_FILE" \
226
+ --output "$OUTPUT_FILE" \
227
+ --current-datetime "$CURRENT_DATETIME"
228
+ echo "analysis_source=github-models" >> "$GITHUB_OUTPUT"
229
+ fi
230
+
231
+ - name: quality-check
232
+ env:
233
+ ANALYSIS_FILE: ${{ steps.analysis-context.outputs.output_file }}
234
+ ANALYSIS_SOURCE: ${{ steps.run-analysis.outputs.analysis_source }}
235
+ RAW_JSON_FILE: ${{ steps.analysis-context.outputs.week_file }}
236
+ CURRENT_DATETIME: ${{ steps.analysis-context.outputs.current_datetime }}
237
+ run: |
238
+ python3 scripts/analysis_gate.py \
239
+ --analysis-file "$ANALYSIS_FILE" \
240
+ --raw-json "$RAW_JSON_FILE" \
241
+ --current-datetime "$CURRENT_DATETIME" \
242
+ --source "$ANALYSIS_SOURCE"
243
+
244
+ - name: Commit analysis
245
+ env:
246
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
247
+ WEEK: ${{ steps.analysis-context.outputs.week }}
248
+ run: |
249
+ git config user.name "github-actions[bot]"
250
+ git config user.email "github-actions[bot]@users.noreply.github.com"
251
+ if ! git status --short -- data/analyzed | grep -q .; then
252
+ echo "No analyzed data changes to commit."
253
+ exit 0
254
+ fi
255
+ git stash push --include-untracked --message analyzed-data -- data/analyzed
256
+ git fetch origin "$DEFAULT_BRANCH"
257
+ git checkout -B "$DEFAULT_BRANCH" "origin/$DEFAULT_BRANCH"
258
+ git stash pop || {
259
+ echo "Failed to reapply analyzed data after syncing $DEFAULT_BRANCH."
260
+ exit 1
261
+ }
262
+ git add data/analyzed/
263
+ git diff --cached --quiet || git commit -m "analysis: weekly summary $WEEK"
264
+ git push origin "HEAD:$DEFAULT_BRANCH" || {
265
+ echo "Push failed after syncing with $DEFAULT_BRANCH."
266
+ exit 1
267
+ }
268
+
269
+ - name: Upload analyzed data
270
+ uses: actions/upload-artifact@v4
271
+ with:
272
+ name: analyzed-data
273
+ path: data/analyzed/
274
+ if-no-files-found: warn
.squad/agents/bender/history.md
+3
@@ -14,6 +14,9 @@
14
15
## Learnings
16
17
+- **2026-05-18T13:05:53.678+02:00:** Issue #10 integrates `analyze` directly into `crawl-and-publish.yml` after `crawl`, with `raw-data` as the crawl→analyze handoff artifact and `analyzed-data` as the stable downstream artifact contract for future generate jobs.
18
+- **2026-05-18T13:05:53.678+02:00:** The safest Phase 2 analysis execution path is: try standalone `copilot` CLI first (`copilot-requests: write`, PAT in `COPILOT_GITHUB_TOKEN`), then fall back to `scripts/analyze_fallback.py` against the GitHub Models API (`models: read`, `GITHUB_TOKEN`) using the same rendered prompt template.
19
+- **2026-05-18T13:05:53.678+02:00:** The automated reviewer gate should validate the analyzer contract, not just file existence: YAML frontmatter with the exact required keys, ordered H2/H3 sections, `quality_score >= 60`, body word-count floor, and rejection of raw JSON/tool-log leakage before publish continues.
20
- **2026-05-18T12:07:20.778+02:00:** Copilot review follow-up on crawler hardening: keep star snapshots broad for `stars_gained`, but document that they intentionally cover pre-filter candidates; restore `get_json()` payload compatibility via an internal `get_json_entry()` helper; treat malformed JSON as non-retryable; and search both `RAW_ROOT` and custom `--output` parents when loading prior star snapshots so reruns keep working.
21
- **2026-05-18T12:07:20.778+02:00:** Issue #8 adds a dedicated `crawl-and-publish.yml` workflow for the crawl stage only: weekly Monday 08:00 UTC plus manual dispatch, serialized with `concurrency`, committing refreshed `data/raw/`, `data/snapshots/`, and `data/cache/`, and restoring the latest successful `crawl-cache` artifact via Actions API lookup so weekly runs can reuse the crawler cache.
22
- **2026-05-18T10:06:38.734+02:00:** GitHub Actions can run the standalone `copilot` CLI (`@github/copilot`) in programmatic mode with `copilot -p ...`. The safest documented CI auth flow is a fine-grained PAT with the **Copilot Requests** account permission passed as `COPILOT_GITHUB_TOKEN`; `gh auth token` only exposes an existing `gh` token and `gh-copilot` is deprecated in favor of the standalone CLI. GitHub Models (`models: read`) is the clean fallback if direct Copilot CLI automation proves brittle.
.squad/decisions/inbox/bender-analyze-job.md
new
+38
@@ -0,0 +1,38 @@
1
+# Bender Decision Inbox — Analyze Job
2
+
3
+- **Date:** 2026-05-18T13:05:53.678+02:00
4
+- **Author:** Bender
5
+- **Issue:** #10 — Integrate Actions analyze job with Copilot path and reviewer gate
6
+
7
+## Context
8
+
9
+Phase 2 needs the weekly workflow to transform `data/raw/YYYY-WNN.json` into `data/analyzed/YYYY-WNN-summary.md` inside GitHub Actions, while preserving the approved fallback architecture and enforcing the analyzer contract before any downstream publish step runs.
10
+
11
+## Decision
12
+
13
+1. Extend `.github/workflows/crawl-and-publish.yml` with an `analyze` job that runs after `crawl`.
14
+2. Standardize the stage handoff artifacts as:
15
+ - `raw-data` for crawl → analyze
16
+ - `analyzed-data` for analyze → generate
17
+3. Use standalone Copilot CLI as the primary analysis path with:
18
+ - `permissions.copilot-requests: write`
19
+ - PAT secret `COPILOT_GH_TOKEN` exported as `COPILOT_GITHUB_TOKEN`
20
+4. Add `scripts/analyze_fallback.py` as the GitHub Models fallback using `permissions.models: read` and `GITHUB_TOKEN`.
21
+5. Enforce an automated `quality-check` gate in the workflow that blocks publish when the analysis contract is not met.
22
+
23
+## Quality Gate Contract
24
+
25
+The workflow gate should fail if any of the following are false:
26
+
27
+- YAML frontmatter exists.
28
+- The exact required frontmatter keys are present.
29
+- `quality_score` is an integer and at least 60.
30
+- Required H2/H3 sections appear in the documented order.
31
+- Body word count is at least 200.
32
+- Output does not leak raw JSON, traceback text, or placeholder/tool-log content.
33
+
34
+## Implications
35
+
36
+- Future generate jobs can safely consume `analyzed-data` without needing to inspect the raw crawl artifact.
37
+- Copilot CLI failures do not block the pipeline immediately; the GitHub Models fallback preserves publishability.
38
+- Reviewer-gate failures stay machine-detectable and stop low-quality summaries before they reach downstream stages.
scripts/analysis_gate.py
new
+351
@@ -0,0 +1,351 @@
1
+#!/usr/bin/env python3
2
+from __future__ import annotations
3
+
4
+import argparse
5
+import json
6
+import os
7
+import re
8
+import sys
9
+from datetime import UTC, datetime
10
+from pathlib import Path
11
+from typing import Any
12
+
13
+try: # pragma: no cover - optional dependency on runners
14
+ import yaml
15
+except ImportError: # pragma: no cover - exercised via fallback parser
16
+ yaml = None
17
+
18
+REQUIRED_FIELDS = [
19
+ "title",
20
+ "date",
21
+ "week",
22
+ "year",
23
+ "tags",
24
+ "categories",
25
+ "repos_featured",
26
+ "stars_tracked",
27
+ "top_repo",
28
+ "quality_score",
29
+ "summary",
30
+]
31
+REQUIRED_HEADINGS = [
32
+ "## Notable New Repositories",
33
+ "## Trending This Week",
34
+ "## Trend Analysis",
35
+ "### Signal",
36
+ "### Noise",
37
+ "## What's Missing",
38
+ "### Gaps",
39
+ "## Conclusion",
40
+]
41
+RAW_MARKERS = [
42
+ "```json",
43
+ '"week":',
44
+ '"new_repos"',
45
+ '"trending_repos"',
46
+ "traceback (most recent call last)",
47
+]
48
+PLACEHOLDER_PATTERNS = [
49
+ (re.compile(r"(?mi)^\s*(?:[-*]\s*)?todo\s*[:\-]"), "TODO placeholder marker"),
50
+ (re.compile(r"(?mi)^\s*(?:[-*]\s*)?tbd\s*[:\-]"), "TBD placeholder marker"),
51
+ (re.compile(r"(?i)\bplaceholder text\b"), "placeholder text"),
52
+ (re.compile(r"(?i)\byour analysis here\b"), "placeholder instruction"),
53
+]
54
+WEEK_PATTERN = re.compile(r"^(?P<year>\d{4})-W(?P<week>\d{2})$")
55
+FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n(.*)\Z", re.DOTALL)
56
+HEADING_PATTERN = re.compile(r"(?m)^(#{2,3})\s+(.+?)\s*$")
57
+WORD_PATTERN = re.compile(r"\b[\w'-]+\b")
58
+TOP_REPO_PATTERN = re.compile(r"^[^/\s]+/[^/\s]+$")
59
+
60
+
61
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
62
+ parser = argparse.ArgumentParser(description="Validate weekly analysis output against the analysis spec.")
63
+ parser.add_argument("--analysis-file", required=True, type=Path, help="Path to the rendered markdown summary.")
64
+ parser.add_argument("--raw-json", required=True, type=Path, help="Path to the raw weekly payload.")
65
+ parser.add_argument("--current-datetime", required=True, help="Current run timestamp in ISO 8601 format.")
66
+ parser.add_argument("--source", default="unknown", help="Analysis source label for summaries.")
67
+ return parser.parse_args(argv)
68
+
69
+
70
+def parse_datetime(value: str | datetime) -> datetime:
71
+ if isinstance(value, datetime):
72
+ parsed = value
73
+ elif isinstance(value, str):
74
+ candidate = value.strip()
75
+ if candidate.endswith("Z"):
76
+ candidate = f"{candidate[:-1]}+00:00"
77
+ parsed = datetime.fromisoformat(candidate)
78
+ else: # pragma: no cover - guarded by callers
79
+ raise TypeError(f"Unsupported datetime value: {value!r}")
80
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
81
+
82
+
83
+def week_slug(value: datetime) -> str:
84
+ year, week, _ = value.astimezone(UTC).isocalendar()
85
+ return f"{year}-W{week:02d}"
86
+
87
+
88
+def load_json(path: Path) -> dict[str, Any]:
89
+ payload = json.loads(path.read_text(encoding="utf-8"))
90
+ if not isinstance(payload, dict):
91
+ raise ValueError(f"Raw payload must be an object: {path}")
92
+ return payload
93
+
94
+
95
+def strip_quotes(value: str) -> str:
96
+ stripped = value.strip()
97
+ if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in {'"', "'"}:
98
+ return stripped[1:-1]
99
+ return stripped
100
+
101
+
102
+def parse_inline_list(value: str) -> list[str]:
103
+ inner = value.strip()[1:-1].strip()
104
+ if not inner:
105
+ return []
106
+ items: list[str] = []
107
+ for part in inner.split(","):
108
+ item = strip_quotes(part)
109
+ if not item:
110
+ raise ValueError(f"Malformed YAML list entry: {value}")
111
+ items.append(item)
112
+ return items
113
+
114
+
115
+def parse_frontmatter_fallback(text: str) -> dict[str, Any]:
116
+ frontmatter: dict[str, Any] = {}
117
+ lines = text.splitlines()
118
+ index = 0
119
+ while index < len(lines):
120
+ line = lines[index]
121
+ if not line.strip():
122
+ index += 1
123
+ continue
124
+ if line.startswith((" ", "\t")):
125
+ raise ValueError(f"Unexpected indentation in frontmatter: {line}")
126
+ if ":" not in line:
127
+ raise ValueError(f"Malformed frontmatter line: {line}")
128
+ key, raw_value = line.split(":", 1)
129
+ key = key.strip()
130
+ value = raw_value.strip()
131
+ if not key:
132
+ raise ValueError(f"Malformed frontmatter key: {line}")
133
+ if value == "":
134
+ items: list[str] = []
135
+ index += 1
136
+ while index < len(lines):
137
+ candidate = lines[index]
138
+ if not candidate.strip():
139
+ index += 1
140
+ continue
141
+ if not candidate.startswith((" ", "\t")):
142
+ break
143
+ stripped = candidate.strip()
144
+ if not stripped.startswith("- "):
145
+ raise ValueError(f"Unsupported multiline frontmatter value for {key}: {candidate}")
146
+ items.append(strip_quotes(stripped[2:]))
147
+ index += 1
148
+ frontmatter[key] = items
149
+ continue
150
+ if value.startswith("[") and value.endswith("]"):
151
+ frontmatter[key] = parse_inline_list(value)
152
+ else:
153
+ scalar = strip_quotes(value)
154
+ frontmatter[key] = int(scalar) if re.fullmatch(r"-?\d+", scalar) else scalar
155
+ index += 1
156
+ return frontmatter
157
+
158
+
159
+def parse_frontmatter(text: str) -> dict[str, Any]:
160
+ if yaml is not None:
161
+ try:
162
+ data = yaml.safe_load(text) or {}
163
+ except Exception:
164
+ data = parse_frontmatter_fallback(text)
165
+ else:
166
+ if not isinstance(data, dict):
167
+ raise ValueError("YAML frontmatter must be a mapping.")
168
+ return data
169
+ return parse_frontmatter_fallback(text)
170
+
171
+
172
+def extract_frontmatter(text: str) -> tuple[dict[str, Any], str]:
173
+ match = FRONTMATTER_PATTERN.match(text)
174
+ if not match:
175
+ raise ValueError("Analysis output is missing YAML frontmatter.")
176
+ frontmatter_text, body = match.groups()
177
+ return parse_frontmatter(frontmatter_text), body
178
+
179
+
180
+def validate_string_field(frontmatter: dict[str, Any], field: str, errors: list[str]) -> None:
181
+ value = frontmatter.get(field)
182
+ if value is None:
183
+ return
184
+ if not isinstance(value, str) or not value.strip():
185
+ errors.append(f"{field} must be a non-empty string.")
186
+
187
+
188
+def validate_integer_field(frontmatter: dict[str, Any], field: str, errors: list[str], *, minimum: int = 0) -> None:
189
+ value = frontmatter.get(field)
190
+ if value is None:
191
+ return
192
+ if isinstance(value, bool) or not isinstance(value, int):
193
+ errors.append(f"{field} must be an integer.")
194
+ return
195
+ if value < minimum:
196
+ errors.append(f"{field} must be at least {minimum}.")
197
+
198
+
199
+def validate_string_list(
200
+ frontmatter: dict[str, Any],
201
+ field: str,
202
+ errors: list[str],
203
+ *,
204
+ minimum: int | None = None,
205
+ maximum: int | None = None,
206
+ includes: str | None = None,
207
+) -> None:
208
+ value = frontmatter.get(field)
209
+ if value is None:
210
+ return
211
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
212
+ errors.append(f"{field} must be an array of strings.")
213
+ return
214
+ if minimum is not None and len(value) < minimum:
215
+ errors.append(f"{field} must contain at least {minimum} items.")
216
+ if maximum is not None and len(value) > maximum:
217
+ errors.append(f"{field} must contain at most {maximum} items.")
218
+ if includes is not None and includes not in value:
219
+ errors.append(f"{field} must include {includes!r}.")
220
+
221
+
222
+def find_missing_headings(body: str) -> list[str]:
223
+ headings = [f"{level} {title.strip()}" for level, title in HEADING_PATTERN.findall(body)]
224
+ missing: list[str] = []
225
+ position = -1
226
+ for required in REQUIRED_HEADINGS:
227
+ try:
228
+ position = headings.index(required, position + 1)
229
+ except ValueError:
230
+ missing.append(required)
231
+ return missing
232
+
233
+
234
+def validate_analysis(text: str, raw_payload: dict[str, Any], current_datetime: str) -> tuple[list[str], int]:
235
+ errors: list[str] = []
236
+ try:
237
+ frontmatter, body = extract_frontmatter(text)
238
+ except ValueError as exc:
239
+ return [str(exc)], 0
240
+
241
+ missing_fields = [field for field in REQUIRED_FIELDS if field not in frontmatter]
242
+ if missing_fields:
243
+ errors.append(f"Missing frontmatter fields: {', '.join(missing_fields)}")
244
+
245
+ extra_fields = sorted(set(frontmatter) - set(REQUIRED_FIELDS))
246
+ if extra_fields:
247
+ errors.append(f"Unexpected frontmatter fields: {', '.join(extra_fields)}")
248
+
249
+ validate_string_field(frontmatter, "title", errors)
250
+ validate_string_field(frontmatter, "week", errors)
251
+ validate_string_field(frontmatter, "top_repo", errors)
252
+ validate_string_field(frontmatter, "summary", errors)
253
+ validate_string_list(frontmatter, "tags", errors, minimum=3, maximum=8)
254
+ validate_string_list(frontmatter, "categories", errors, includes="weekly")
255
+ validate_integer_field(frontmatter, "year", errors)
256
+ validate_integer_field(frontmatter, "repos_featured", errors)
257
+ validate_integer_field(frontmatter, "stars_tracked", errors)
258
+ validate_integer_field(frontmatter, "quality_score", errors)
259
+
260
+ quality_score = frontmatter.get("quality_score")
261
+ if isinstance(quality_score, int) and quality_score < 60:
262
+ errors.append("quality_score must be at least 60.")
263
+
264
+ expected_week = raw_payload.get("week")
265
+ week_match = WEEK_PATTERN.fullmatch(expected_week) if isinstance(expected_week, str) else None
266
+ expected_year = int(week_match.group("year")) if week_match else None
267
+ if frontmatter.get("week") != expected_week:
268
+ errors.append(f"week must match raw payload week {expected_week!r}.")
269
+ if expected_year is not None and frontmatter.get("year") != expected_year:
270
+ errors.append(f"year must match the raw payload week year ({expected_year}).")
271
+
272
+ date_value = frontmatter.get("date")
273
+ if date_value is None:
274
+ pass
275
+ else:
276
+ try:
277
+ analysis_datetime = parse_datetime(date_value)
278
+ run_datetime = parse_datetime(current_datetime)
279
+ except (TypeError, ValueError) as exc:
280
+ errors.append(f"date must be a valid ISO 8601 timestamp: {exc}")
281
+ else:
282
+ if analysis_datetime.astimezone(UTC) != run_datetime.astimezone(UTC):
283
+ errors.append("date must match the current run timestamp.")
284
+ if isinstance(expected_week, str) and week_slug(analysis_datetime) != expected_week:
285
+ errors.append(f"date must fall within raw payload week {expected_week}.")
286
+
287
+ top_repo = frontmatter.get("top_repo")
288
+ if isinstance(top_repo, str) and top_repo and not TOP_REPO_PATTERN.fullmatch(top_repo):
289
+ errors.append("top_repo must use owner/repo format.")
290
+
291
+ missing_headings = find_missing_headings(body)
292
+ if missing_headings:
293
+ for heading in missing_headings:
294
+ errors.append(f"Missing required section heading: {heading}")
295
+
296
+ word_count = len(WORD_PATTERN.findall(body))
297
+ if word_count < 200:
298
+ errors.append(f"Analysis body must be at least 200 words; found {word_count}.")
299
+
300
+ lower_body = body.lower()
301
+ for marker in RAW_MARKERS:
302
+ if marker in lower_body:
303
+ errors.append(f"Analysis body contains prohibited marker: {marker}")
304
+ for pattern, description in PLACEHOLDER_PATTERNS:
305
+ if pattern.search(body):
306
+ errors.append(f"Analysis body contains prohibited placeholder marker: {description}")
307
+
308
+ return errors, word_count
309
+
310
+
311
+def fail(errors: list[str], summary_path: str | None) -> None:
312
+ if summary_path:
313
+ with open(summary_path, "a", encoding="utf-8") as handle:
314
+ handle.write("## Analysis quality gate failed\n")
315
+ for error in errors:
316
+ handle.write(f"- {error}\n")
317
+ for error in errors:
318
+ print(error, file=sys.stderr)
319
+ raise SystemExit(1)
320
+
321
+
322
+def report_success(path: Path, source: str, word_count: int, summary_path: str | None) -> None:
323
+ message = f"✅ Analysis quality gate passed for {path.name} via {source} ({word_count} words)."
324
+ print(message)
325
+ if summary_path:
326
+ with open(summary_path, "a", encoding="utf-8") as handle:
327
+ handle.write("## Analysis quality gate passed\n")
328
+ handle.write(f"- File: `{path}`\n")
329
+ handle.write(f"- Source: `{source}`\n")
330
+ handle.write(f"- Word count: `{word_count}`\n")
331
+
332
+
333
+def main(argv: list[str] | None = None) -> int:
334
+ args = parse_args(argv)
335
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
336
+
337
+ if not args.analysis_file.exists():
338
+ fail([f"Missing analysis output: {args.analysis_file}"], summary_path)
339
+
340
+ text = args.analysis_file.read_text(encoding="utf-8")
341
+ raw_payload = load_json(args.raw_json)
342
+ errors, word_count = validate_analysis(text, raw_payload, args.current_datetime)
343
+ if errors:
344
+ fail(errors, summary_path)
345
+
346
+ report_success(args.analysis_file, args.source, word_count, summary_path)
347
+ return 0
348
+
349
+
350
+if __name__ == "__main__":
351
+ raise SystemExit(main())
scripts/analyze_fallback.py
new
+179
@@ -0,0 +1,179 @@
1
+#!/usr/bin/env python3
2
+from __future__ import annotations
3
+
4
+import argparse
5
+import json
6
+import os
7
+import sys
8
+from pathlib import Path
9
+from typing import Any
10
+from urllib import error, request
11
+
12
+ROOT = Path(__file__).resolve().parent.parent
13
+DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "analyze-weekly.md"
14
+DEFAULT_ANALYZED_DIR = ROOT / "data" / "analyzed"
15
+DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
16
+DEFAULT_MODELS_MODEL = "openai/gpt-4.1"
17
+DEFAULT_MODELS_TIMEOUT = 30
18
+
19
+
20
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
21
+ parser = argparse.ArgumentParser(description="Fallback weekly analysis via GitHub Models API.")
22
+ parser.add_argument("--raw-json", required=True, type=Path, help="Path to the weekly raw JSON payload.")
23
+ parser.add_argument("--output", required=True, type=Path, help="Path to write the analyzed markdown output.")
24
+ parser.add_argument("--current-datetime", required=True, help="ISO-8601 timestamp for the analysis run.")
25
+ parser.add_argument(
26
+ "--prompt-template",
27
+ type=Path,
28
+ default=DEFAULT_PROMPT_TEMPLATE,
29
+ help="Prompt template path (defaults to prompts/analyze-weekly.md).",
30
+ )
31
+ parser.add_argument(
32
+ "--analyzed-dir",
33
+ type=Path,
34
+ default=DEFAULT_ANALYZED_DIR,
35
+ help="Directory containing prior weekly summaries.",
36
+ )
37
+ parser.add_argument(
38
+ "--print-prompt",
39
+ action="store_true",
40
+ help="Render the prompt to stdout without calling GitHub Models.",
41
+ )
42
+ return parser.parse_args(argv)
43
+
44
+
45
+def load_json(path: Path) -> dict[str, Any]:
46
+ return json.loads(path.read_text(encoding="utf-8"))
47
+
48
+
49
+def find_previous_summary(current_week: str, analyzed_dir: Path) -> Path | None:
50
+ if not analyzed_dir.exists():
51
+ return None
52
+
53
+ candidates = []
54
+ for path in analyzed_dir.glob("*-summary.md"):
55
+ week = path.name.removesuffix("-summary.md")
56
+ if week < current_week:
57
+ candidates.append(path)
58
+ return max(candidates, default=None)
59
+
60
+
61
+def render_prompt(
62
+ *,
63
+ prompt_template_path: Path,
64
+ raw_json_path: Path,
65
+ output_path: Path,
66
+ current_datetime: str,
67
+ analyzed_dir: Path,
68
+) -> str:
69
+ payload = load_json(raw_json_path)
70
+ current_week = payload["week"]
71
+ previous_summary_path = find_previous_summary(current_week, analyzed_dir)
72
+ previous_summary_content = previous_summary_path.read_text(encoding="utf-8") if previous_summary_path else ""
73
+
74
+ prompt = prompt_template_path.read_text(encoding="utf-8")
75
+ replacements = {
76
+ "{{CURRENT_DATETIME}}": current_datetime,
77
+ "{{RAW_JSON_PATH}}": str(raw_json_path),
78
+ "{{OUTPUT_PATH}}": str(output_path),
79
+ "{{PREVIOUS_SUMMARY_PATH_OR_NONE}}": str(previous_summary_path) if previous_summary_path else "None",
80
+ "{{RAW_JSON_CONTENT}}": raw_json_path.read_text(encoding="utf-8").strip(),
81
+ "{{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}": previous_summary_content.strip(),
82
+ }
83
+ for needle, value in replacements.items():
84
+ prompt = prompt.replace(needle, value)
85
+ return prompt
86
+
87
+
88
+def extract_markdown(response_payload: dict[str, Any]) -> str:
89
+ choices = response_payload.get("choices") or []
90
+ if not choices:
91
+ raise ValueError("GitHub Models response did not include any choices.")
92
+
93
+ message = choices[0].get("message") or {}
94
+ content = message.get("content")
95
+
96
+ if isinstance(content, str):
97
+ return content.strip() + "\n"
98
+
99
+ if isinstance(content, list):
100
+ parts: list[str] = []
101
+ for item in content:
102
+ if isinstance(item, dict):
103
+ text = item.get("text") or item.get("output_text")
104
+ if text:
105
+ parts.append(text)
106
+ if parts:
107
+ return "\n".join(parts).strip() + "\n"
108
+
109
+ text = choices[0].get("text")
110
+ if isinstance(text, str) and text.strip():
111
+ return text.strip() + "\n"
112
+
113
+ raise ValueError("GitHub Models response did not contain markdown output.")
114
+
115
+
116
+def call_github_models(prompt: str) -> str:
117
+ token = os.environ.get("GITHUB_TOKEN")
118
+ if not token:
119
+ raise RuntimeError("GITHUB_TOKEN is required for GitHub Models fallback.")
120
+
121
+ endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT)
122
+ model = os.environ.get("GITHUB_MODELS_MODEL", DEFAULT_MODELS_MODEL)
123
+ timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
124
+ payload = {
125
+ "model": model,
126
+ "messages": [
127
+ {
128
+ "role": "user",
129
+ "content": prompt,
130
+ }
131
+ ],
132
+ "temperature": 0.3,
133
+ }
134
+ body = json.dumps(payload).encode("utf-8")
135
+ req = request.Request(
136
+ endpoint,
137
+ data=body,
138
+ headers={
139
+ "Authorization": f"Bearer {token}",
140
+ "Content-Type": "application/json",
141
+ "Accept": "application/json",
142
+ },
143
+ method="POST",
144
+ )
145
+
146
+ try:
147
+ with request.urlopen(req, timeout=timeout) as response:
148
+ response_payload = json.load(response)
149
+ except error.HTTPError as exc: # pragma: no cover - exercised via message formatting
150
+ detail = exc.read().decode("utf-8", errors="replace")
151
+ raise RuntimeError(f"GitHub Models API request failed ({exc.code}): {detail}") from exc
152
+ except error.URLError as exc: # pragma: no cover - network failures are environment-specific
153
+ raise RuntimeError(f"GitHub Models API request failed: {exc.reason}") from exc
154
+
155
+ return extract_markdown(response_payload)
156
+
157
+
158
+def main(argv: list[str] | None = None) -> int:
159
+ args = parse_args(argv)
160
+ prompt = render_prompt(
161
+ prompt_template_path=args.prompt_template,
162
+ raw_json_path=args.raw_json,
163
+ output_path=args.output,
164
+ current_datetime=args.current_datetime,
165
+ analyzed_dir=args.analyzed_dir,
166
+ )
167
+
168
+ if args.print_prompt:
169
+ print(prompt)
170
+ return 0
171
+
172
+ markdown = call_github_models(prompt)
173
+ args.output.parent.mkdir(parents=True, exist_ok=True)
174
+ args.output.write_text(markdown, encoding="utf-8")
175
+ return 0
176
+
177
+
178
+if __name__ == "__main__":
179
+ raise SystemExit(main())
tests/test_analysis_gate.py
new
+172
@@ -0,0 +1,172 @@
1
+import unittest
2
+
3
+import scripts.analysis_gate as analysis_gate
4
+
5
+
6
+RAW_PAYLOAD = {"week": "2026-W21"}
7
+CURRENT_DATETIME = "2026-05-18T00:00:00Z"
8
+
9
+
10
+def make_body(*, trending_heading: str = "## Trending This Week", include_todo_app: bool = False) -> str:
11
+ notable = " ".join(
12
+ [
13
+ "This section evaluates durable launches, compares architecture choices, and explains why the strongest repositories matter for practitioners tracking real engineering movement."
14
+ ]
15
+ * 4
16
+ )
17
+ trending = " ".join(
18
+ [
19
+ "Attention moved toward practical tooling, but the narrative distinguishes genuine momentum from incumbents that simply remain popular because they already dominate conversation."
20
+ ]
21
+ * 4
22
+ )
23
+ signal = " ".join(
24
+ [
25
+ "The durable pattern is disciplined infrastructure work, careful developer experience improvements, and credible evidence that teams are solving recurring operational pain."
26
+ ]
27
+ * 3
28
+ )
29
+ noise = " ".join(
30
+ [
31
+ "The weak pattern is wrapper churn, shallow agent branding, and launches that borrow attention without demonstrating technical substance or ecosystem fit."
32
+ ]
33
+ * 3
34
+ )
35
+ gaps = " ".join(
36
+ [
37
+ "What is missing is more progress on observability, testing ergonomics, and dependable security tooling for smaller teams that still need production discipline."
38
+ ]
39
+ * 3
40
+ )
41
+ conclusion = " ".join(
42
+ [
43
+ "The week matters because it shows teams rewarding grounded software that reduces toil, while hype-heavy experiments still struggle to prove lasting value."
44
+ ]
45
+ * 2
46
+ )
47
+ if include_todo_app:
48
+ conclusion += " Several repositories mention todo apps as legitimate examples rather than placeholder notes."
49
+ return f"""
50
+## Notable New Repositories
51
+
52
+{notable}
53
+
54
+{trending_heading}
55
+
56
+{trending}
57
+
58
+## Trend Analysis
59
+
60
+### Signal
61
+
62
+{signal}
63
+
64
+### Noise
65
+
66
+{noise}
67
+
68
+## What's Missing
69
+
70
+### Gaps
71
+
72
+{gaps}
73
+
74
+## Conclusion
75
+
76
+{conclusion}
77
+""".strip()
78
+
79
+
80
+def make_analysis(frontmatter: str, body: str) -> str:
81
+ return f"---\n{frontmatter}\n---\n\n{body}\n"
82
+
83
+
84
+VALID_FRONTMATTER = '''title: "Week 21, 2026 Analysis"
85
+date: 2026-05-18T00:00:00Z
86
+week: 2026-W21
87
+year: 2026
88
+tags:
89
+ - ai
90
+ - agents
91
+ - infrastructure
92
+categories:
93
+ - weekly
94
+repos_featured: 9
95
+stars_tracked: 1200
96
+top_repo: owner/repo
97
+quality_score: 82
98
+summary: "A grounded week focused on practical tools."'''.strip()
99
+
100
+
101
+class AnalysisGateTests(unittest.TestCase):
102
+ def test_validate_analysis_accepts_block_style_lists(self) -> None:
103
+ errors, word_count = analysis_gate.validate_analysis(
104
+ make_analysis(VALID_FRONTMATTER, make_body()),
105
+ RAW_PAYLOAD,
106
+ CURRENT_DATETIME,
107
+ )
108
+
109
+ self.assertEqual(errors, [])
110
+ self.assertGreaterEqual(word_count, 200)
111
+
112
+ def test_validate_analysis_rejects_wrong_week_date_and_types(self) -> None:
113
+ invalid_frontmatter = '''title: "Week 21, 2026 Analysis"
114
+date: 2026-05-12T00:00:00Z
115
+week: 2026-W20
116
+year: "2026"
117
+tags: weekly
118
+categories:
119
+ - analysis
120
+repos_featured: 9
121
+stars_tracked: 1200
122
+top_repo: owner/repo
123
+quality_score: 82
124
+summary: "A grounded week focused on practical tools."'''.strip()
125
+
126
+ errors, _ = analysis_gate.validate_analysis(
127
+ make_analysis(invalid_frontmatter, make_body()),
128
+ RAW_PAYLOAD,
129
+ CURRENT_DATETIME,
130
+ )
131
+
132
+ self.assertIn("week must match raw payload week '2026-W21'.", errors)
133
+ self.assertIn("year must be an integer.", errors)
134
+ self.assertIn("tags must be an array of strings.", errors)
135
+ self.assertIn("categories must include 'weekly'.", errors)
136
+ self.assertIn("date must match the current run timestamp.", errors)
137
+ self.assertIn("date must fall within raw payload week 2026-W21.", errors)
138
+
139
+ def test_validate_analysis_requires_real_heading_lines(self) -> None:
140
+ body = make_body(
141
+ trending_heading="The prose references ## Trending This Week without creating a heading line.",
142
+ )
143
+ errors, _ = analysis_gate.validate_analysis(
144
+ make_analysis(VALID_FRONTMATTER, body),
145
+ RAW_PAYLOAD,
146
+ CURRENT_DATETIME,
147
+ )
148
+
149
+ self.assertIn("Missing required section heading: ## Trending This Week", errors)
150
+
151
+ def test_validate_analysis_allows_legitimate_todo_mentions(self) -> None:
152
+ errors, _ = analysis_gate.validate_analysis(
153
+ make_analysis(VALID_FRONTMATTER, make_body(include_todo_app=True)),
154
+ RAW_PAYLOAD,
155
+ CURRENT_DATETIME,
156
+ )
157
+
158
+ self.assertEqual(errors, [])
159
+
160
+ def test_validate_analysis_rejects_todo_placeholders(self) -> None:
161
+ body = make_body() + "\n\nTODO: replace this closing note.\n"
162
+ errors, _ = analysis_gate.validate_analysis(
163
+ make_analysis(VALID_FRONTMATTER, body),
164
+ RAW_PAYLOAD,
165
+ CURRENT_DATETIME,
166
+ )
167
+
168
+ self.assertIn("Analysis body contains prohibited placeholder marker: TODO placeholder marker", errors)
169
+
170
+
171
+if __name__ == "__main__":
172
+ unittest.main()
tests/test_analyze_fallback.py
new
+127
@@ -0,0 +1,127 @@
1
+import io
2
+import json
3
+import tempfile
4
+import unittest
5
+from pathlib import Path
6
+from unittest import mock
7
+
8
+import scripts.analyze_fallback as analyze_fallback
9
+
10
+
11
+class _FakeHTTPResponse(io.BytesIO):
12
+ def __enter__(self):
13
+ return self
14
+
15
+ def __exit__(self, exc_type, exc, tb):
16
+ self.close()
17
+ return False
18
+
19
+
20
+class AnalyzeFallbackTests(unittest.TestCase):
21
+ def test_find_previous_summary_picks_latest_prior_week(self) -> None:
22
+ tests_root = Path(__file__).resolve().parent
23
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
24
+ analyzed_dir = Path(tmpdir) / "analyzed"
25
+ analyzed_dir.mkdir()
26
+ (analyzed_dir / "2026-W19-summary.md").write_text("old\n", encoding="utf-8")
27
+ (analyzed_dir / "2026-W20-summary.md").write_text("latest\n", encoding="utf-8")
28
+ (analyzed_dir / "2026-W21-summary.md").write_text("current\n", encoding="utf-8")
29
+ (analyzed_dir / "2026-W22-summary.md").write_text("future\n", encoding="utf-8")
30
+
31
+ previous = analyze_fallback.find_previous_summary("2026-W21", analyzed_dir)
32
+
33
+ self.assertEqual(previous, analyzed_dir / "2026-W20-summary.md")
34
+
35
+ def test_render_prompt_replaces_all_placeholders(self) -> None:
36
+ tests_root = Path(__file__).resolve().parent
37
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
38
+ base = Path(tmpdir)
39
+ raw_path = base / "data" / "raw" / "2026-W21.json"
40
+ analyzed_dir = base / "data" / "analyzed"
41
+ prompt_template = base / "prompt.md"
42
+ output_path = analyzed_dir / "2026-W21-summary.md"
43
+ raw_path.parent.mkdir(parents=True)
44
+ analyzed_dir.mkdir(parents=True)
45
+
46
+ raw_path.write_text(json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}), encoding="utf-8")
47
+ (analyzed_dir / "2026-W20-summary.md").write_text("previous summary", encoding="utf-8")
48
+ prompt_template.write_text(
49
+ "date={{CURRENT_DATETIME}}\nraw={{RAW_JSON_PATH}}\nout={{OUTPUT_PATH}}\nprev={{PREVIOUS_SUMMARY_PATH_OR_NONE}}\njson={{RAW_JSON_CONTENT}}\nbody={{PREVIOUS_SUMMARY_CONTENT_OR_EMPTY}}\n",
50
+ encoding="utf-8",
51
+ )
52
+
53
+ prompt = analyze_fallback.render_prompt(
54
+ prompt_template_path=prompt_template,
55
+ raw_json_path=raw_path,
56
+ output_path=output_path,
57
+ current_datetime="2026-05-18T13:05:53.678+02:00",
58
+ analyzed_dir=analyzed_dir,
59
+ )
60
+
61
+ self.assertIn("date=2026-05-18T13:05:53.678+02:00", prompt)
62
+ self.assertIn(f"raw={raw_path}", prompt)
63
+ self.assertIn(f"out={output_path}", prompt)
64
+ self.assertIn("prev=", prompt)
65
+ self.assertIn("previous summary", prompt)
66
+ self.assertIn('"week": "2026-W21"', prompt)
67
+ self.assertNotIn("{{CURRENT_DATETIME}}", prompt)
68
+
69
+ def test_extract_markdown_supports_message_parts(self) -> None:
70
+ payload = {
71
+ "choices": [
72
+ {
73
+ "message": {
74
+ "content": [
75
+ {"type": "output_text", "text": "part one"},
76
+ {"type": "output_text", "output_text": "part two"},
77
+ ]
78
+ }
79
+ }
80
+ ]
81
+ }
82
+
83
+ markdown = analyze_fallback.extract_markdown(payload)
84
+
85
+ self.assertEqual(markdown, "part one\npart two\n")
86
+
87
+ def test_main_writes_fallback_output(self) -> None:
88
+ tests_root = Path(__file__).resolve().parent
89
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
90
+ base = Path(tmpdir)
91
+ raw_path = base / "data" / "raw" / "2026-W21.json"
92
+ prompt_template = base / "prompt.md"
93
+ output_path = base / "data" / "analyzed" / "2026-W21-summary.md"
94
+ raw_path.parent.mkdir(parents=True)
95
+ output_path.parent.mkdir(parents=True)
96
+ raw_path.write_text(json.dumps({"week": "2026-W21", "new_repos": [], "trending_repos": []}), encoding="utf-8")
97
+ prompt_template.write_text("{{RAW_JSON_CONTENT}}", encoding="utf-8")
98
+
99
+ response = _FakeHTTPResponse(
100
+ json.dumps({"choices": [{"message": {"content": "# Summary\n"}}]}).encode("utf-8")
101
+ )
102
+
103
+ with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False), mock.patch.object(
104
+ analyze_fallback.request, "urlopen", return_value=response
105
+ ) as urlopen_mock:
106
+ exit_code = analyze_fallback.main(
107
+ [
108
+ "--raw-json",
109
+ str(raw_path),
110
+ "--output",
111
+ str(output_path),
112
+ "--current-datetime",
113
+ "2026-05-18T13:05:53.678+02:00",
114
+ "--prompt-template",
115
+ str(prompt_template),
116
+ "--analyzed-dir",
117
+ str(output_path.parent),
118
+ ]
119
+ )
120
+
121
+ self.assertEqual(exit_code, 0)
122
+ self.assertEqual(output_path.read_text(encoding="utf-8"), "# Summary\n")
123
+ self.assertEqual(urlopen_mock.call_args.kwargs["timeout"], analyze_fallback.DEFAULT_MODELS_TIMEOUT)
124
+
125
+
126
+if __name__ == "__main__":
127
+ unittest.main()