Skip to content

Commit 981402c

Browse files
authored
feat(metricsai): implement testing module — AI test-classifier metrics (#39)
* feat(metricsai): implement testing module — AI test-classifier metrics Replace the `testing` stub with a live module that scans the AI test-classifier's PR comments and emits weekly `testing_classifier_*` columns, mirroring how the `security` module was ported from its shell script. The classifier posts one comment per CI run with findings, leading with a `test-classifier:` label and embedding an `AI_CLASSIFIER_JSON` block whose `classifications` array holds one verdict per failing test. `sources/github.py` gains `fetch_classifier_comments`: it scans both PR comment surfaces (legacy issue comments + newer review comments), gates by author/window/label, parses each verdict, and expands every comment into one `Classification` per entry carrying the comment's 👍/👎 totals. The module aggregates the week into per-verdict counts (app_bug / test_bug / flaky_failure / environment_issue), comment-level reaction totals, and the 👍-rate — the classifier's tuning signal. Config: `testing_github_repos` / `testing_github_authors` (the classifier posts from CI as `github-actions[bot]`, distinct from the security reviewer), falling back to the shared `github_repos` and the classifier-bot default. Tests mirror `test_security.py` (aggregation, verdict parsing, dual-surface scan, config fallback). Docs/README/CLAUDE.md updated: `testing` now live. * style(metricsai): ruff format — collapse two single-line classifier-test calls * test(metricsai): update CLI default-modules test for live testing module Mock fetch_classifier_comments (the testing module is now live and requires a GitHub token, like security) and assert on the real testing_classifier_* column instead of the removed stub column name.
1 parent 677e9a3 commit 981402c

9 files changed

Lines changed: 527 additions & 25 deletions

File tree

metricsai/CLAUDE.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ week-ending date) → run the selected modules → merge their dicts into
4545
MetricValue]` whose **keys are the exact spreadsheet column names**. Each built-in module
4646
calls `register(...)` at import time and is imported in `modules/__init__.py` for that
4747
side effect. `selected_modules(None)` returns all (sorted); `--module` narrows. To add a
48-
module: subclass, `register()`, add the import. `build_pr` and `testing` are stubs (zeros);
49-
`security` is live.
48+
module: subclass, `register()`, add the import. `build_pr` is a stub (zeros); `security` and
49+
`testing` are live.
5050

5151
**`security` module is the real one.** It emits *both* the `security_*` and
5252
`security_compliance_*` column families from a single GitHub comment scan, split by
@@ -59,6 +59,14 @@ Conventional-Comment label (`security` vs `compliance`), plus one AWS Security H
5959
- `skip_sechub` (`--skip-sechub` / `METRICSAI_SKIP_SECHUB`) omits the AWS call and that one
6060
column, so GitHub metrics still gather/post with no AWS creds.
6161

62+
**`testing` module** emits the `testing_classifier_*` columns from one scan of the AI
63+
test-classifier's PR comments (`fetch_classifier_comments` in `sources/github.py`): comments
64+
leading with the `test-classifier:` label whose embedded `AI_CLASSIFIER_JSON` block carries a
65+
`classifications` array. Each array entry expands to one record counted into its verdict
66+
bucket; the comment's 👍/👎 feed `testing_classifier_thumbs_up_rate_pct`. It uses its own
67+
`testing_github_repos` / `testing_github_authors` settings (the classifier posts from CI as
68+
`github-actions[bot]`), falling back to `github_repos` and the classifier-bot default.
69+
6270
**Secrets (`keychain.py`):** a generic `resolve_secret` (env → macOS keychain → TTY prompt)
6371
backs `resolve_token` (GitHub, service `metricsai-github`) and `resolve_webhook_key` (webhook
6472
key, service `metricsai-webhook`). Non-interactive contexts never prompt — they fail fast

metricsai/README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ field per metric — to a Google Apps Script webhook that fronts a Google Sheet.
77
The design goal is simplicity: one row per week, one field per metric, modules that each
88
return plain key/value pairs whose keys are exactly the spreadsheet columns.
99

10-
> **Status:** the `security` module is implemented (GitHub PR comments + AWS Security Hub);
11-
> `build_pr` and `testing` still return placeholder (zero) values; and the Google Apps
12-
> Script / Sheet are not built yet. Use `--dry-run` to print the row without sending it.
10+
> **Status:** the `security` (GitHub PR comments + AWS Security Hub) and `testing` (AI
11+
> test-classifier PR comments) modules are implemented; `build_pr` still returns
12+
> placeholder (zero) values; and the Google Apps Script / Sheet are not built yet. Use
13+
> `--dry-run` to print the row without sending it.
1314
1415
## Modules and spreadsheet columns
1516

@@ -20,7 +21,7 @@ NAME` (repeatable) narrows the scope.
2021
| ----------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2122
| _(row key)_ || `week_ending_date` |
2223
| `build_pr` | stub | `build_pr_avg_cycle_time_days`, `build_pr_ai_gen_pr_rate_pct`, `build_pr_num_comments` |
23-
| `testing` | stub | `testing_quality_comment_thumbs_up_rate_pct`, `testing_quality_suggestion_merge_rate_pct`, `testing_quality_time_to_workflow_run_completion` |
24+
| `testing` | live | `testing_classifier_total_classifications`, `testing_classifier_thumbs_ups`, `testing_classifier_thumbs_downs`, `testing_classifier_thumbs_up_rate_pct`, `testing_classifier_app_bug`, `testing_classifier_test_bug`, `testing_classifier_flaky_failure`, `testing_classifier_environment_issue` |
2425
| `security` | live | `security_total_security_comments`, `security_thumbs_ups`, `security_thumbs_downs`, `security_critical`, `security_high`, `security_medium`, `security_low`, `security_total_sechub_critical_high`, and the `security_compliance_*` columns below |
2526

2627
The `security` module emits **both** the `security_*` and `security_compliance_*` families:
@@ -31,6 +32,16 @@ plus one AWS Security Hub findings count. The `security_compliance_*` columns ar
3132
`security_compliance_thumbs_downs`, `security_compliance_critical`,
3233
`security_compliance_high`, `security_compliance_medium`, `security_compliance_low`.
3334

35+
The `testing` module scans the AI **test-classifier**'s PR comments — those leading with the
36+
`test-classifier:` label and embedding a machine-readable JSON verdict block (see
37+
`testing/classifier/.skills/test-classifier/SKILL.md`). Each comment carries a
38+
`classifications` array (one entry per failing test); every entry is counted into its verdict
39+
bucket (`app_bug` / `test_bug` / `flaky_failure` / `environment_issue`), and the comment's
40+
👍/👎 reactions feed `testing_classifier_thumbs_up_rate_pct` — the classifier's tuning signal.
41+
The classifier posts from CI, so its author defaults to `github-actions[bot]` (override with
42+
`METRICSAI_TESTING_GITHUB_AUTHORS`) and its repos default to the shared `github_repos`
43+
(override with `METRICSAI_TESTING_GITHUB_REPOS`).
44+
3445
## Prerequisites
3546

3647
- **macOS** (the GitHub token is stored in the macOS Keychain).

metricsai/docs/modules.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ register(DeployModule())
4545
## Built-in modules
4646

4747
All registered modules run by default; each owns the spreadsheet columns under its name
48-
prefix. `security` is implemented; `build_pr` and `testing` are stubs.
48+
prefix. `security` and `testing` are implemented; `build_pr` is a stub.
4949

5050
### build_pr
5151

metricsai/src/metricsai/config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
#: Default author whose review comments are counted as AI-generated.
1313
DEFAULT_AUTHOR = "github-copilot[bot]"
1414

15+
#: Default author of the AI test-classifier's PR comments. The classifier posts from CI, so
16+
#: its login differs from the security reviewer's; the ``testing`` module keys off this.
17+
DEFAULT_CLASSIFIER_AUTHOR = "github-actions[bot]"
18+
1519

1620
def csv_list(value: str | None) -> list[str]:
1721
"""Split a comma-separated configuration string into a clean list.
@@ -49,6 +53,10 @@ class Settings(BaseSettings):
4953
:ivar github_repos: Comma-separated ``owner/repo`` list to scan. Required for the
5054
security module (overridable with ``--repo``).
5155
:ivar github_authors: Comma-separated comment author logins to count as AI-generated.
56+
:ivar testing_github_repos: Comma-separated ``owner/repo`` list the ``testing`` module
57+
scans for classifier comments. Falls back to ``github_repos`` when unset.
58+
:ivar testing_github_authors: Comma-separated author logins of the test-classifier's
59+
comments. Falls back to the classifier-bot default when unset.
5260
:ivar week_ending_day: Weekday that closes the reporting week (e.g. ``thursday`` /
5361
``thu``). The query window is the 7 days ending on it.
5462
:ivar aws_region: Optional AWS region for Security Hub. When unset, boto3's default
@@ -70,6 +78,8 @@ class Settings(BaseSettings):
7078
github_base_url: str = "https://api.github.com"
7179
github_repos: str = ""
7280
github_authors: str = DEFAULT_AUTHOR
81+
testing_github_repos: str = ""
82+
testing_github_authors: str = ""
7383
week_ending_day: str = "thursday"
7484
aws_region: str | None = None
7585
skip_sechub: bool = False
@@ -84,3 +94,13 @@ def repos(self) -> list[str]:
8494
def authors(self) -> list[str]:
8595
"""Comment authors to count, falling back to the default bot."""
8696
return csv_list(self.github_authors) or [DEFAULT_AUTHOR]
97+
98+
@property
99+
def testing_repos(self) -> list[str]:
100+
"""Repositories the testing module scans, falling back to ``github_repos``."""
101+
return csv_list(self.testing_github_repos) or self.repos
102+
103+
@property
104+
def testing_authors(self) -> list[str]:
105+
"""Classifier comment authors, falling back to the classifier-bot default."""
106+
return csv_list(self.testing_github_authors) or [DEFAULT_CLASSIFIER_AUTHOR]
Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,101 @@
1-
"""Testing / quality metrics module (stub).
1+
"""Testing / quality metrics module.
22
3-
Emits the ``testing_quality_*`` spreadsheet columns. Values are placeholders for now.
3+
Emits the ``testing_classifier_*`` spreadsheet columns from a single scan of the AI
4+
test-classifier's PR comments. The classifier posts one comment per CI run with findings,
5+
each leading with the ``test-classifier:`` Conventional-Comment label and embedding a JSON
6+
verdict block (one entry per failing test). We expand those into per-verdict records,
7+
attach each comment's 👍/👎 reaction totals, and aggregate the week into:
48
5-
.. todo::
6-
Source these from the GitHub API / CI, e.g. the thumbs-up rate on AI test-suggestion
7-
comments, the merge rate of accepted suggestions, and time to workflow-run completion.
8-
Set :pyattr:`requires_github_token` to ``True`` when implemented.
9+
* per-verdict counts (``app_bug`` / ``test_bug`` / ``flaky_failure`` / ``environment_issue``),
10+
* total comment-level reactions, and
11+
* the 👍-rate -- the classifier's tuning signal.
12+
13+
Source: :func:`metricsai.sources.github.fetch_classifier_comments`.
914
"""
1015

1116
from __future__ import annotations
1217

1318
from metricsai.context import RunContext
14-
from metricsai.models import MetricValue
19+
from metricsai.models import MetricValue, week_window
1520
from metricsai.modules import register
1621
from metricsai.modules.base import MetricsModule
22+
from metricsai.sources.github import Classification, fetch_classifier_comments
23+
24+
#: Verdicts the classifier emits, mapped to their spreadsheet column suffix. Records whose
25+
#: verdict is absent/unparseable (``""``) count toward the comment total but no bucket.
26+
_VERDICT_COLUMNS = {
27+
"APPLICATION_BUG": "testing_classifier_app_bug",
28+
"TEST_BUG": "testing_classifier_test_bug",
29+
"FLAKY_FAILURE": "testing_classifier_flaky_failure",
30+
"ENVIRONMENT_ISSUE": "testing_classifier_environment_issue",
31+
}
1732

1833

1934
class TestingModule(MetricsModule):
20-
"""Gathers testing / quality metrics (currently stubbed)."""
35+
"""Gathers AI test-classifier precision metrics."""
2136

2237
name = "testing"
23-
requires_github_token = False
38+
requires_github_token = True
2439

2540
def gather(self, ctx: RunContext) -> dict[str, MetricValue]:
26-
"""Return placeholder testing/quality metrics.
41+
"""Collect the classifier metrics for the reporting week.
2742
28-
:param ctx: The shared per-run context (unused while stubbed).
29-
:returns: Stub metric key/value pairs keyed by spreadsheet column.
43+
:param ctx: The shared per-run context.
44+
:returns: The ``testing_classifier_*`` key/value pairs.
45+
:raises ValueError: If no target repositories are configured.
3046
"""
31-
return {
32-
"testing_quality_comment_thumbs_up_rate_pct": 0.0,
33-
"testing_quality_suggestion_merge_rate_pct": 0.0,
34-
"testing_quality_time_to_workflow_run_completion": 0.0,
35-
}
47+
settings = ctx.settings
48+
repos = settings.testing_repos
49+
if not repos:
50+
raise ValueError(
51+
"No repositories configured. Set METRICSAI_TESTING_GITHUB_REPOS "
52+
"(or METRICSAI_GITHUB_REPOS), or pass --repo."
53+
)
54+
55+
start, end = week_window(ctx.week_ending_date)
56+
classifications = fetch_classifier_comments(
57+
token=ctx.get_github_token(),
58+
base_url=settings.github_base_url,
59+
repos=repos,
60+
authors=settings.testing_authors,
61+
start=start,
62+
end=end,
63+
)
64+
return _aggregate_classifications(classifications)
65+
66+
67+
def _aggregate_classifications(records: list[Classification]) -> dict[str, MetricValue]:
68+
"""Aggregate per-verdict records into the ``testing_classifier_*`` columns.
69+
70+
The 👍-rate is rounded to one decimal and is ``0.0`` when there are no reactions, so the
71+
column is always numeric. Per-comment reactions are repeated across that comment's
72+
records, so reaction totals are summed over verdict entries exactly as the comments
73+
carry them.
74+
75+
:param records: One entry per classification (see
76+
:class:`~metricsai.sources.github.Classification`).
77+
:returns: The ``testing_classifier_*`` metrics.
78+
"""
79+
counts = dict.fromkeys(_VERDICT_COLUMNS.values(), 0)
80+
thumbs_up = 0
81+
thumbs_down = 0
82+
for record in records:
83+
thumbs_up += record.thumbs_up
84+
thumbs_down += record.thumbs_down
85+
column = _VERDICT_COLUMNS.get(record.verdict)
86+
if column is not None:
87+
counts[column] += 1
88+
89+
total_reactions = thumbs_up + thumbs_down
90+
up_rate = round(100.0 * thumbs_up / total_reactions, 1) if total_reactions else 0.0
91+
92+
return {
93+
"testing_classifier_total_classifications": len(records),
94+
"testing_classifier_thumbs_ups": thumbs_up,
95+
"testing_classifier_thumbs_downs": thumbs_down,
96+
"testing_classifier_thumbs_up_rate_pct": up_rate,
97+
**counts,
98+
}
3699

37100

38101
register(TestingModule())

0 commit comments

Comments
 (0)