diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2cc5c403c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + - package-ecosystem: pip + directory: /reporting + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore \ No newline at end of file diff --git a/.github/workflows/org-repo-report.yml b/.github/workflows/org-repo-report.yml new file mode 100644 index 000000000..c8b525c30 --- /dev/null +++ b/.github/workflows/org-repo-report.yml @@ -0,0 +1,88 @@ +name: Organization Repository Report + +on: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: write + pull-requests: read + +concurrency: + group: org-repo-report-github-org-stats + cancel-in-progress: true + +jobs: + generate-org-repo-report: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Detect report script changes + if: ${{ github.event_name == 'pull_request' }} + id: report-script-changes + uses: actions/github-script@v7 + with: + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + + const reportScriptChanged = files.some( + (file) => file.filename === 'reporting/generate_org_repo_report.py' + ); + + core.info(`report_script changed: ${reportScriptChanged}`); + core.setOutput('report_script', reportScriptChanged ? 'true' : 'false'); + + - name: Run report script tests + if: ${{ github.event_name == 'pull_request' && steps.report-script-changes.outputs.report_script == 'true' }} + run: | + python -m pip install --upgrade pip + python -m pip install -r reporting/requirements-dev.txt + ruff check reporting + mypy reporting/generate_org_repo_report.py + pytest -q reporting/tests + + - name: Generate report + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + ORG_NAME: morganstanley + OUTPUT_CSV: reporting/org-repo-report.csv + OUTPUT_MD: reporting/org-repo-report.md + run: python reporting/generate_org_repo_report.py + + - name: Upload report artifacts for PR validation + if: ${{ github.event_name == 'pull_request' }} + uses: actions/upload-artifact@v4 + with: + name: org-repo-report + path: | + reporting/org-repo-report.csv + reporting/org-repo-report.md + + - name: Commit and push report + if: ${{ github.event_name != 'pull_request' }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B github-org-stats + git add reporting/org-repo-report.csv reporting/org-repo-report.md + if git diff --cached --quiet; then + echo "No report changes to commit." + exit 0 + fi + git commit -m "chore(reporting): update organization repository report" + git push origin HEAD:github-org-stats diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..029519e08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] \ No newline at end of file diff --git a/reporting/generate_org_repo_report.py b/reporting/generate_org_repo_report.py new file mode 100644 index 000000000..dc9afcced --- /dev/null +++ b/reporting/generate_org_repo_report.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +"""Generate organization repository metadata report. + +Report fields per repository: +- Repository name +- Repository created date/time +- Repository creator (best effort via org audit log) +- Most recent update date/time +- Most recent updater (push actor when available) +""" + +from __future__ import annotations + +import csv +import json +import os +import sys +import time +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Dict, Iterable, List, Optional, Tuple + +API_BASE = "https://api.github.com" +GRAPHQL_URL = "https://api.github.com/graphql" +UNKNOWN_VALUE = "unknown" + + +class GitHubClient: + def __init__(self, token: str): + self.token = token + self.audit_supported: Optional[bool] = None + + def _send_request( + self, + req: urllib.request.Request, + request_label: str, + retries: int = 3, + ) -> Tuple[object, Dict[str, str]]: + last_error: Optional[Exception] = None + for attempt in range(1, retries + 1): + try: + with urllib.request.urlopen(req, timeout=60) as response: + body = response.read().decode("utf-8") + data = json.loads(body) if body else None + response_headers = { + k.lower(): v for k, v in response.headers.items() + } + return data, response_headers + except urllib.error.HTTPError as err: + body = err.read().decode("utf-8", errors="replace") + retry_after_seconds = self._parse_retry_after_seconds( + err.headers.get("Retry-After") + ) + body_lower = body.lower() + is_primary_limit = ( + err.code == 403 and err.headers.get("X-RateLimit-Remaining") == "0" + ) + is_secondary_limit = err.code == 403 and ( + "secondary rate limit" in body_lower + or "abuse detection" in body_lower + ) + is_retryable_rate_limit = ( + err.code == 429 or is_primary_limit or is_secondary_limit + ) + + if is_retryable_rate_limit and attempt < retries: + if is_primary_limit: + reset_epoch = int(err.headers.get("X-RateLimit-Reset", "0")) + sleep_for = max(reset_epoch - int(time.time()), 1) + elif retry_after_seconds is not None: + sleep_for = max(retry_after_seconds, 1) + else: + # Backoff for secondary/abuse limits when Retry-After is absent. + sleep_for = min(30 * attempt, 300) + + print( + f"Rate limited ({err.code}). Sleeping for {sleep_for} seconds before retrying {request_label}.", + file=sys.stderr, + ) + time.sleep(sleep_for) + continue + + raise RuntimeError( + f"GitHub API error ({err.code}) for {request_label}: {body}" + ) from err + except Exception as err: # pragma: no cover - defensive + last_error = err + if attempt < retries: + time.sleep(attempt) + else: + raise RuntimeError( + f"Failed to call GitHub API for {request_label}: {err}" + ) from err + + raise RuntimeError(f"Unexpected API failure for {request_label}: {last_error}") + + def _request( + self, + path: str, + params: Optional[Dict[str, str]] = None, + accept: str = "application/vnd.github+json", + retries: int = 3, + ) -> Tuple[object, Dict[str, str]]: + query = "" + if params: + query = "?" + urllib.parse.urlencode(params) + url = f"{API_BASE}{path}{query}" + + headers = { + "Accept": accept, + "Authorization": f"Bearer {self.token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "org-repo-report-generator", + } + req = urllib.request.Request(url, headers=headers, method="GET") + return self._send_request(req, path, retries=retries) + + def _graphql_request( + self, + query: str, + variables: Dict[str, object], + retries: int = 3, + ) -> Dict[str, object]: + payload = json.dumps({"query": query, "variables": variables}).encode("utf-8") + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "org-repo-report-generator", + } + req = urllib.request.Request( + GRAPHQL_URL, headers=headers, data=payload, method="POST" + ) + data, _ = self._send_request(req, "graphql", retries=retries) + if not isinstance(data, dict): + raise RuntimeError("Unexpected GraphQL response format") + if isinstance(data.get("errors"), list) and data["errors"]: + raise RuntimeError(f"GitHub GraphQL error: {data['errors']}") + return data + + @staticmethod + def _parse_retry_after_seconds(retry_after: Optional[str]) -> Optional[int]: + if not retry_after: + return None + + retry_after = retry_after.strip() + if retry_after.isdigit(): + return int(retry_after) + + try: + retry_at = parsedate_to_datetime(retry_after) + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + return max(int((retry_at - datetime.now(timezone.utc)).total_seconds()), 0) + except (TypeError, ValueError, OverflowError): + return None + + def paginate( + self, path: str, params: Optional[Dict[str, str]] = None + ) -> Iterable[object]: + next_path = path + next_params = dict(params or {}) + + while next_path: + data, headers = self._request(next_path, next_params) + if not isinstance(data, list): + raise RuntimeError( + f"Expected list response for {next_path}, got {type(data)}" + ) + + for item in data: + yield item + + link_header = headers.get("link", "") + next_link = self._extract_next_link(link_header) + if next_link: + parsed = urllib.parse.urlparse(next_link) + next_path = parsed.path + next_params = dict(urllib.parse.parse_qsl(parsed.query)) + else: + next_path = "" + next_params = {} + + @staticmethod + def _extract_next_link(link_header: str) -> Optional[str]: + if not link_header: + return None + parts = [p.strip() for p in link_header.split(",")] + for part in parts: + sections = [s.strip() for s in part.split(";")] + if len(sections) < 2: + continue + if ( + sections[1] == 'rel="next"' + and sections[0].startswith("<") + and sections[0].endswith(">") + ): + return sections[0][1:-1] + return None + + def list_org_repos(self, org: str) -> List[Dict[str, object]]: + repos: List[Dict[str, object]] = [] + for repo in self.paginate( + f"/orgs/{org}/repos", + params={ + "per_page": "100", + "type": "all", + "sort": "full_name", + "direction": "asc", + }, + ): + if isinstance(repo, dict): + repos.append(repo) + return repos + + def get_repo_creators(self, org: str) -> Dict[str, Tuple[str, str]]: + if self.audit_supported is False: + return {} + + try: + audit_events = self.paginate( + f"/orgs/{org}/audit-log", + params={ + "per_page": "100", + "phrase": "action:repo.create", + }, + ) + except RuntimeError as err: + text = str(err) + if "(403)" in text or "(404)" in text: + # Token lacks org audit-log access. + self.audit_supported = False + return {} + raise + + self.audit_supported = True + creators: Dict[str, Tuple[str, str]] = {} + for event in audit_events: + if not isinstance(event, dict): + continue + repo_name = self._extract_audit_repo_name(event, org) + if not repo_name or repo_name in creators: + continue + created_by = str(event.get("actor") or "") + created_at = str(event.get("created_at") or "") + creators[repo_name] = (created_by, created_at) + + return creators + + def get_latest_updaters(self, org: str, repo_names: List[str]) -> Dict[str, str]: + if not repo_names: + return {} + + query = """ + query RepoLatestUpdater($org: String!, $name: String!) { + repository(owner: $org, name: $name) { + name + defaultBranchRef { + target { + __typename + ... on Commit { + history(first: 1) { + nodes { + author { + name + user { + login + } + } + committer { + name + user { + login + } + } + } + } + } + } + } + } + } + """ + + latest_updaters: Dict[str, str] = {} + + for repo_name in repo_names: + if not repo_name: + continue + + data = self._graphql_request(query, {"org": org, "name": repo_name}) + data_payload = data.get("data") + if not isinstance(data_payload, dict): + continue + + repository = ( + data_payload.get("repository", {}) + if isinstance(data_payload.get("repository"), dict) + else {} + ) + if not isinstance(repository, dict) or not repository: + continue + + branch_ref = ( + repository.get("defaultBranchRef", {}) + if isinstance(repository.get("defaultBranchRef"), dict) + else {} + ) + target = ( + branch_ref.get("target", {}) if isinstance(branch_ref, dict) else {} + ) + history = target.get("history", {}) if isinstance(target, dict) else {} + history_nodes = ( + history.get("nodes", []) if isinstance(history, dict) else [] + ) + if not history_nodes: + continue + + latest_commit = ( + history_nodes[0] if isinstance(history_nodes[0], dict) else {} + ) + author = ( + latest_commit.get("author", {}) + if isinstance(latest_commit.get("author"), dict) + else {} + ) + committer = ( + latest_commit.get("committer", {}) + if isinstance(latest_commit.get("committer"), dict) + else {} + ) + + committer_user = ( + committer.get("user", {}) + if isinstance(committer.get("user"), dict) + else {} + ) + author_user = ( + author.get("user", {}) if isinstance(author.get("user"), dict) else {} + ) + + updater = str(committer_user.get("login") or author_user.get("login") or "") + if not updater: + updater = str(committer.get("name") or author.get("name") or "") + + latest_updaters[repo_name] = updater + + return latest_updaters + + @staticmethod + def _extract_audit_repo_name(event: Dict[str, object], org: str) -> str: + repo_value = event.get("repo") + if isinstance(repo_value, str) and repo_value: + prefix = f"{org}/" + return ( + repo_value[len(prefix) :] + if repo_value.startswith(prefix) + else repo_value + ) + + repository = event.get("repository") + if isinstance(repository, dict): + name = repository.get("name") + if isinstance(name, str): + return name + + repo_name = event.get("repo_name") + if isinstance(repo_name, str): + return repo_name + + return "" + + +def normalize_timestamp(timestamp: str) -> str: + if not timestamp: + return "" + try: + dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + except ValueError: + return timestamp + + +def write_csv(rows: List[Dict[str, str]], output_csv: str) -> None: + headers = [ + "repo_name", + "repo_created_at", + "repo_created_by", + "most_recent_update_at", + "most_recent_updated_by", + ] + with open(output_csv, "w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=headers) + writer.writeheader() + for row in rows: + writer.writerow( + { + "repo_name": row["repo_name"], + "repo_created_at": row["repo_created_at"], + "repo_created_by": row["repo_created_by"] or UNKNOWN_VALUE, + "most_recent_update_at": row["most_recent_update_at"], + "most_recent_updated_by": row["most_recent_updated_by"] + or UNKNOWN_VALUE, + } + ) + + +def write_markdown( + rows: List[Dict[str, str]], output_md: str, org: str, audit_supported: bool +) -> None: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + lines = [ + f"# {org} Repository Report", + "", + f"Generated: {now}", + "", + "| Repo Name | Repo Created At | Repo Created By | Most Recent Update At | Most Recent Updated By |", + "| --- | --- | --- | --- | --- |", + ] + + for row in rows: + lines.append( + "| " + + " | ".join( + [ + row["repo_name"] or "n/a", + row["repo_created_at"] or "n/a", + row["repo_created_by"] or UNKNOWN_VALUE, + row["most_recent_update_at"] or "n/a", + row["most_recent_updated_by"] or UNKNOWN_VALUE, + ] + ) + + " |" + ) + + lines.extend( + [ + "", + "## Notes", + "", + "- `repo_created_by` is sourced from the organization audit log when accessible.", + "- `most_recent_updated_by` uses the latest push event actor when available, otherwise latest default-branch commit metadata.", + ] + ) + + if not audit_supported: + lines.append( + "- Creator information is unavailable because the token could not access organization audit log data (typically requires org-level `admin:org`)." + ) + + with open(output_md, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + +def main() -> int: + org = os.getenv("ORG_NAME", "morganstanley") + token = os.getenv("GH_TOKEN") + output_csv = os.getenv("OUTPUT_CSV", "reporting/org-repo-report.csv") + output_md = os.getenv("OUTPUT_MD", "reporting/org-repo-report.md") + + if not token: + print("GH_TOKEN environment variable is required.", file=sys.stderr) + return 1 + + output_csv_dir = os.path.dirname(output_csv) or "." + output_md_dir = os.path.dirname(output_md) or "." + os.makedirs(output_csv_dir, exist_ok=True) + os.makedirs(output_md_dir, exist_ok=True) + + client = GitHubClient(token) + repos = client.list_org_repos(org) + repo_creators = client.get_repo_creators(org) + latest_updaters = client.get_latest_updaters( + org, [str(repo.get("name") or "") for repo in repos] + ) + rows: List[Dict[str, str]] = [] + + for repo in repos: + repo_name = str(repo.get("name") or "") + created_at = str(repo.get("created_at") or "") + latest_update_at = str(repo.get("pushed_at") or repo.get("updated_at") or "") + latest_updated_by = latest_updaters.get(repo_name, "") + + created_by, created_from_audit_at = repo_creators.get(repo_name, ("", "")) + + # Prefer audit-log create timestamp if available; otherwise use repo metadata created_at. + created_time = created_from_audit_at or created_at + + rows.append( + { + "repo_name": repo_name, + "repo_created_at": normalize_timestamp(created_time), + "repo_created_by": created_by, + "most_recent_update_at": normalize_timestamp(latest_update_at), + "most_recent_updated_by": latest_updated_by, + } + ) + + rows.sort(key=lambda x: x["repo_name"].lower()) + write_csv(rows, output_csv) + # Treat unknown audit support as available to avoid false warning text. + audit_supported = client.audit_supported is not False + write_markdown(rows, output_md, org=org, audit_supported=audit_supported) + + print(f"Wrote {len(rows)} rows to {output_csv} and {output_md}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/reporting/requirements-dev.txt b/reporting/requirements-dev.txt new file mode 100644 index 000000000..60fd7a6db --- /dev/null +++ b/reporting/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest==8.3.5 +pytest-cov==5.0.0 +ruff==0.11.13 +mypy==1.15.0 \ No newline at end of file diff --git a/reporting/requirements.txt b/reporting/requirements.txt new file mode 100644 index 000000000..7b4c93515 --- /dev/null +++ b/reporting/requirements.txt @@ -0,0 +1,2 @@ +# No third-party Python dependencies are required. +# Keep this file so Dependabot can track future packages added under reporting/. \ No newline at end of file diff --git a/reporting/tests/test_generate_org_repo_report.py b/reporting/tests/test_generate_org_repo_report.py new file mode 100644 index 000000000..67c4264c2 --- /dev/null +++ b/reporting/tests/test_generate_org_repo_report.py @@ -0,0 +1,222 @@ +import csv +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +SCRIPT_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPT_DIR) not in os.sys.path: + os.sys.path.insert(0, str(SCRIPT_DIR)) + +import generate_org_repo_report as report # noqa: E402 + + +class TestHelpers(unittest.TestCase): + def test_normalize_timestamp_formats_utc(self) -> None: + value = report.normalize_timestamp("2026-05-22T12:34:56Z") + self.assertEqual(value, "2026-05-22 12:34:56 UTC") + + def test_normalize_timestamp_returns_original_for_invalid(self) -> None: + value = report.normalize_timestamp("not-a-date") + self.assertEqual(value, "not-a-date") + + def test_extract_next_link(self) -> None: + link_header = ( + '; rel="next", ' + '; rel="last"' + ) + next_link = report.GitHubClient._extract_next_link(link_header) + self.assertEqual(next_link, "https://api.github.com/orgs/test/repos?page=2") + + def test_extract_audit_repo_name_prefers_repo_field(self) -> None: + event = {"repo": "morganstanley/my-repo", "repo_name": "fallback"} + repo_name = report.GitHubClient._extract_audit_repo_name(event, "morganstanley") + self.assertEqual(repo_name, "my-repo") + + +class TestFileOutputs(unittest.TestCase): + def test_write_csv_replaces_empty_values_with_unknown(self) -> None: + rows = [ + { + "repo_name": "demo", + "repo_created_at": "2026-05-22 00:00:00 UTC", + "repo_created_by": "", + "most_recent_update_at": "2026-05-22 01:00:00 UTC", + "most_recent_updated_by": "", + } + ] + + with tempfile.TemporaryDirectory() as td: + csv_path = Path(td) / "report.csv" + report.write_csv(rows, str(csv_path)) + + with csv_path.open("r", encoding="utf-8", newline="") as fh: + parsed = list(csv.DictReader(fh)) + + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["repo_created_by"], report.UNKNOWN_VALUE) + self.assertEqual(parsed[0]["most_recent_updated_by"], report.UNKNOWN_VALUE) + + def test_write_markdown_includes_audit_access_note(self) -> None: + rows = [ + { + "repo_name": "demo", + "repo_created_at": "2026-05-22 00:00:00 UTC", + "repo_created_by": "alice", + "most_recent_update_at": "2026-05-22 01:00:00 UTC", + "most_recent_updated_by": "bob", + } + ] + + with tempfile.TemporaryDirectory() as td: + md_path = Path(td) / "report.md" + report.write_markdown( + rows, str(md_path), org="morganstanley", audit_supported=False + ) + text = md_path.read_text(encoding="utf-8") + + self.assertIn("# morganstanley Repository Report", text) + self.assertIn("| demo |", text) + self.assertIn("Creator information is unavailable", text) + + +class TestClientBehavior(unittest.TestCase): + def test_get_repo_creators_marks_audit_unsupported_on_403(self) -> None: + client = report.GitHubClient(token="fake") + + with mock.patch.object( + client, + "paginate", + side_effect=RuntimeError( + "GitHub API error (403) for /orgs/test/audit-log: denied" + ), + ): + creators = client.get_repo_creators("test") + + self.assertEqual(creators, {}) + self.assertIs(client.audit_supported, False) + + def test_get_latest_updaters_prefers_committer_login(self) -> None: + client = report.GitHubClient(token="fake") + + def fake_graphql(_query, variables): + if variables["name"] == "repo-a": + return { + "data": { + "repository": { + "defaultBranchRef": { + "target": { + "history": { + "nodes": [ + { + "author": { + "name": "A", + "user": {"login": "author-login"}, + }, + "committer": { + "name": "C", + "user": { + "login": "committer-login" + }, + }, + } + ] + } + } + } + } + } + } + return { + "data": { + "repository": { + "defaultBranchRef": { + "target": { + "history": { + "nodes": [ + { + "author": { + "name": "Only Name", + "user": None, + }, + "committer": {"name": "", "user": None}, + } + ] + } + } + } + } + } + } + + with mock.patch.object(client, "_graphql_request", side_effect=fake_graphql): + updaters = client.get_latest_updaters( + "morganstanley", ["repo-a", "repo-b", ""] + ) + + self.assertEqual(updaters["repo-a"], "committer-login") + self.assertEqual(updaters["repo-b"], "Only Name") + self.assertNotIn("", updaters) + + +class TestMain(unittest.TestCase): + def test_main_requires_gh_token(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + exit_code = report.main() + self.assertEqual(exit_code, 1) + + def test_main_writes_reports_with_mocked_client(self) -> None: + class FakeClient: + def __init__(self, token: str): + self.token = token + self.audit_supported = True + + def list_org_repos(self, _org: str): + return [ + { + "name": "zeta", + "created_at": "2026-05-21T00:00:00Z", + "updated_at": "2026-05-22T00:00:00Z", + "pushed_at": "2026-05-22T01:00:00Z", + }, + { + "name": "alpha", + "created_at": "2026-05-20T00:00:00Z", + "updated_at": "2026-05-20T01:00:00Z", + "pushed_at": None, + }, + ] + + def get_repo_creators(self, _org: str): + return {"alpha": ("alice", "2026-05-20T00:30:00Z")} + + def get_latest_updaters(self, _org: str, _repo_names): + return {"zeta": "zane", "alpha": ""} + + with tempfile.TemporaryDirectory() as td: + output_csv = Path(td) / "out" / "report.csv" + output_md = Path(td) / "out" / "report.md" + + env = { + "GH_TOKEN": "fake-token", + "ORG_NAME": "morganstanley", + "OUTPUT_CSV": str(output_csv), + "OUTPUT_MD": str(output_md), + } + + with mock.patch.object(report, "GitHubClient", FakeClient): + with mock.patch.dict(os.environ, env, clear=True): + exit_code = report.main() + + self.assertEqual(exit_code, 0) + self.assertTrue(output_csv.exists()) + self.assertTrue(output_md.exists()) + + csv_text = output_csv.read_text(encoding="utf-8") + self.assertLess(csv_text.find("alpha"), csv_text.find("zeta")) + self.assertIn(report.UNKNOWN_VALUE, csv_text) + + +if __name__ == "__main__": + unittest.main()