octocrab 0.54.9

A modern, extensible GitHub API client.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
#!/usr/bin/env python3
"""
GitHub REST API v2022-11-28 Endpoint Coverage Calculator & Badge Generator

Calculates coverage of active (non-deprecated) GitHub REST API endpoints in Octocrab
by matching OpenAPI operations against Rust source code using regular expressions.
Generates:
  1. Shields.io custom dynamic JSON badge payload.
  2. GitHub Actions Job Summary ($GITHUB_STEP_SUMMARY).
  3. Detailed JSON reports for CI artifacts.
  4. Categorized missing endpoints reports (Markdown/JSON).
"""

import argparse
import json
import os
import re
import sys
import urllib.request
from collections import defaultdict
from typing import Any, Dict, List, Optional, Set, Tuple

DEFAULT_SPEC_URL = "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json"
DEFAULT_CACHE_PATH = ".cache/api.github.com.2022-11-28.json"

# Regex 1: Matches GitHub REST API doc URLs in Rust doc comments
DOC_LINK_REGEX = re.compile(
    r"https://docs\.github\.com/(?:en/)?rest/([a-zA-Z0-9_\-]+/[a-zA-Z0-9_\-]+)(?:\?apiVersion=2022-11-28)?(?:#([a-zA-Z0-9_\-]+))?"
)

# Regex 2: Matches route templates in format!(), route =, uri =
ROUTE_TEMPLATE_REGEX = re.compile(
    r'(?:format!\(\s*|(?:route|uri|path)\s*=\s*)"(/[^"]+)"'
)

# Regex 3: Matches HTTP method calls on octocrab client
HTTP_METHOD_REGEX = re.compile(
    r'crab\.(?:_|)(get|post|put|patch|delete)\w*\('
)


def fetch_or_load_spec(spec_url: str, cache_path: str) -> Dict[str, Any]:
    """Loads OpenAPI spec from cache if present, otherwise downloads and caches it."""
    if os.path.exists(cache_path):
        print(f"Loading cached OpenAPI spec from: {cache_path}")
        with open(cache_path, "r", encoding="utf-8") as f:
            return json.load(f)

    print(f"Downloading GitHub REST API v2022-11-28 OpenAPI spec from: {spec_url}")
    os.makedirs(os.path.dirname(cache_path), exist_ok=True)
    req = urllib.request.Request(spec_url, headers={"User-Agent": "octocrab-coverage-calculator"})
    with urllib.request.urlopen(req) as resp:
        content = resp.read()

    with open(cache_path, "wb") as f:
        f.write(content)

    print(f"Cached OpenAPI spec to: {cache_path} ({len(content)} bytes)")
    return json.loads(content.decode("utf-8"))


def normalize_route(path: str) -> str:
    """Normalizes parameter placeholders like {owner}, {repo}, {} into '{_}'."""
    # Handle self.repo interpolation: /{}/... -> /repos/{owner}/{repo}/...
    path = re.sub(r"^/\{\}/", "/repos/{owner}/{repo}/", path)
    # Normalize all parameter names to {_}
    return re.sub(r"\{[a-zA-Z0-9_]*\}", "{_}", path)


def get_badge_color(percentage: float) -> str:
    """Determines badge color according to coverage percentage."""
    if percentage >= 80.0:
        return "brightgreen"
    elif percentage >= 60.0:
        return "green"
    elif percentage >= 40.0:
        return "yellow"
    elif percentage >= 20.0:
        return "orange"
    else:
        return "red"


def render_progress_bar(percentage: float, width: int = 20) -> str:
    """Generates an ASCII progress bar string."""
    filled = int(round((percentage / 100.0) * width))
    bar = "█" * filled + "░" * (width - filled)
    return f"[{bar}] {percentage:.1f}%"


def analyze_coverage(
    spec: Dict[str, Any], src_dir: str
) -> Dict[str, Any]:
    """Analyzes active endpoints vs implemented endpoints in Octocrab."""
    paths = spec.get("paths", {})
    active_operations: Dict[Tuple[str, str], Dict[str, Any]] = {}
    deprecated_count = 0

    # Index lookups
    doc_url_to_op: Dict[str, Tuple[str, str]] = {}
    anchor_to_op: Dict[str, Tuple[str, str]] = {}
    norm_path_to_ops: Dict[Tuple[str, str], List[Tuple[str, str]]] = defaultdict(list)
    categories: Dict[Tuple[str, str], str] = {}

    for path, methods in paths.items():
        path_deprecated = methods.get("deprecated", False) if isinstance(methods, dict) else False
        norm_path = normalize_route(path)
        for method, op in methods.items():
            method_upper = method.upper()
            if method_upper not in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
                continue

            # Fully exclude deprecated endpoints so they never count toward total active or missing
            is_deprecated = (
                path_deprecated
                or op.get("deprecated", False)
                or op.get("x-deprecated", False)
            )
            if is_deprecated:
                deprecated_count += 1
                continue

            op_key = (method_upper, path)
            ext_url = (op.get("externalDocs") or {}).get("url", "")
            summary = op.get("summary", "")
            op_id = op.get("operationId", "")
            tags = op.get("tags", [])
            primary_tag = tags[0] if tags else "other"
            categories[op_key] = primary_tag

            active_operations[op_key] = {
                "method": method_upper,
                "path": path,
                "operationId": op_id,
                "summary": summary,
                "externalDocs": ext_url,
                "category": primary_tag,
            }

            if ext_url:
                # e.g., 'actions/artifacts#get-an-artifact'
                norm_doc = re.sub(r"^https?://docs\.github\.com/(?:en/)?rest/", "", ext_url).split("?")[0]
                doc_url_to_op[norm_doc] = op_key
                if "#" in norm_doc:
                    anchor = norm_doc.split("#", 1)[1]
                    anchor_to_op[anchor] = op_key

            norm_path_to_ops[(method_upper, norm_path)].append(op_key)

    total_active = len(active_operations)
    covered_ops: Set[Tuple[str, str]] = set()
    covered_sources: Dict[Tuple[str, str], Set[str]] = defaultdict(set)

    # Scan Rust source files
    for root, _, files in os.walk(src_dir):
        for f in files:
            if not f.endswith(".rs"):
                continue

            filepath = os.path.join(root, f)
            relpath = os.path.relpath(filepath, src_dir)

            with open(filepath, "r", encoding="utf-8", errors="ignore") as fp:
                content = fp.read()

            # 1. Match Doc comments via DOC_LINK_REGEX
            for match in DOC_LINK_REGEX.finditer(content):
                category, anchor = match.group(1), match.group(2)
                doc_key = f"{category}#{anchor}" if anchor else category
                
                matched_op = doc_url_to_op.get(doc_key)
                if not matched_op and anchor:
                    matched_op = anchor_to_op.get(anchor)

                if matched_op:
                    covered_ops.add(matched_op)
                    covered_sources[matched_op].add(relpath)

            # 2. Match Route strings and HTTP method invocations
            routes = ROUTE_TEMPLATE_REGEX.findall(content)
            methods = [m.upper() for m in HTTP_METHOD_REGEX.findall(content)]
            if routes and methods:
                for r in routes:
                    norm_r = normalize_route(r)
                    for m in set(methods):
                        matched_ops = norm_path_to_ops.get((m, norm_r), [])
                        for op_key in matched_ops:
                            covered_ops.add(op_key)
                            covered_sources[op_key].add(relpath)

    # Group statistics by category
    category_stats = defaultdict(lambda: {"total": 0, "covered": 0, "missing": []})
    for op_key, op_info in active_operations.items():
        cat = op_info["category"]
        category_stats[cat]["total"] += 1
        if op_key in covered_ops:
            category_stats[cat]["covered"] += 1
        else:
            category_stats[cat]["missing"].append(op_info)

    covered_count = len(covered_ops)
    missing_count = total_active - covered_count
    coverage_percentage = (covered_count / total_active * 100.0) if total_active > 0 else 0.0

    return {
        "total_active": total_active,
        "covered_count": covered_count,
        "missing_count": missing_count,
        "deprecated_count": deprecated_count,
        "coverage_percentage": round(coverage_percentage, 1),
        "covered_operations": [
            {
                "method": op_key[0],
                "path": op_key[1],
                "operationId": active_operations[op_key]["operationId"],
                "summary": active_operations[op_key]["summary"],
                "category": active_operations[op_key]["category"],
                "sources": sorted(list(covered_sources[op_key])),
            }
            for op_key in covered_ops
        ],
        "missing_operations": [
            op_info
            for op_key, op_info in active_operations.items()
            if op_key not in covered_ops
        ],
        "category_stats": dict(category_stats),
    }


def generate_badge_payload(stats: Dict[str, Any]) -> Dict[str, Any]:
    """Generates the Shields.io dynamic JSON badge payload."""
    pct = stats["coverage_percentage"]
    color = get_badge_color(pct)
    return {
        "schemaVersion": 1,
        "label": "REST API v2022-11-28",
        "message": f"{pct:.1f}%",
        "color": color,
        "total": stats["total_active"],
        "covered": stats["covered_count"],
        "missing": stats["missing_count"],
        "percentage": pct,
    }


def generate_missing_report(missing_ops: List[Dict[str, Any]]) -> str:
    """Generates a structured Markdown report of all missing active endpoints."""
    by_category = defaultdict(list)
    for op in missing_ops:
        by_category[op["category"]].append(op)

    lines = [
        "# Missing Active GitHub REST API (v2022-11-28) Endpoints",
        "",
        f"Total missing active endpoints: **{len(missing_ops)}**",
        "",
        "> This document is automatically generated by `scripts/calculate_api_coverage.py`.",
        "> Deprecated endpoints from the OpenAPI schema are completely excluded.",
        "",
    ]

    for cat in sorted(by_category.keys()):
        ops = by_category[cat]
        lines.append(f"## {cat.capitalize()} ({len(ops)})")
        lines.append("")
        lines.append("| Method | Endpoint Path | Summary | Docs |")
        lines.append("| :--- | :--- | :--- | :--- |")
        for op in sorted(ops, key=lambda x: (x["path"], x["method"])):
            doc_link = f"[Docs]({op['externalDocs']})" if op.get("externalDocs") else "N/A"
            lines.append(f"| `{op['method']}` | `{op['path']}` | {op.get('summary', 'N/A')} | {doc_link} |")
        lines.append("")

    return "\n".join(lines)


def generate_job_summary(stats: Dict[str, Any]) -> str:
    """Generates Markdown for GitHub Actions Job Summaries."""
    total = stats["total_active"]
    covered = stats["covered_count"]
    missing = stats["missing_count"]
    deprecated = stats["deprecated_count"]
    pct = stats["coverage_percentage"]
    progress_bar = render_progress_bar(pct, width=25)

    summary_lines = [
        "# 🐙 GitHub REST API (v2022-11-28) Endpoint Coverage",
        "",
        "| Metric | Count | Percentage |",
        "| :--- | :--- | :--- |",
        f"| **Active Endpoints** | {total:,} | 100.0% |",
        f"| **Covered in Octocrab** | **{covered:,}** | **{pct:.1f}%** |",
        f"| **Missing Endpoints** | {missing:,} | {100.0 - pct:.1f}% |",
        f"| **Deprecated Endpoints** | {deprecated:,} | *(Excluded)* |",
        "",
        f"> `{progress_bar}`",
        "",
        "### 📊 Coverage by API Domain",
        "",
        "| Domain / Category | Covered | Total | Coverage | Status |",
        "| :--- | :--- | :--- | :--- | :--- |",
    ]

    sorted_categories = sorted(
        stats["category_stats"].items(),
        key=lambda item: (item[1]["covered"] / item[1]["total"] if item[1]["total"] else 0),
        reverse=True,
    )

    for cat, cat_data in sorted_categories:
        c_tot = cat_data["total"]
        c_cov = cat_data["covered"]
        c_pct = (c_cov / c_tot * 100.0) if c_tot > 0 else 0.0
        status_emoji = "🟢" if c_pct >= 80.0 else "🟡" if c_pct >= 40.0 else "🔴"
        summary_lines.append(
            f"| **{cat}** | {c_cov} | {c_tot} | {c_pct:.1f}% | {status_emoji} |"
        )

    summary_lines.extend([
        "",
        f"<details>",
        f"<summary>🔍 Click to view all missing active endpoints ({missing} total by category)</summary>",
        "",
    ])

    by_category = defaultdict(list)
    for op in stats["missing_operations"]:
        by_category[op["category"]].append(op)

    for cat in sorted(by_category.keys()):
        ops = by_category[cat]
        summary_lines.append(f"<details><summary><b>{cat}</b> ({len(ops)} missing)</summary>\n")
        summary_lines.append("| Method | Path | Summary | Docs |")
        summary_lines.append("| :--- | :--- | :--- | :--- |")
        for op in ops:
            doc_link = f"[Docs]({op['externalDocs']})" if op.get("externalDocs") else "N/A"
            summary_lines.append(
                f"| `{op['method']}` | `{op['path']}` | {op.get('summary', 'N/A')} | {doc_link} |"
            )
        summary_lines.append("\n</details>\n")

    summary_lines.append("</details>\n")
    return "\n".join(summary_lines)


def main():
    parser = argparse.ArgumentParser(
        description="Calculate GitHub REST API v2022-11-28 coverage for Octocrab."
    )
    parser.add_argument(
        "--spec-url",
        default=DEFAULT_SPEC_URL,
        help="URL of the official GitHub REST API OpenAPI spec (2022-11-28)",
    )
    parser.add_argument(
        "--spec-cache",
        default=DEFAULT_CACHE_PATH,
        help="Local file path for caching the OpenAPI specification",
    )
    parser.add_argument(
        "--src-dir",
        default="src",
        help="Path to Octocrab src directory (default: src)",
    )
    parser.add_argument(
        "--badge-out",
        help="Path to output Shields.io endpoint-coverage.json file",
    )
    parser.add_argument(
        "--summary-out",
        help="Path to output GitHub Actions Job Summary Markdown file",
    )
    parser.add_argument(
        "--details-out",
        help="Path to output full coverage details JSON file",
    )
    parser.add_argument(
        "--missing-out",
        help="Path to output missing endpoints file (.md or .json)",
    )

    args = parser.parse_args()

    # Load OpenAPI spec
    spec = fetch_or_load_spec(args.spec_url, args.spec_cache)

    # Analyze coverage
    print(f"Scanning '{args.src_dir}' for covered endpoints...")
    stats = analyze_coverage(spec, args.src_dir)

    print("\n" + "=" * 50)
    print(f"Active Endpoints:  {stats['total_active']}")
    print(f"Covered:           {stats['covered_count']}")
    print(f"Missing:           {stats['missing_count']}")
    print(f"Coverage:          {stats['coverage_percentage']:.1f}%")
    print("=" * 50 + "\n")

    # Output badge JSON
    if args.badge_out:
        badge_payload = generate_badge_payload(stats)
        os.makedirs(os.path.dirname(args.badge_out) or ".", exist_ok=True)
        with open(args.badge_out, "w", encoding="utf-8") as f:
            json.dump(badge_payload, f, indent=2)
        print(f"Saved Shields.io badge JSON to: {args.badge_out}")

    # Output details JSON
    if args.details_out:
        os.makedirs(os.path.dirname(args.details_out) or ".", exist_ok=True)
        with open(args.details_out, "w", encoding="utf-8") as f:
            json.dump(stats, f, indent=2)
        print(f"Saved full coverage details JSON to: {args.details_out}")

    # Output Missing Endpoints report (MD or JSON)
    if args.missing_out:
        os.makedirs(os.path.dirname(args.missing_out) or ".", exist_ok=True)
        if args.missing_out.endswith(".json"):
            with open(args.missing_out, "w", encoding="utf-8") as f:
                json.dump(stats["missing_operations"], f, indent=2)
        else:
            missing_md = generate_missing_report(stats["missing_operations"])
            with open(args.missing_out, "w", encoding="utf-8") as f:
                f.write(missing_md)
        print(f"Saved missing endpoints report to: {args.missing_out}")

    # Output Job Summary
    summary_markdown = generate_job_summary(stats)
    if args.summary_out:
        os.makedirs(os.path.dirname(args.summary_out) or ".", exist_ok=True)
        with open(args.summary_out, "a", encoding="utf-8") as f:
            f.write(summary_markdown + "\n")
        print(f"Appended GitHub Actions Job Summary to: {args.summary_out}")


if __name__ == "__main__":
    main()