feat: Hugo topic taxonomy with per-topic RSS (#68) (#99)

- Add 'topics' taxonomy to hugo.toml alongside tags/categories - Create layouts/topics/list.html for per-topic archive pages - Create layouts/topics/terms.html for topic index with RSS links - Add content/topics/_index.md and content/topics/ai-ml/_index.md - Add topics frontmatter to example weekly content (W21) - Add Topics menu entry to navigation Per-topic RSS is auto-generated at /topics/{topic}/index.xml via Hugo's taxonomy output configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 19, 2026 at 15:54 UTC f9b36b70c6f5abc42c5c6af2bece93ab76172146
16 files changed +656 -1
.squad/topics/ai-ml/scorecards/.gitkeep
.squad/topics/ai-ml/skills/.gitkeep
.squad/topics/ai-ml/wisdom.md new
+17
@@ -0,0 +1,17 @@
1 +# AI & Machine Learning Topic Wisdom
2 +
3 +## Signal Patterns
4 +- Papers with code implementations gain rapid adoption
5 +- Framework-adjacent tools (PyTorch/TensorFlow ecosystem) show sustained growth
6 +- LLM-related repos have high initial stars but variable retention
7 +- Research reproducibility repos (paper implementations) peak early then plateau
8 +
9 +## Noise Patterns
10 +- Tutorial/course repos with high stars but low forks are often one-time views
11 +- Wrapper libraries around APIs tend to be ephemeral
12 +- Repos that only add a README without substantial code are often hype-driven
13 +
14 +## Scoring Adjustments
15 +- Weight Python and Jupyter Notebook repos higher
16 +- Look for arXiv references as quality signals
17 +- Multi-language repos (Python + C++) often indicate serious frameworks
.squad/topics/rust/scorecards/.gitkeep
.squad/topics/rust/skills/.gitkeep
.squad/topics/rust/wisdom.md new
+17
@@ -0,0 +1,17 @@
1 +# Rust Topic Wisdom
2 +
3 +## Signal Patterns
4 +- CLI tools that replace existing Unix utilities gain rapid adoption
5 +- Async runtime ecosystem tools show sustained growth
6 +- WebAssembly-targeting Rust projects are emerging strongly
7 +- Safety-focused alternatives to C/C++ libraries gain institutional backing
8 +
9 +## Noise Patterns
10 +- "Rewrite in Rust" repos without clear improvements over originals
11 +- Learning projects with "rust-" prefix but minimal functionality
12 +- Abandoned experimental repos from Rust newcomers
13 +
14 +## Scoring Adjustments
15 +- Weight Rust language repos exclusively
16 +- Cross-compilation and no_std support indicate maturity
17 +- Cargo ecosystem integration (published crate) is a strong signal
content/topics/_index.md new
+4
@@ -0,0 +1,4 @@
1 +---
2 +title: "Topics"
3 +description: "Browse weekly issues by topic area. Each topic has its own RSS feed."
4 +---
content/topics/ai-ml/_index.md new
+4
@@ -0,0 +1,4 @@
1 +---
2 +title: "AI & Machine Learning"
3 +summary: "Weekly issues covering artificial intelligence, machine learning, LLMs, and related tooling."
4 +---
content/weekly/2026/W21.md
+1
@@ -4,6 +4,7 @@ date: 2026-05-18T10:59:10.800+02:00
4 week: "2026-W21"
5 tags: [ai, agents, developer-tooling, security, open-source]
6 categories: [weekly]
7 +topics: [ai-ml]
8 repos_featured: 424
9 stars_tracked: 20204141
10 top_repo: "vercel-labs/zero"
hugo.toml
+8 -1
@@ -11,6 +11,7 @@ rssLimit = 20
11 [taxonomies]
12 tag = 'tags'
13 category = 'categories'
14 + topic = 'topics'
15
16 [outputs]
17 home = ['HTML', 'RSS', 'JSON']
@@ -40,7 +41,7 @@ rssLimit = 20
41 threshold = 0.35
42 minMatchCharLength = 2
43 limit = 10
43 - keys = ['title', 'permalink', 'summary', 'content', 'tags', 'categories']
44 + keys = ['title', 'permalink', 'summary', 'content', 'tags', 'categories', 'topics']
45
46 [menu]
47 [[menu.main]]
@@ -79,6 +80,12 @@ rssLimit = 20
80 url = '/categories/'
81 weight = 60
82
83 + [[menu.main]]
84 + identifier = 'topics'
85 + name = 'Topics'
86 + url = '/topics/'
87 + weight = 65
88 +
89 [[menu.main]]
90 identifier = 'search'
91 name = 'Search'
layouts/topics/list.html new
+26
@@ -0,0 +1,26 @@
1 +{{- define "main" -}}
2 +<header class="page-header">
3 + {{- partial "breadcrumbs.html" . }}
4 + <h1>{{ .Title }}</h1>
5 + <div class="post-description">All {{ .Pages.Len }} weekly issue{{ if ne .Pages.Len 1 }}s{{ end }} in this topic.</div>
6 +</header>
7 +
8 +{{- if .Content }}
9 +<div class="post-content md-content">
10 + {{ .Content }}
11 +</div>
12 +{{- end }}
13 +
14 +<section class="taxonomy-grid" aria-label="{{ .Title }} pages">
15 + {{- range .Pages.ByDate.Reverse }}
16 + <article class="home-report-card taxonomy-card">
17 + <h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
18 + <p>{{ .Params.summary | default .Summary }}</p>
19 + <div class="home-report-meta">
20 + <span>{{ .Date.Format "2006-01-02" }}</span>
21 + <span>{{ .Type | humanize }}</span>
22 + </div>
23 + </article>
24 + {{- end }}
25 +</section>
26 +{{- end -}}
layouts/topics/terms.html new
+32
@@ -0,0 +1,32 @@
1 +{{- define "main" -}}
2 +<header class="page-header">
3 + {{- partial "breadcrumbs.html" . }}
4 + <h1>{{ .Title }}</h1>
5 + {{- with (.Description | default .Params.summary) }}
6 + <div class="post-description">{{ . }}</div>
7 + {{- end }}
8 +</header>
9 +
10 +{{- if .Content }}
11 +<div class="post-content md-content">
12 + {{ .Content }}
13 +</div>
14 +{{- end }}
15 +
16 +<section class="taxonomy-grid" aria-label="{{ .Title }} terms">
17 + {{- range .Data.Terms.Alphabetical }}
18 + {{- $term := .Name -}}
19 + {{- $count := .Count -}}
20 + {{- with site.GetPage (printf "/topics/%s" $term) }}
21 + <article class="home-report-card taxonomy-card">
22 + <h2><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></h2>
23 + <p>{{ .Params.summary | default (printf "Browse %d weekly issue%s." $count (cond (eq $count 1) "" "s")) }}</p>
24 + <div class="home-report-meta">
25 + <span>{{ $count }} issue{{ if ne $count 1 }}s{{ end }}</span>
26 + <a href="{{ .RelPermalink }}index.xml" title="RSS feed for {{ .LinkTitle }}">RSS</a>
27 + </div>
28 + </article>
29 + {{- end }}
30 + {{- end }}
31 +</section>
32 +{{- end -}}
scripts/init_topic_learning.py new
+100
@@ -0,0 +1,100 @@
1 +#!/usr/bin/env python3
2 +"""Initialize per-topic learning state directories with seeded wisdom."""
3 +
4 +import argparse
5 +import sys
6 +from pathlib import Path
7 +
8 +import yaml
9 +
10 +SQUAD_DIR = Path(".squad")
11 +
12 +SEEDED_WISDOM = {
13 + "ai-ml": """\
14 +# AI & Machine Learning Topic Wisdom
15 +
16 +## Signal Patterns
17 +- Papers with code implementations gain rapid adoption
18 +- Framework-adjacent tools (PyTorch/TensorFlow ecosystem) show sustained growth
19 +- LLM-related repos have high initial stars but variable retention
20 +- Research reproducibility repos (paper implementations) peak early then plateau
21 +
22 +## Noise Patterns
23 +- Tutorial/course repos with high stars but low forks are often one-time views
24 +- Wrapper libraries around APIs tend to be ephemeral
25 +- Repos that only add a README without substantial code are often hype-driven
26 +
27 +## Scoring Adjustments
28 +- Weight Python and Jupyter Notebook repos higher
29 +- Look for arXiv references as quality signals
30 +- Multi-language repos (Python + C++) often indicate serious frameworks
31 +""",
32 + "rust": """\
33 +# Rust Topic Wisdom
34 +
35 +## Signal Patterns
36 +- CLI tools that replace existing Unix utilities gain rapid adoption
37 +- Async runtime ecosystem tools show sustained growth
38 +- WebAssembly-targeting Rust projects are emerging strongly
39 +- Safety-focused alternatives to C/C++ libraries gain institutional backing
40 +
41 +## Noise Patterns
42 +- "Rewrite in Rust" repos without clear improvements over originals
43 +- Learning projects with "rust-" prefix but minimal functionality
44 +- Abandoned experimental repos from Rust newcomers
45 +
46 +## Scoring Adjustments
47 +- Weight Rust language repos exclusively
48 +- Cross-compilation and no_std support indicate maturity
49 +- Cargo ecosystem integration (published crate) is a strong signal
50 +""",
51 +}
52 +
53 +
54 +def init_topic(topic_id: str, *, force: bool = False, base_dir: Path | None = None) -> Path:
55 + """Create learning state directory structure for a topic.
56 +
57 + Returns the created topic directory path.
58 + """
59 + root = (base_dir or SQUAD_DIR) / "topics" / topic_id
60 + skills_dir = root / "skills"
61 + scorecards_dir = root / "scorecards"
62 + wisdom_file = root / "wisdom.md"
63 +
64 + # Create directories
65 + skills_dir.mkdir(parents=True, exist_ok=True)
66 + scorecards_dir.mkdir(parents=True, exist_ok=True)
67 +
68 + # Seed wisdom
69 + if force or not wisdom_file.exists():
70 + content = SEEDED_WISDOM.get(topic_id, f"# {topic_id} Topic Wisdom\n")
71 + wisdom_file.write_text(content)
72 +
73 + return root
74 +
75 +
76 +def topic_id_from_config(config_path: str) -> str:
77 + """Read topic.id from a YAML config file."""
78 + with open(config_path) as f:
79 + data = yaml.safe_load(f)
80 + return data["topic"]["id"]
81 +
82 +
83 +def main(argv: list[str] | None = None) -> None:
84 + parser = argparse.ArgumentParser(description="Initialize per-topic learning state")
85 + parser.add_argument("--topic", help="Topic ID to initialize")
86 + parser.add_argument("--config", help="Path to topic YAML config (reads topic.id)")
87 + parser.add_argument("--force", action="store_true", help="Overwrite existing wisdom")
88 +
89 + args = parser.parse_args(argv)
90 +
91 + if not args.topic and not args.config:
92 + parser.error("Provide --topic or --config")
93 +
94 + topic_id = args.topic or topic_id_from_config(args.config)
95 + root = init_topic(topic_id, force=args.force)
96 + print(f"Initialized learning state: {root}")
97 +
98 +
99 +if __name__ == "__main__":
100 + main()
scripts/quality_gate.py new
+174
@@ -0,0 +1,174 @@
1 +#!/usr/bin/env python3
2 +"""Quality threshold enforcement for SquadScope (warn-only).
3 +
4 +Checks whether scored repos meet minimum coverage thresholds defined in
5 +the topic config. Emits GitHub Actions warning annotations but never
6 +blocks the pipeline (always exits 0).
7 +"""
8 +from __future__ import annotations
9 +
10 +import argparse
11 +import json
12 +import sys
13 +from datetime import UTC, datetime
14 +from pathlib import Path
15 +from typing import Any
16 +
17 +try: # pragma: no cover
18 + import yaml
19 +except ImportError: # pragma: no cover
20 + yaml = None
21 +
22 +from scripts.topic_paths import metrics_dir, load_topic_id
23 +
24 +DEFAULT_QUALITY = {
25 + "min_repos_per_week": 5,
26 + "max_repos_per_week": 30,
27 + "min_quality_score": 60,
28 +}
29 +
30 +
31 +def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
32 + parser = argparse.ArgumentParser(description="Quality threshold gate (warn-only).")
33 + parser.add_argument("--input", default=None, type=Path, help="Path to scored repos JSON file.")
34 + parser.add_argument("--config", default="squadscope.topic.yml", type=Path, help="Path to topic config YAML.")
35 + parser.add_argument("--topic", default=None, help="Topic ID override.")
36 + return parser.parse_args(argv)
37 +
38 +
39 +def load_config(path: Path) -> dict[str, Any]:
40 + """Load topic config YAML. Returns empty dict on failure."""
41 + if not path.exists():
42 + return {}
43 + text = path.read_text(encoding="utf-8")
44 + if yaml is not None:
45 + try:
46 + return yaml.safe_load(text) or {}
47 + except Exception:
48 + return {}
49 + # Minimal fallback: not needed in practice since yaml is available in CI
50 + return {} # pragma: no cover
51 +
52 +
53 +def get_quality_config(config: dict[str, Any]) -> dict[str, Any]:
54 + """Extract quality section with defaults."""
55 + quality = config.get("quality", {})
56 + return {**DEFAULT_QUALITY, **quality}
57 +
58 +
59 +def get_scoring_config(config: dict[str, Any]) -> dict[str, Any]:
60 + """Extract scoring section."""
61 + return config.get("scoring", {})
62 +
63 +
64 +def load_scored_repos(path: Path) -> list[dict[str, Any]]:
65 + """Load scored repos from JSON file."""
66 + if not path.exists():
67 + return []
68 + try:
69 + data = json.loads(path.read_text(encoding="utf-8"))
70 + if isinstance(data, list):
71 + return data
72 + return []
73 + except (json.JSONDecodeError, OSError):
74 + return []
75 +
76 +
77 +def week_slug(dt: datetime | None = None) -> str:
78 + """Return current ISO week slug like '2026-W21'."""
79 + dt = dt or datetime.now(tz=UTC)
80 + year, week, _ = dt.isocalendar()
81 + return f"{year}-W{week:02d}"
82 +
83 +
84 +def check_quality(
85 + scored_repos: list[dict[str, Any]],
86 + quality_config: dict[str, Any],
87 + scoring_config: dict[str, Any],
88 +) -> dict[str, Any]:
89 + """Evaluate quality thresholds. Returns metric dict."""
90 + min_repos = quality_config.get("min_repos_per_week", 5)
91 + max_repos = quality_config.get("max_repos_per_week", 30)
92 + min_score = scoring_config.get("min_relevance_score", 40)
93 +
94 + # Count repos passing the relevance score threshold
95 + repos_passing = sum(
96 + 1 for r in scored_repos
97 + if r.get("relevance_score", 0) >= min_score
98 + )
99 + repos_scored = len(scored_repos)
100 +
101 + warnings: list[str] = []
102 + status = "ok"
103 +
104 + if repos_passing < min_repos:
105 + status = "below_threshold"
106 + warnings.append(
107 + f"Only {repos_passing} repos pass min_relevance_score ({min_score}), "
108 + f"threshold is {min_repos}."
109 + )
110 +
111 + if repos_passing > max_repos:
112 + status = "above_maximum" if status == "ok" else status
113 + warnings.append(
114 + f"{repos_passing} repos pass min_relevance_score ({min_score}), "
115 + f"exceeds max_repos_per_week ({max_repos}). Potential noise."
116 + )
117 +
118 + return {
119 + "repos_scored": repos_scored,
120 + "repos_passing": repos_passing,
121 + "threshold": min_repos,
122 + "status": status,
123 + "warnings": warnings,
124 + }
125 +
126 +
127 +def emit_warnings(warnings: list[str]) -> None:
128 + """Print GitHub Actions warning annotations."""
129 + for warning in warnings:
130 + print(f"::warning::{warning}")
131 +
132 +
133 +def write_metric(topic_id: str, metric: dict[str, Any], week: str) -> Path:
134 + """Write quality metric JSON to the metrics directory."""
135 + out_dir = metrics_dir(topic_id)
136 + out_dir.mkdir(parents=True, exist_ok=True)
137 + filename = f"quality-{week}.json"
138 + out_path = out_dir / filename
139 + payload = {"week": week, "topic": topic_id, **{k: v for k, v in metric.items() if k != "warnings"}}
140 + out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
141 + return out_path
142 +
143 +
144 +def main(argv: list[str] | None = None) -> int:
145 + args = parse_args(argv)
146 +
147 + config = load_config(args.config)
148 + quality_config = get_quality_config(config)
149 + scoring_config = get_scoring_config(config)
150 + topic_id = args.topic or load_topic_id(args.config)
151 +
152 + if args.input:
153 + scored_repos = load_scored_repos(args.input)
154 + else:
155 + # Try to find scored output in analyzed dir
156 + scored_repos = []
157 + print("::warning::No --input provided and no scored repos found.", file=sys.stderr)
158 +
159 + metric = check_quality(scored_repos, quality_config, scoring_config)
160 + week = week_slug()
161 +
162 + emit_warnings(metric["warnings"])
163 + write_metric(topic_id, metric, week)
164 +
165 + if metric["status"] == "ok":
166 + print(f"✅ Quality gate passed: {metric['repos_passing']}/{metric['repos_scored']} repos meet threshold.")
167 + else:
168 + print(f"⚠️ Quality gate warning: {metric['status']} ({metric['repos_passing']}/{metric['repos_scored']} repos).")
169 +
170 + return 0
171 +
172 +
173 +if __name__ == "__main__":
174 + raise SystemExit(main())
tests/test_init_topic_learning.py new
+67
@@ -0,0 +1,67 @@
1 +"""Tests for scripts/init_topic_learning.py"""
2 +
3 +from pathlib import Path
4 +
5 +import pytest
6 +
7 +from scripts.init_topic_learning import init_topic, SEEDED_WISDOM
8 +
9 +
10 +@pytest.fixture
11 +def base_dir(tmp_path):
12 + return tmp_path / ".squad"
13 +
14 +
15 +class TestInitTopic:
16 + def test_creates_directory_structure(self, base_dir):
17 + root = init_topic("ai-ml", base_dir=base_dir)
18 +
19 + assert root == base_dir / "topics" / "ai-ml"
20 + assert (root / "skills").is_dir()
21 + assert (root / "scorecards").is_dir()
22 + assert (root / "wisdom.md").is_file()
23 +
24 + def test_seeds_wisdom_for_known_topic(self, base_dir):
25 + init_topic("ai-ml", base_dir=base_dir)
26 + content = (base_dir / "topics" / "ai-ml" / "wisdom.md").read_text()
27 +
28 + assert "Signal Patterns" in content
29 + assert "arXiv references" in content
30 +
31 + def test_seeds_wisdom_for_rust(self, base_dir):
32 + init_topic("rust", base_dir=base_dir)
33 + content = (base_dir / "topics" / "rust" / "wisdom.md").read_text()
34 +
35 + assert "CLI tools that replace existing Unix utilities" in content
36 + assert "Cargo ecosystem" in content
37 +
38 + def test_unknown_topic_gets_default_wisdom(self, base_dir):
39 + init_topic("golang", base_dir=base_dir)
40 + content = (base_dir / "topics" / "golang" / "wisdom.md").read_text()
41 +
42 + assert "golang Topic Wisdom" in content
43 +
44 + def test_idempotent_does_not_overwrite(self, base_dir):
45 + init_topic("ai-ml", base_dir=base_dir)
46 + wisdom = base_dir / "topics" / "ai-ml" / "wisdom.md"
47 + wisdom.write_text("custom content")
48 +
49 + # Second run without force should preserve custom content
50 + init_topic("ai-ml", base_dir=base_dir)
51 + assert wisdom.read_text() == "custom content"
52 +
53 + def test_force_overwrites_existing(self, base_dir):
54 + init_topic("ai-ml", base_dir=base_dir)
55 + wisdom = base_dir / "topics" / "ai-ml" / "wisdom.md"
56 + wisdom.write_text("custom content")
57 +
58 + init_topic("ai-ml", force=True, base_dir=base_dir)
59 + assert wisdom.read_text() == SEEDED_WISDOM["ai-ml"]
60 +
61 + def test_multiple_runs_safe(self, base_dir):
62 + """Running multiple times doesn't raise errors."""
63 + init_topic("ai-ml", base_dir=base_dir)
64 + init_topic("ai-ml", base_dir=base_dir)
65 + init_topic("ai-ml", base_dir=base_dir)
66 +
67 + assert (base_dir / "topics" / "ai-ml" / "wisdom.md").is_file()
tests/test_quality_gate.py new
+206
@@ -0,0 +1,206 @@
1 +"""Tests for scripts/quality_gate.py"""
2 +from __future__ import annotations
3 +
4 +import json
5 +import os
6 +from pathlib import Path
7 +from unittest.mock import patch
8 +
9 +import pytest
10 +
11 +import scripts.quality_gate as quality_gate
12 +
13 +
14 +def make_scored_repos(count: int, score: float = 65.0) -> list[dict]:
15 + """Generate a list of scored repo dicts."""
16 + return [{"name": f"org/repo-{i}", "relevance_score": score} for i in range(count)]
17 +
18 +
19 +class TestGetQualityConfig:
20 + def test_defaults_when_empty(self):
21 + config = quality_gate.get_quality_config({})
22 + assert config["min_repos_per_week"] == 5
23 + assert config["max_repos_per_week"] == 30
24 + assert config["min_quality_score"] == 60
25 +
26 + def test_overrides(self):
27 + config = quality_gate.get_quality_config({"quality": {"min_repos_per_week": 10}})
28 + assert config["min_repos_per_week"] == 10
29 + assert config["max_repos_per_week"] == 30
30 +
31 +
32 +class TestCheckQuality:
33 + def test_ok_status(self):
34 + repos = make_scored_repos(10, score=50.0)
35 + result = quality_gate.check_quality(
36 + repos,
37 + {"min_repos_per_week": 5, "max_repos_per_week": 30},
38 + {"min_relevance_score": 40},
39 + )
40 + assert result["status"] == "ok"
41 + assert result["repos_passing"] == 10
42 + assert result["repos_scored"] == 10
43 + assert result["warnings"] == []
44 +
45 + def test_below_threshold(self):
46 + repos = make_scored_repos(3, score=50.0)
47 + result = quality_gate.check_quality(
48 + repos,
49 + {"min_repos_per_week": 5, "max_repos_per_week": 30},
50 + {"min_relevance_score": 40},
51 + )
52 + assert result["status"] == "below_threshold"
53 + assert result["repos_passing"] == 3
54 + assert len(result["warnings"]) == 1
55 + assert "threshold is 5" in result["warnings"][0]
56 +
57 + def test_above_maximum(self):
58 + repos = make_scored_repos(35, score=50.0)
59 + result = quality_gate.check_quality(
60 + repos,
61 + {"min_repos_per_week": 5, "max_repos_per_week": 30},
62 + {"min_relevance_score": 40},
63 + )
64 + assert result["status"] == "above_maximum"
65 + assert result["repos_passing"] == 35
66 + assert len(result["warnings"]) == 1
67 + assert "noise" in result["warnings"][0].lower()
68 +
69 + def test_repos_below_score_not_counted(self):
70 + repos = make_scored_repos(10, score=30.0)
71 + result = quality_gate.check_quality(
72 + repos,
73 + {"min_repos_per_week": 5, "max_repos_per_week": 30},
74 + {"min_relevance_score": 40},
75 + )
76 + assert result["repos_passing"] == 0
77 + assert result["status"] == "below_threshold"
78 +
79 + def test_empty_repos(self):
80 + result = quality_gate.check_quality(
81 + [],
82 + {"min_repos_per_week": 5, "max_repos_per_week": 30},
83 + {"min_relevance_score": 40},
84 + )
85 + assert result["status"] == "below_threshold"
86 + assert result["repos_passing"] == 0
87 + assert result["repos_scored"] == 0
88 +
89 +
90 +class TestEmitWarnings:
91 + def test_prints_annotations(self, capsys):
92 + quality_gate.emit_warnings(["Something is wrong", "Another issue"])
93 + captured = capsys.readouterr()
94 + assert "::warning::Something is wrong" in captured.out
95 + assert "::warning::Another issue" in captured.out
96 +
97 + def test_no_output_when_empty(self, capsys):
98 + quality_gate.emit_warnings([])
99 + captured = capsys.readouterr()
100 + assert captured.out == ""
101 +
102 +
103 +class TestWriteMetric:
104 + def test_writes_json(self, tmp_path, monkeypatch):
105 + monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t)
106 + metric = {"repos_scored": 10, "repos_passing": 8, "threshold": 5, "status": "ok", "warnings": []}
107 + path = quality_gate.write_metric("ai-ml", metric, "2026-W21")
108 + assert path.exists()
109 + data = json.loads(path.read_text())
110 + assert data["week"] == "2026-W21"
111 + assert data["topic"] == "ai-ml"
112 + assert data["repos_passing"] == 8
113 + assert data["status"] == "ok"
114 + assert "warnings" not in data
115 +
116 +
117 +class TestLoadScoredRepos:
118 + def test_loads_valid_json(self, tmp_path):
119 + path = tmp_path / "scored.json"
120 + repos = make_scored_repos(5)
121 + path.write_text(json.dumps(repos))
122 + result = quality_gate.load_scored_repos(path)
123 + assert len(result) == 5
124 +
125 + def test_missing_file(self, tmp_path):
126 + result = quality_gate.load_scored_repos(tmp_path / "missing.json")
127 + assert result == []
128 +
129 + def test_invalid_json(self, tmp_path):
130 + path = tmp_path / "bad.json"
131 + path.write_text("not json")
132 + result = quality_gate.load_scored_repos(path)
133 + assert result == []
134 +
135 + def test_non_list_json(self, tmp_path):
136 + path = tmp_path / "obj.json"
137 + path.write_text(json.dumps({"repos": []}))
138 + result = quality_gate.load_scored_repos(path)
139 + assert result == []
140 +
141 +
142 +class TestMain:
143 + def test_with_input_file(self, tmp_path, monkeypatch):
144 + monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t)
145 + scored_path = tmp_path / "scored.json"
146 + scored_path.write_text(json.dumps(make_scored_repos(10, score=50.0)))
147 +
148 + config_path = tmp_path / "config.yml"
149 + config_path.write_text(
150 + "topic:\n id: ai-ml\nscoring:\n min_relevance_score: 40\n"
151 + "quality:\n min_repos_per_week: 5\n max_repos_per_week: 30\n"
152 + )
153 +
154 + result = quality_gate.main(["--input", str(scored_path), "--config", str(config_path)])
155 + assert result == 0
156 +
157 + def test_missing_input_no_crash(self, tmp_path, monkeypatch):
158 + monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t)
159 + config_path = tmp_path / "config.yml"
160 + config_path.write_text("topic:\n id: test\n")
161 +
162 + result = quality_gate.main(["--input", str(tmp_path / "nope.json"), "--config", str(config_path)])
163 + assert result == 0
164 +
165 + def test_always_exits_zero(self, tmp_path, monkeypatch):
166 + monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t)
167 + scored_path = tmp_path / "scored.json"
168 + scored_path.write_text(json.dumps(make_scored_repos(2, score=50.0)))
169 +
170 + config_path = tmp_path / "config.yml"
171 + config_path.write_text(
172 + "topic:\n id: ai-ml\nscoring:\n min_relevance_score: 40\n"
173 + "quality:\n min_repos_per_week: 10\n max_repos_per_week: 30\n"
174 + )
175 +
176 + result = quality_gate.main(["--input", str(scored_path), "--config", str(config_path)])
177 + assert result == 0
178 +
179 + def test_topic_override(self, tmp_path, monkeypatch):
180 + monkeypatch.setattr(quality_gate, "metrics_dir", lambda t: tmp_path / "metrics" / t)
181 + scored_path = tmp_path / "scored.json"
182 + scored_path.write_text(json.dumps(make_scored_repos(10, score=50.0)))
183 +
184 + config_path = tmp_path / "config.yml"
185 + config_path.write_text("topic:\n id: ai-ml\nscoring:\n min_relevance_score: 40\n")
186 +
187 + result = quality_gate.main([
188 + "--input", str(scored_path),
189 + "--config", str(config_path),
190 + "--topic", "custom-topic",
191 + ])
192 + assert result == 0
193 + metric_path = tmp_path / "metrics" / "custom-topic" / quality_gate.week_slug() + ".json"
194 + # Verify metric written with correct topic
195 + files = list((tmp_path / "metrics" / "custom-topic").glob("quality-*.json"))
196 + assert len(files) == 1
197 + data = json.loads(files[0].read_text())
198 + assert data["topic"] == "custom-topic"
199 +
200 +
201 +class TestWeekSlug:
202 + def test_format(self):
203 + from datetime import datetime, timezone
204 + dt = datetime(2026, 5, 18, tzinfo=timezone.utc)
205 + result = quality_gate.week_slug(dt)
206 + assert result == "2026-W21"