diff --git a/agents/codex-942.md b/agents/codex-942.md new file mode 100644 index 000000000..3b2902501 --- /dev/null +++ b/agents/codex-942.md @@ -0,0 +1 @@ + diff --git a/scripts/analyze_api_rate_limits.py b/scripts/analyze_api_rate_limits.py index 055e2ebe2..1c58650fa 100755 --- a/scripts/analyze_api_rate_limits.py +++ b/scripts/analyze_api_rate_limits.py @@ -10,10 +10,11 @@ import argparse import json import os +import re import subprocess import sys from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any @@ -102,7 +103,7 @@ def get_rate_limits(token: str | None = None) -> dict[str, Any] | None: return None -def get_workflow_runs(repo: str, token: str | None = None, hours: int = 1) -> dict[str, Any]: +def get_workflow_runs(repo: str, token: str | None = None) -> dict[str, Any]: """Get recent workflow runs for a repository.""" env = os.environ.copy() if token: @@ -128,6 +129,212 @@ def get_workflow_runs(repo: str, token: str | None = None, hours: int = 1) -> di return {"workflow_runs": [], "total_count": 0} +def _parse_github_timestamp(value: str) -> datetime | None: + """Parse GitHub timestamp strings into timezone-aware datetimes.""" + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed + + +def _normalize_now(now: datetime | None) -> datetime: + """Ensure a timezone-aware timestamp for comparison.""" + if now is None: + return datetime.now(tz=UTC) + if now.tzinfo is None: + return now.replace(tzinfo=UTC) + return now + + +def _extract_run_timestamp(run: dict[str, Any]) -> datetime | None: + """Select the best available timestamp for a workflow run.""" + for key in ("created_at", "run_started_at", "updated_at"): + value = run.get(key) + if not value: + continue + parsed = _parse_github_timestamp(str(value)) + if parsed: + return parsed + return None + + +def _normalize_repos(repos: list[str]) -> list[str]: + """Normalize repo inputs into clean owner/repo strings.""" + normalized: list[str] = [] + seen: set[str] = set() + for raw_repo in repos: + for line in str(raw_repo).splitlines(): + for repo in _split_repo_entries(line): + repo = _clean_repo(repo) + if repo and repo not in seen: + normalized.append(repo) + seen.add(repo) + return normalized + + +_REPO_ENTRY = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\\.git)?(?:@\\S+)?") + + +def _split_repo_entries(raw: str) -> list[str]: + """Split raw input into repo-like entries.""" + raw = raw.strip() + if not raw: + return [] + if "," in raw: + entries: list[str] = [] + for chunk in raw.split(","): + chunk = chunk.strip() + if not chunk: + continue + entries.extend(_split_repo_entries(chunk)) + return entries + + matches = _REPO_ENTRY.findall(raw) + if len(matches) > 1: + remainder = _REPO_ENTRY.sub("", raw) + if re.fullmatch(r"[\s,;|]*", remainder): + return matches + return [raw] + + +# Pattern to identify tokens that are valid GitHub URLs +# Matches URLs where github.com is the actual host, not a substring of another domain +_GITHUB_URL_PATTERN = re.compile(r"^(?:https?://|git://|ssh://)?(?:git@)?(?:www\.)?github\.com[/:]") + + +def _is_github_url(token: str) -> bool: + """Check if a token is a valid GitHub URL (not a lookalike domain).""" + return bool(_GITHUB_URL_PATTERN.match(token)) + + +def _strip_wrapping_repo(value: str) -> str: + """Strip wrapping punctuation from a repo-like string.""" + repo = value.strip().strip("`'\"") + for left, right in (("<", ">"), ("[", "]"), ("(", ")")): + if repo.startswith(left) and repo.endswith(right): + repo = repo[1:-1].strip() + break + return repo.strip(" ,.;:") + + +def _clean_repo(repo: str) -> str: + """Normalize repo string from common URL or git formats.""" + repo = _strip_wrapping_repo(repo) + if not repo: + return "" + if repo.endswith(")") and " (" in repo: + repo = repo.rsplit(" (", 1)[0].strip() + tokens = repo.split() + if len(tokens) > 1: + # Use proper URL validation instead of substring check to avoid + # matching lookalike domains (e.g., evil-github.com, github.com.evil.org) + candidate = next((token for token in tokens if _is_github_url(token)), None) + if candidate is None: + # Prefer simple owner/repo format over URLs with "/" that might be + # from malicious domains. Skip anything that looks like a URL scheme. + candidate = next( + ( + token + for token in tokens + if "/" in token + and not token.startswith("(") + and "://" not in token + and not token.startswith("git@") + ), + None, + ) + if candidate is None: + # Last resort: pick any token with "/" but not a known malicious URL pattern + candidate = next( + (token for token in tokens if "/" in token and not token.startswith("(")), + None, + ) + repo = candidate or tokens[-1] + repo = _strip_wrapping_repo(repo) + if repo.startswith("ssh://"): + repo = repo[len("ssh://") :] + for host in ("github.com", "www.github.com"): + if f"@{host}" in repo: + repo = repo.split("@", 1)[1] + break + for prefix in ( + "https://github.com/", + "http://github.com/", + "git://github.com/", + "github.com/", + "www.github.com/", + ): + if repo.startswith(prefix): + repo = repo[len(prefix) :] + break + if repo.startswith("git@github.com:") or repo.startswith("git@www.github.com:"): + repo = repo.split(":", 1)[1] + if repo.split("/", 1)[0].isdigit(): + repo = repo.split("/", 1)[1] if "/" in repo else repo + elif repo.startswith("git@github.com/") or repo.startswith("git@www.github.com/"): + repo = repo.split("/", 1)[1] + elif repo.startswith("github.com:") or repo.startswith("www.github.com:"): + repo = repo.split(":", 1)[1] + if repo.split("/", 1)[0].isdigit(): + repo = repo.split("/", 1)[1] if "/" in repo else repo + repo = repo.split("?", 1)[0].split("#", 1)[0] + repo = repo.rstrip("/") + if "@" in repo: + repo = repo.split("@", 1)[0] + parts = [part for part in repo.split("/") if part] + if len(parts) < 2: + return "" + repo = "/".join(parts[:2]) + if repo.endswith(".git"): + repo = repo[:-4] + return repo + + +def summarize_workflow_activity( + repos: list[str], + *, + token: str | None = None, + hours: int = 1, + now: datetime | None = None, +) -> list[dict[str, Any]]: + """Summarize recent workflow activity for the requested repositories.""" + normalized_repos = _normalize_repos(repos) + if not normalized_repos: + return [] + + window_start = _normalize_now(now) - timedelta(hours=hours) + summaries: list[dict[str, Any]] = [] + + for repo in normalized_repos: + data = get_workflow_runs(repo, token=token) + runs_raw = data.get("workflow_runs", []) + runs = [] + if isinstance(runs_raw, list): + runs = [run for run in runs_raw if isinstance(run, dict)] + recent_runs = [] + for run in runs: + created_dt = _extract_run_timestamp(run) + if created_dt and created_dt >= window_start: + recent_runs.append(run) + summaries.append( + { + "repo": repo, + "window_hours": hours, + "recent_runs": len(recent_runs), + "total_runs": ( + data.get("total_count") + if isinstance(data.get("total_count"), int) + else len(runs) + ), + } + ) + + return summaries + + def analyze_rate_limits(tokens: dict[str, str | None]) -> list[TokenRateLimits]: """Analyze rate limits for multiple tokens.""" results = [] @@ -161,6 +368,23 @@ def print_utilization_table(limits: list[TokenRateLimits]) -> None: print("-" * 80) + optional_entries: list[tuple[str, str, RateLimitInfo]] = [] + for trl in limits: + if trl.code_search is not None: + optional_entries.append((trl.source, "Code Search", trl.code_search)) + if trl.actions_runner is not None: + optional_entries.append((trl.source, "Actions Runner Registration", trl.actions_runner)) + + if optional_entries: + print("\nOPTIONAL RESOURCE UTILIZATION") + print("-" * 80) + print(f"{'Token':<25} {'Resource':<30} {'Used/Limit (%)':<20}") + print("-" * 80) + for source, name, info in optional_entries: + info_str = f"{info.used}/{info.limit} ({info.utilization_pct:.1f}%)" + print(f"{source:<25} {name:<30} {info_str:<20}") + print("-" * 80) + def print_warnings(limits: list[TokenRateLimits]) -> list[str]: """Print warnings for high utilization and return list of warnings.""" @@ -170,11 +394,17 @@ def print_warnings(limits: list[TokenRateLimits]) -> list[str]: has_warnings = False for trl in limits: - for resource_name, resource in [ + resources = [ ("Core", trl.core), ("GraphQL", trl.graphql), ("Search", trl.search), - ]: + ] + if trl.code_search is not None: + resources.append(("Code Search", trl.code_search)) + if trl.actions_runner is not None: + resources.append(("Actions Runner Registration", trl.actions_runner)) + + for resource_name, resource in resources: pct = resource.utilization_pct if pct > 80: msg = f"šŸ”“ CRITICAL: {trl.source} {resource_name} at {pct:.1f}%" @@ -234,6 +464,31 @@ def print_recommendations() -> None: print(f" {rec}") +def print_workflow_activity(summaries: list[dict[str, Any]]) -> None: + """Print workflow activity summary.""" + if not summaries: + return + print("\nšŸ“Š WORKFLOW ACTIVITY") + print("-" * 40) + for summary in summaries: + repo = summary.get("repo", "unknown") + window = summary.get("window_hours", "?") + recent = summary.get("recent_runs", 0) + total = summary.get("total_runs", 0) + print(f"{repo}: {recent} run(s) in last {window}h (total reported: {total})") + + +def _rate_limit_payload(info: RateLimitInfo) -> dict[str, Any]: + """Serialize rate limit info for JSON output.""" + return { + "limit": info.limit, + "remaining": info.remaining, + "used": info.used, + "utilization_pct": round(info.utilization_pct, 2), + "reset": info.reset_time, + } + + def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( @@ -250,6 +505,12 @@ def main() -> int: metavar="REPO", help="Also check workflow activity in specified repos (owner/repo format)", ) + parser.add_argument( + "--workflow-hours", + type=int, + default=1, + help="Time window (hours) for workflow activity checks (default: 1)", + ) parser.add_argument( "--pat-env", default="CODESPACES_WORKFLOWS", @@ -276,36 +537,36 @@ def main() -> int: print("Error: Could not retrieve rate limits for any token", file=sys.stderr) return 1 + workflow_summaries: list[dict[str, Any]] = [] + if args.check_repos: + token_for_workflows = next((value for value in tokens.values() if value), None) + workflow_summaries = summarize_workflow_activity( + args.check_repos, + token=token_for_workflows, + hours=args.workflow_hours, + ) + if args.json: # JSON output for programmatic use output = { "timestamp": datetime.now(tz=UTC).isoformat(), "tokens": {}, } + if workflow_summaries: + output["workflow_activity"] = workflow_summaries for trl in limits: - output["tokens"][trl.source] = { - "core": { - "limit": trl.core.limit, - "remaining": trl.core.remaining, - "used": trl.core.used, - "utilization_pct": round(trl.core.utilization_pct, 2), - "reset": trl.core.reset_time, - }, - "graphql": { - "limit": trl.graphql.limit, - "remaining": trl.graphql.remaining, - "used": trl.graphql.used, - "utilization_pct": round(trl.graphql.utilization_pct, 2), - "reset": trl.graphql.reset_time, - }, - "search": { - "limit": trl.search.limit, - "remaining": trl.search.remaining, - "used": trl.search.used, - "utilization_pct": round(trl.search.utilization_pct, 2), - "reset": trl.search.reset_time, - }, + token_payload = { + "core": _rate_limit_payload(trl.core), + "graphql": _rate_limit_payload(trl.graphql), + "search": _rate_limit_payload(trl.search), } + if trl.code_search is not None: + token_payload["code_search"] = _rate_limit_payload(trl.code_search) + if trl.actions_runner is not None: + token_payload["actions_runner_registration"] = _rate_limit_payload( + trl.actions_runner + ) + output["tokens"][trl.source] = token_payload print(json.dumps(output, indent=2)) return 0 @@ -314,6 +575,7 @@ def main() -> int: warnings = print_warnings(limits) print_load_balance_analysis(limits) print_recommendations() + print_workflow_activity(workflow_summaries) # Return non-zero if critical warnings critical = any("CRITICAL" in w for w in warnings) diff --git a/scripts/langchain/followup_issue_generator.py b/scripts/langchain/followup_issue_generator.py index 40cfb8038..07326d2fc 100755 --- a/scripts/langchain/followup_issue_generator.py +++ b/scripts/langchain/followup_issue_generator.py @@ -60,7 +60,7 @@ "implementation": "Implementation Notes", } -LIST_ITEM_REGEX = re.compile(r"^\s*([-*+]|\d+[.)])\s+(.*)$") +LIST_ITEM_REGEX = re.compile(r"^\s*([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$") CHECKBOX_REGEX = re.compile(r"^\[([ xX])\]\s*(.*)$") # Prompts for multi-round LLM interaction diff --git a/scripts/langchain/issue_formatter.py b/scripts/langchain/issue_formatter.py index ec1273ca5..385f250a8 100755 --- a/scripts/langchain/issue_formatter.py +++ b/scripts/langchain/issue_formatter.py @@ -78,7 +78,7 @@ "implementation": "Implementation Notes", } -LIST_ITEM_REGEX = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$") +LIST_ITEM_REGEX = re.compile(r"^(\s*)([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$") CHECKBOX_REGEX = re.compile(r"^\[([ xX])\]\s*(.*)$") diff --git a/scripts/langchain/issue_optimizer.py b/scripts/langchain/issue_optimizer.py index 87ba9f764..172951d8d 100755 --- a/scripts/langchain/issue_optimizer.py +++ b/scripts/langchain/issue_optimizer.py @@ -87,11 +87,11 @@ "implementation": "Implementation Notes", } -LIST_ITEM_REGEX = re.compile(r"^\s*[-*+]\s+(.*)$") +LIST_ITEM_REGEX = re.compile(r"^\s*([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$") CHECKBOX_REGEX = re.compile(r"^\[[ xX]\]\s*(.*)$") SUBJECTIVE_CRITERIA = ("clean", "nice", "good", "fast", "better", "intuitive", "polished") -SUGGESTIONS_MARKER_PREFIX = "Updated WORKFLOW_OUTPUTS.md suggestions-json:" +SUGGESTIONS_MARKER_PREFIX = "suggestions-json:" @dataclass @@ -291,13 +291,25 @@ def _resolve_section(label: str) -> str | None: def _parse_sections(body: str) -> dict[str, list[str]]: sections: dict[str, list[str]] = {key: [] for key in SECTION_TITLES} current: str | None = None + in_code_block = False for line in body.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + in_code_block = not in_code_block + if in_code_block: + if current: + sections[current].append(line) + continue heading_match = re.match(r"^\s*#{1,6}\s+(.*)$", line) if heading_match: section_key = _resolve_section(heading_match.group(1)) if section_key: current = section_key continue + section_key = _resolve_section(stripped) + if section_key and stripped: + current = section_key + continue if current: sections[current].append(line) return sections @@ -308,7 +320,7 @@ def _strip_checkbox(line: str) -> str: match = LIST_ITEM_REGEX.match(stripped) if not match: return stripped - content = match.group(1).strip() + content = match.group(match.lastindex).strip() checkbox = CHECKBOX_REGEX.match(content) if checkbox: return checkbox.group(1).strip() @@ -317,10 +329,17 @@ def _strip_checkbox(line: str) -> str: def _parse_checklist(lines: list[str]) -> list[str]: items: list[str] = [] + in_code_block = False for line in lines: - if not line.strip(): + stripped = line.strip() + if stripped.startswith("```"): + in_code_block = not in_code_block + continue + if in_code_block: + continue + if not stripped: continue - if LIST_ITEM_REGEX.match(line.strip()): + if LIST_ITEM_REGEX.match(stripped): value = _strip_checkbox(line) if value: items.append(value) @@ -420,7 +439,7 @@ def _extract_json_payload(text: str) -> str | None: def _extract_suggestions_json(comment_body: str) -> dict[str, Any] | None: if not comment_body: return None - marker = "suggestions-json:" + marker = SUGGESTIONS_MARKER_PREFIX start = comment_body.find(marker) if start == -1: return None @@ -442,7 +461,7 @@ def _formatted_output_valid(text: str) -> bool: def _strip_task_marker(text: str) -> str: - cleaned = re.sub(r"^\s*[-*+]\s*", "", text) + cleaned = re.sub(r"^\s*([-*+]|\d+[.)]|[A-Za-z][.)])\s*", "", text) cleaned = re.sub(r"^\s*\[[ xX]\]\s*", "", cleaned) return cleaned.strip() diff --git a/scripts/langchain/task_decomposer.py b/scripts/langchain/task_decomposer.py index ac1da7d18..9c91b7ba4 100755 --- a/scripts/langchain/task_decomposer.py +++ b/scripts/langchain/task_decomposer.py @@ -31,7 +31,7 @@ PROMPT_PATH = Path(__file__).resolve().parent / "prompts" / "decompose_task.md" -LIST_ITEM_REGEX = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$") +LIST_ITEM_REGEX = re.compile(r"^\s*(?:[-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$") DEPENDENCY_PHRASE_REGEX = re.compile( r"\b(depends on|blocked by|waiting for|post-merge|" r"(?:after|once|when)\b[^,]*\bmerge\b|requires\b[^.]*\bmerge\b)\b", diff --git a/tests/scripts/test_analyze_api_rate_limits.py b/tests/scripts/test_analyze_api_rate_limits.py new file mode 100644 index 000000000..9f2f97b92 --- /dev/null +++ b/tests/scripts/test_analyze_api_rate_limits.py @@ -0,0 +1,714 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime + +from scripts import analyze_api_rate_limits + + +def test_parse_github_timestamp() -> None: + parsed = analyze_api_rate_limits._parse_github_timestamp("2025-01-02T03:04:05Z") + assert parsed is not None + assert parsed.year == 2025 + assert parsed.month == 1 + assert parsed.day == 2 + assert parsed.tzinfo == UTC + assert analyze_api_rate_limits._parse_github_timestamp("not-a-time") is None + + +def test_parse_github_timestamp_assumes_utc_for_naive() -> None: + parsed = analyze_api_rate_limits._parse_github_timestamp("2025-01-02T03:04:05") + assert parsed is not None + assert parsed.tzinfo == UTC + + +def test_summarize_workflow_activity_handles_naive_now(monkeypatch) -> None: + runs = [ + {"created_at": "2025-01-01T10:30:00Z"}, + {"created_at": "2025-01-01T08:59:59Z"}, + ] + + def fake_get_workflow_runs(_repo: str, token: str | None = None) -> dict[str, object]: + return {"workflow_runs": runs, "total_count": 2} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo"], + token="token", + hours=1, + now=now, + ) + assert summaries[0]["recent_runs"] == 1 + + +def test_summarize_workflow_activity_handles_naive_run_timestamps(monkeypatch) -> None: + runs = [ + {"created_at": "2025-01-01T10:30:00"}, + {"created_at": "2025-01-01T08:59:59"}, + ] + + def fake_get_workflow_runs(_repo: str, token: str | None = None) -> dict[str, object]: + return {"workflow_runs": runs, "total_count": 2} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo"], + token="token", + hours=1, + now=now, + ) + assert summaries[0]["recent_runs"] == 1 + + +def test_summarize_workflow_activity_normalizes_repos(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [" owner/repo ", "owner2/repo2, owner3/repo3", ""], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo", "owner2/repo2", "owner3/repo3"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_dedupes_repos(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo", "owner/repo", "owner/repo, owner2/repo2"], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo", "owner2/repo2"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_normalizes_repo_urls(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "https://github.com/owner/repo", + "github.com/owner/repo", + "git@github.com:owner/repo.git", + "ssh://user@github.com/owner/repo", + "user@github.com/owner/repo", + "user@www.github.com/owner/repo", + "ssh://git@github.com/owner/repo.git", + "ssh://github.com/owner/repo", + "ssh://github.com/owner/repo.git", + "git://github.com/owner/repo.git", + "owner/repo/", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_rejects_lookalike_github_domains(monkeypatch) -> None: + """Regression test for CodeQL security alert: incomplete URL substring sanitization. + + Ensures that lookalike domains (e.g., evil-github.com, github.com.evil.org) + are not mistakenly matched as valid GitHub URLs when selecting candidates + from whitespace-separated input tokens. + """ + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + + # When given space-separated input with a lookalike domain and a valid repo, + # the function should select the simple owner/repo format, not the lookalike URL + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "origin https://evil-github.com/attacker/repo owner/repo", + "upstream https://github.com.evil.org/attacker/repo2 owner2/repo2", + ], + token="token", + hours=1, + now=now, + ) + + # Should extract the simple owner/repo, not the malicious URLs + assert calls == ["owner/repo", "owner2/repo2"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_is_github_url_validates_host_correctly() -> None: + """Test that _is_github_url properly validates GitHub URLs.""" + # Valid GitHub URLs + assert analyze_api_rate_limits._is_github_url("https://github.com/owner/repo") + assert analyze_api_rate_limits._is_github_url("http://github.com/owner/repo") + assert analyze_api_rate_limits._is_github_url("git://github.com/owner/repo") + assert analyze_api_rate_limits._is_github_url("ssh://github.com/owner/repo") + assert analyze_api_rate_limits._is_github_url("github.com/owner/repo") + assert analyze_api_rate_limits._is_github_url("git@github.com:owner/repo") + assert analyze_api_rate_limits._is_github_url("https://www.github.com/owner/repo") + assert analyze_api_rate_limits._is_github_url("git@www.github.com:owner/repo") + + # Invalid lookalike domains (these should NOT match) + assert not analyze_api_rate_limits._is_github_url("https://evil-github.com/owner/repo") + assert not analyze_api_rate_limits._is_github_url("https://github.com.evil.org/owner/repo") + assert not analyze_api_rate_limits._is_github_url("https://fakegithub.com/owner/repo") + assert not analyze_api_rate_limits._is_github_url("https://my-github.com/owner/repo") + assert not analyze_api_rate_limits._is_github_url("evil-github.com/owner/repo") + assert not analyze_api_rate_limits._is_github_url("github.com.evil.org/owner/repo") + + +def test_summarize_workflow_activity_splits_space_delimited_repos(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo owner2/repo2 owner3/repo3"], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo", "owner2/repo2", "owner3/repo3"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_splits_mixed_delimiters(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo, owner2/repo2 owner3/repo3"], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo", "owner2/repo2", "owner3/repo3"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_splits_semicolon_and_pipe(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo; owner2/repo2|owner3/repo3"], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo", "owner2/repo2", "owner3/repo3"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_normalizes_git_remote_outputs(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "git@github.com:owner/repo.git (fetch)", + "https://github.com/owner/repo.git (push)", + "ssh://git@github.com/owner/repo.git (fetch)", + "origin https://github.com/owner/repo.git (fetch)", + "upstream\tgit@github.com:owner/repo.git (push)", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_strips_wrapping_punctuation(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "", + "[owner2/repo2]", + "`owner3/repo3`", + '"owner4/repo4"', + "owner5/repo5,", + "owner6/repo6;", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == [ + "owner/repo", + "owner2/repo2", + "owner3/repo3", + "owner4/repo4", + "owner5/repo5", + "owner6/repo6", + ] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_extracts_repo_from_listing_output(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "owner/repo One repo description", + "owner2/repo2\tAnother description here", + "origin owner3/repo3", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo", "owner2/repo2", "owner3/repo3"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_handles_multiline_repo_inputs(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "origin https://github.com/owner/repo.git (fetch)\n" + "origin https://github.com/owner/repo.git (push)", + "upstream git@github.com:owner2/repo2.git (fetch)\n", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo", "owner2/repo2"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_ignores_incomplete_repos(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "owner", + "github.com/owner", + "https://github.com/owner/repo", + "owner/repo", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_normalizes_ssh_ports(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "ssh://git@github.com:22/owner/repo.git", + "git@github.com:22/owner/repo", + "ssh://user@github.com:2222/owner/repo", + "ssh://github.com:2222/owner/repo", + "github.com:owner/repo", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_trims_repo_paths(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "https://github.com/owner/repo/tree/main", + "owner/repo/blob/main/README.md", + "owner/repo.git/extra/path", + "https://github.com/owner/repo?tab=readme", + "owner/repo#readme", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_summarize_workflow_activity_strips_repo_refs(monkeypatch) -> None: + calls: list[str] = [] + + def fake_get_workflow_runs(repo: str, token: str | None = None) -> dict[str, object]: + calls.append(repo) + return {"workflow_runs": [], "total_count": 0} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + [ + "owner/repo@main", + "https://github.com/owner/repo@refs/heads/main", + "git@github.com:owner/repo@v1", + "owner/repo.git@v1", + ], + token="token", + hours=1, + now=now, + ) + + assert calls == ["owner/repo"] + assert [summary["repo"] for summary in summaries] == calls + + +def test_main_json_includes_workflow_activity(monkeypatch, capsys) -> None: + token_limits = analyze_api_rate_limits.TokenRateLimits( + source="GITHUB_TOKEN", + core=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + graphql=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + search=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + code_search=analyze_api_rate_limits.RateLimitInfo( + limit=20, remaining=15, used=5, reset_timestamp=0 + ), + actions_runner=analyze_api_rate_limits.RateLimitInfo( + limit=10, remaining=9, used=1, reset_timestamp=0 + ), + ) + + def fake_analyze_rate_limits( + _tokens: dict[str, str | None], + ) -> list[analyze_api_rate_limits.TokenRateLimits]: + return [token_limits] + + def fake_summarize_workflow_activity( + repos: list[str], + *, + token: str | None = None, + hours: int = 1, + now: datetime | None = None, + ) -> list[dict[str, object]]: + assert repos == ["owner/repo"] + assert token == "token" + assert hours == 2 + return [ + { + "repo": "owner/repo", + "window_hours": 2, + "recent_runs": 0, + "total_runs": 0, + } + ] + + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(analyze_api_rate_limits, "analyze_rate_limits", fake_analyze_rate_limits) + monkeypatch.setattr( + analyze_api_rate_limits, "summarize_workflow_activity", fake_summarize_workflow_activity + ) + monkeypatch.setattr( + analyze_api_rate_limits.sys, + "argv", + ["script", "--json", "--check-repos", "owner/repo", "--workflow-hours", "2"], + ) + + assert analyze_api_rate_limits.main() == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["workflow_activity"] == [ + { + "repo": "owner/repo", + "window_hours": 2, + "recent_runs": 0, + "total_runs": 0, + } + ] + token_payload = payload["tokens"]["GITHUB_TOKEN"] + assert token_payload["code_search"] == analyze_api_rate_limits._rate_limit_payload( + token_limits.code_search + ) + assert token_payload[ + "actions_runner_registration" + ] == analyze_api_rate_limits._rate_limit_payload(token_limits.actions_runner) + + +def test_summarize_workflow_activity_filters_window(monkeypatch) -> None: + runs = [ + {"created_at": "2025-01-01T10:00:00Z"}, + {"created_at": "2025-01-01T08:59:59Z"}, + {"created_at": "not-a-time"}, + {}, + ] + + def fake_get_workflow_runs(_repo: str, token: str | None = None) -> dict[str, object]: + return {"workflow_runs": runs, "total_count": 4} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo"], + token="token", + hours=2, + now=now, + ) + assert summaries == [ + { + "repo": "owner/repo", + "window_hours": 2, + "recent_runs": 1, + "total_runs": 4, + } + ] + + +def test_summarize_workflow_activity_falls_back_to_other_timestamps(monkeypatch) -> None: + runs = [ + {"run_started_at": "2025-01-01T10:30:00Z"}, + {"created_at": "not-a-time", "updated_at": "2025-01-01T10:45:00Z"}, + {"updated_at": "2025-01-01T09:59:59Z"}, + ] + + def fake_get_workflow_runs(_repo: str, token: str | None = None) -> dict[str, object]: + return {"workflow_runs": runs, "total_count": 3} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo"], + token="token", + hours=1, + now=now, + ) + assert summaries[0]["recent_runs"] == 2 + + +def test_summarize_workflow_activity_falls_back_total_count(monkeypatch) -> None: + runs = [{"created_at": "2025-01-01T10:00:00Z"}] + + def fake_get_workflow_runs(_repo: str, token: str | None = None) -> dict[str, object]: + return {"workflow_runs": runs, "total_count": None} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo"], + token="token", + hours=2, + now=now, + ) + assert summaries[0]["total_runs"] == 1 + + +def test_summarize_workflow_activity_handles_non_list_runs(monkeypatch) -> None: + def fake_get_workflow_runs(_repo: str, token: str | None = None) -> dict[str, object]: + return {"workflow_runs": None, "total_count": None} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo"], + token="token", + hours=2, + now=now, + ) + assert summaries[0]["recent_runs"] == 0 + assert summaries[0]["total_runs"] == 0 + + +def test_summarize_workflow_activity_ignores_non_dict_runs(monkeypatch) -> None: + runs = [{"created_at": "2025-01-01T10:00:00Z"}, "oops", 123] + + def fake_get_workflow_runs(_repo: str, token: str | None = None) -> dict[str, object]: + return {"workflow_runs": runs, "total_count": None} + + monkeypatch.setattr(analyze_api_rate_limits, "get_workflow_runs", fake_get_workflow_runs) + now = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + summaries = analyze_api_rate_limits.summarize_workflow_activity( + ["owner/repo"], + token="token", + hours=2, + now=now, + ) + assert summaries[0]["recent_runs"] == 1 + assert summaries[0]["total_runs"] == 1 + + +def test_print_warnings_includes_optional_resources(capsys) -> None: + limits = [ + analyze_api_rate_limits.TokenRateLimits( + source="GITHUB_TOKEN", + core=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + graphql=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + search=analyze_api_rate_limits.RateLimitInfo( + limit=30, remaining=30, used=0, reset_timestamp=0 + ), + code_search=analyze_api_rate_limits.RateLimitInfo( + limit=10, remaining=1, used=9, reset_timestamp=0 + ), + actions_runner=analyze_api_rate_limits.RateLimitInfo( + limit=10, remaining=1, used=9, reset_timestamp=0 + ), + ) + ] + + warnings = analyze_api_rate_limits.print_warnings(limits) + _ = capsys.readouterr() + + assert any("Code Search" in warning for warning in warnings) + assert any("Actions Runner Registration" in warning for warning in warnings) + + +def test_print_utilization_table_includes_optional_resources(capsys) -> None: + limits = [ + analyze_api_rate_limits.TokenRateLimits( + source="GITHUB_TOKEN", + core=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + graphql=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + search=analyze_api_rate_limits.RateLimitInfo( + limit=30, remaining=30, used=0, reset_timestamp=0 + ), + code_search=analyze_api_rate_limits.RateLimitInfo( + limit=10, remaining=1, used=9, reset_timestamp=0 + ), + actions_runner=analyze_api_rate_limits.RateLimitInfo( + limit=10, remaining=1, used=9, reset_timestamp=0 + ), + ) + ] + + analyze_api_rate_limits.print_utilization_table(limits) + output = capsys.readouterr().out + + assert "OPTIONAL RESOURCE UTILIZATION" in output + assert "Code Search" in output + assert "Actions Runner Registration" in output + + +def test_print_utilization_table_skips_optional_section_without_resources(capsys) -> None: + limits = [ + analyze_api_rate_limits.TokenRateLimits( + source="GITHUB_TOKEN", + core=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + graphql=analyze_api_rate_limits.RateLimitInfo( + limit=5000, remaining=4500, used=500, reset_timestamp=0 + ), + search=analyze_api_rate_limits.RateLimitInfo( + limit=30, remaining=30, used=0, reset_timestamp=0 + ), + ) + ] + + analyze_api_rate_limits.print_utilization_table(limits) + output = capsys.readouterr().out + + assert "OPTIONAL RESOURCE UTILIZATION" not in output diff --git a/tests/scripts/test_issue_formatter.py b/tests/scripts/test_issue_formatter.py index 4b082616e..d95c6dc85 100644 --- a/tests/scripts/test_issue_formatter.py +++ b/tests/scripts/test_issue_formatter.py @@ -249,6 +249,25 @@ def test_format_issue_fallback_parses_aliases_and_preamble() -> None: assert "- [ ] formatted body" in acceptance +def test_format_issue_fallback_accepts_alpha_lists() -> None: + raw = """Tasks: +a) add formatter +B) add tests + +Acceptance Criteria: +a) formatter runs +""" + result = issue_formatter.format_issue_body(raw, use_llm=False) + formatted = result["formatted_body"] + + tasks = _extract_section(formatted, "Tasks") + acceptance = _extract_section(formatted, "Acceptance Criteria") + + assert "- [ ] add formatter" in tasks + assert "- [ ] add tests" in tasks + assert "- [ ] formatter runs" in acceptance + + def test_format_issue_fallback_preserves_code_fences_in_tasks() -> None: raw = """## Tasks - add formatter diff --git a/tests/scripts/test_issue_optimizer.py b/tests/scripts/test_issue_optimizer.py index f51d9ff3d..7b27af2a2 100644 --- a/tests/scripts/test_issue_optimizer.py +++ b/tests/scripts/test_issue_optimizer.py @@ -45,7 +45,7 @@ def test_extract_suggestions_json_from_comment() -> None: payload = issue_optimizer._extract_suggestions_json(comment) assert payload is not None assert payload["blocked_tasks"][0]["task"] == "Update workflow" - assert "Updated WORKFLOW_OUTPUTS.md suggestions-json:" in comment + assert "suggestions-json:" in comment def test_format_suggestions_comment_includes_key_sections() -> None: @@ -80,7 +80,7 @@ def test_format_suggestions_comment_includes_key_sections() -> None: assert "### Task splitting" in comment assert "### Blocked tasks" in comment assert "### Objective acceptance criteria" in comment - assert "