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()