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"