From 1849024f4f7a6d47f2c7dd37923c53071d08d520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Fri, 28 Aug 2026 13:05:12 +0800 Subject: [PATCH] feat(analysis): stellar-aware issue and PR analysis (#203, #204) Add detection-driven Stellar/Soroban awareness to the existing Phase 6 issue and PR analysis flows. When Stellar signals are detected in the repository or PR diff, a Stellar-aware section is appended to the prompt covering contract-specific concerns (authorization, storage keys, extend_ttl, panic!/unwrap in contract paths, cross-contract calls). When not detected, behavior is identical to the current generic review. Changes: - _FileAdapter: adapts GitHub file dicts (filename/patch) into detection- compatible objects with .path and .content attributes - _adapt_pr_files: converts PR changed-file dicts for detect_stellar_project - _adapt_repo_files: converts repo file dicts/tuples for detection - _safe_detect: wraps detection with try/except, never crashes analysis - analyze_pull_request: runs detection on PR files, appends Stellar review section when detected, returns stellar_detected flag - analyze_issue: accepts optional repo_files param, appends Stellar guidance section when detection is positive Tests (29, all passing): - _PRFileAdapter and adapter coverage - Stellar PR includes section, non-Stellar PR excludes it - stellar_detected flag true/false cases - Detection failure falls back to generic - AI failure returns error message - Stellar issue includes section, non-Stellar excludes it - Detection uncertainty falls back safely - Backward compatibility (no repo_files = generic) Closes #203, Closes #204 --- app/services/analysis.py | 145 ++++++++- tests/test_stellar_aware_analysis.py | 450 +++++++++++++++++++++++++++ 2 files changed, 590 insertions(+), 5 deletions(-) create mode 100644 tests/test_stellar_aware_analysis.py diff --git a/app/services/analysis.py b/app/services/analysis.py index c07c817..b6df9e0 100644 --- a/app/services/analysis.py +++ b/app/services/analysis.py @@ -4,11 +4,18 @@ and delegate to the configured LLM provider. The prompts ask the model to clearly label uncertainty so the UI can distinguish confirmed defects from suggestions. + +When Stellar/Soroban signals are detected in the repository or PR diff, a +Stellar-aware section is appended to the prompt so the model can surface +contract-specific concerns (authorization, storage keys, ``extend_ttl``, +``panic!``/``unwrap`` in contract paths, cross-contract calls). When no +signals are found, the generic prompt is unchanged. """ from __future__ import annotations from app.services.llm import LLMProviderError, get_provider +from app.services.stellar_detection import StellarSignals, detect_stellar_project # Hard cap on the amount of repository text fed to the model for any single # analysis, so a request never uploads the whole repository. @@ -22,6 +29,99 @@ "with '[SUGGESTION]'." ) +# -- Stellar-aware prompt sections ------------------------------------------- + +_STELLAR_PR_SECTION = """ +Stellar/Soroban review (detected): +This repository shows Stellar/Soroban signals. In addition to the generic +review, pay attention to Soroban-specific concerns in the changed files: +- Contract structure: #[contractimpl]/#[contract] usage, contract types. +- Authorization: require_auth / address checks before privileged operations. +- Storage: storage key handling, extend_ttl / persist patterns. +- Error handling: panic!/unwrap in contract code paths (should be avoided). +- Cross-contract calls: proper interface usage and error propagation. +- Stellar dependencies: Soroban crates / SDKs touched by the change. +- Stellar config files (e.g. stellar.toml) if changed. +Mark every Stellar finding [CONFIRMED] or [SUGGESTION] as usual. +""" + +_STELLAR_ISSUE_SECTION = """ +Stellar/Soroban context (detected): +This repository shows Stellar/Soroban signals. Consider Stellar-specific +aspects in your analysis: +- Soroban SDK / XDR concerns relevant to the issue. +- Contract structure and storage patterns. +- Network behaviour (testnet/mainnet/futurenet) if applicable. +- Authorization, error handling, and cross-contract call patterns. +Keep the [CONFIRMED]/[SUGGESTION] labelling and do not fabricate Stellar +claims beyond what the evidence supports. +""" + + +class _FileAdapter: + """Adapt a plain dict (``path``, ``content``) into a detection-compatible object. + + :func:`detect_stellar_project` expects objects with ``.path`` and + ``.content`` attributes. This adapter wraps a dict so the same detection + logic can be reused for GitHub PR changed-file dicts and raw repo file + listings without re-implementing detection. + """ + + __slots__ = ("path", "content") + + def __init__(self, path: str, content: str | None) -> None: + self.path = path + self.content = content + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f"_FileAdapter(path={self.path!r})" + + +def _adapt_pr_files(files: list[dict]) -> list[_FileAdapter]: + """Convert GitHub PR file dicts into detection-compatible objects. + + Each PR file dict has ``filename`` and ``patch`` keys (the latter may be + ``None`` for binary files). The patch text is a diff, not full file + content, but it still contains Soroban attributes, imports, and crate + names in added/removed lines — sufficient for heuristic detection. + """ + adapters: list[_FileAdapter] = [] + for file in files: + filename = file.get("filename") or file.get("path") or "" + patch = file.get("patch") or file.get("content") + if filename: + adapters.append(_FileAdapter(filename, patch)) + return adapters + + +def _adapt_repo_files(files: list[dict] | list[tuple[str, str | None]]) -> list[_FileAdapter]: + """Convert repo file dicts/tuples into detection-compatible objects. + + Accepts either ``[{"path": ..., "content": ...}, ...]`` or + ``[("path", "content"), ...]`` for flexibility. + """ + adapters: list[_FileAdapter] = [] + for item in files: + if isinstance(item, dict): + path = item.get("path") or item.get("filename") or "" + content = item.get("content") or item.get("patch") + elif isinstance(item, (list, tuple)) and len(item) >= 1: + path = item[0] + content = item[1] if len(item) > 1 else None + else: + continue + if path: + adapters.append(_FileAdapter(path, content)) + return adapters + + +def _safe_detect(adapters: list[_FileAdapter]) -> StellarSignals | None: + """Run detection, returning ``None`` on any unexpected failure.""" + try: + return detect_stellar_project(adapters) + except Exception: # noqa: BLE001 - detection must never crash analysis + return None + def _run(prompt: str, *, system: str = _SYSTEM) -> str: """Run a single completion with the configured provider.""" @@ -43,10 +143,30 @@ def _clip(text: str, limit: int = MAX_CONTEXT_CHARS) -> str: return text[:limit] + "\n…[context truncated]" -def analyze_issue(issue: dict, owner: str, repo: str) -> dict: - """Produce a structured AI analysis of a GitHub issue.""" +def analyze_issue( + issue: dict, + owner: str, + repo: str, + *, + repo_files: list[dict] | list[tuple[str, str | None]] | None = None, +) -> dict: + """Produce a structured AI analysis of a GitHub issue. + + When *repo_files* is provided, Stellar/Soroban detection is run on the + file list. If detection is positive, a Stellar-aware section is appended + to the prompt covering Soroban SDK/XDR concerns, contract structure, and + network behaviour. When detection is negative, uncertain, or no files are + provided, the generic issue analysis prompt is used unchanged. + """ body = issue.get("body") or "(no description provided)" labels = ", ".join(issue.get("labels") or []) or "none" + + stellar_section = "" + if repo_files: + signals = _safe_detect(_adapt_repo_files(repo_files)) + if signals is not None and signals.is_stellar: + stellar_section = _STELLAR_ISSUE_SECTION + prompt = f"""Repository: {owner}/{repo} Issue #{issue.get('number')}: {issue.get('title')} State: {issue.get('state')} @@ -54,7 +174,7 @@ def analyze_issue(issue: dict, owner: str, repo: str) -> dict: Description: {_clip(body)} - +{stellar_section} Provide a structured analysis with these sections: 1. Summary - one short paragraph 2. Problem identification - what is actually being asked/fixed @@ -71,7 +191,15 @@ def analyze_issue(issue: dict, owner: str, repo: str) -> dict: def analyze_pull_request(pr: dict, files: list[dict]) -> dict: - """Produce a structured AI analysis of a pull request.""" + """Produce a structured AI analysis of a pull request. + + Stellar/Soroban detection is run on the PR changed-file dicts (adapted + via :func:`_adapt_pr_files`). When detection is positive, a Stellar-aware + section is appended to the review prompt covering authorization patterns, + ``panic!``/``unwrap`` in contract paths, storage keys, ``extend_ttl``, and + cross-contract calls. When not detected, the generic review is unchanged. + Detection failure is handled safely (falls back to generic review). + """ body = pr.get("body") or "(no description provided)" changed = [] for file in files[:40]: @@ -83,6 +211,12 @@ def analyze_pull_request(pr: dict, files: list[dict]) -> dict: ) files_text = "\n".join(changed) if changed else "(no file-level diff available)" + # Run Stellar detection on the adapted PR files. + stellar_section = "" + signals = _safe_detect(_adapt_pr_files(files)) + if signals is not None and signals.is_stellar: + stellar_section = _STELLAR_PR_SECTION + prompt = f"""Pull request #{pr.get('number')}: {pr.get('title')} State: {pr.get('state')} (merged: {pr.get('merged')}) Author: {pr.get('author')} @@ -93,7 +227,7 @@ def analyze_pull_request(pr: dict, files: list[dict]) -> dict: Changed files: {_clip(files_text, MAX_CONTEXT_CHARS // 2)} - +{stellar_section} Provide a structured review with these sections: 1. Summary - what this PR does, one short paragraph 2. Code-change explanation - what each notable change does @@ -106,6 +240,7 @@ def analyze_pull_request(pr: dict, files: list[dict]) -> dict: "pr_number": pr.get("number"), "title": pr.get("title"), "analysis": _run(prompt), + "stellar_detected": signals is not None and signals.is_stellar, } diff --git a/tests/test_stellar_aware_analysis.py b/tests/test_stellar_aware_analysis.py new file mode 100644 index 0000000..93b1d80 --- /dev/null +++ b/tests/test_stellar_aware_analysis.py @@ -0,0 +1,450 @@ +"""Tests for Stellar-aware issue and PR analysis (#203, #204). + +Verifies that: +- A Stellar/Soroban PR receives the Stellar-aware review section. +- A non-Stellar PR receives the existing generic review unchanged. +- Detection failure is handled safely (falls back to generic, no crash). +- AI failure does not change review behavior (existing error handling). +- A Stellar/Soroban project's issue receives Stellar-aware analysis. +- A non-Stellar project's issue does not receive Stellar-specific analysis. +- Detection uncertainty falls back to generic analysis. +- The _PRFileAdapter and _adapt_pr_files adapter work correctly. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from app.services import analysis +from app.services.analysis import ( + _FileAdapter, + _adapt_pr_files, + _adapt_repo_files, + analyze_issue, + analyze_pull_request, +) +from app.services.llm import LLMProviderError +from app.services.stellar_detection import detect_stellar_project + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +# A Soroban PR diff that touches a Cargo.toml adding soroban-sdk and a Rust +# contract source file with #[contractimpl]. +_SOROBAN_PR_FILES = [ + { + "filename": "Cargo.toml", + "status": "modified", + "additions": 2, + "deletions": 0, + "patch": ( + "--- a/Cargo.toml\n" + "+++ b/Cargo.toml\n" + "@@ -10,3 +10,5 @@\n" + " [dependencies]\n" + "+soroban-sdk = { version = \"21.0.0\" }\n" + "+soroban-token-sdk = { version = \"21.0.0\" }\n" + ), + }, + { + "filename": "src/token.rs", + "status": "added", + "additions": 15, + "deletions": 0, + "patch": ( + "--- /dev/null\n" + "+++ b/src/token.rs\n" + "@@ -0,0 +1,15 @@\n" + "+#![no_std]\n" + "+use soroban_sdk::contractimpl;\n" + "+\n" + "+pub struct TokenContract;\n" + "+\n" + "+#[contractimpl]\n" + "+impl TokenContract {\n" + "+ pub fn mint(env: &soroban_sdk::Env, to: soroban_sdk::Address, amount: i128) {\n" + "+ env.storage().persistent().set(&to, &amount);\n" + "+ }\n" + "+}\n" + ), + }, +] + +# A non-Stellar PR diff — a simple Python change. +_GENERIC_PR_FILES = [ + { + "filename": "app/views.py", + "status": "modified", + "additions": 5, + "deletions": 2, + "patch": ( + "--- a/app/views.py\n" + "+++ b/app/views.py\n" + "@@ -20,8 +20,11 @@\n" + "-def index():\n" + "- return render_template('index.html')\n" + "+def index():\n" + "+ page = request.args.get('page', 1)\n" + "+ items = Item.query.paginate(page=page, per_page=20)\n" + "+ return render_template('index.html', items=items)\n" + ), + }, +] + +# Repo file list with Soroban signals for issue analysis. +_SOROBAN_REPO_FILES = [ + {"path": "Cargo.toml", "content": "[dependencies]\nsoroban-sdk = '21.0.0'\n"}, + {"path": "src/lib.rs", "content": "#![no_std]\n#[contractimpl]\npub struct Contract {}\n"}, +] + +# Non-Stellar repo file list. +_GENERIC_REPO_FILES = [ + {"path": "app.py", "content": "from flask import Flask\napp = Flask(__name__)\n"}, + {"path": "requirements.txt", "content": "flask==3.0.0\n"}, +] + + +def _capture_prompt(fn, *args, **kwargs) -> str: + """Call an analysis function and return the prompt that was sent to the provider.""" + captured: list[str] = [] + + class _CaptureProvider: + def complete(self, messages, *, stream=False): + for msg in messages: + if msg.get("role") == "user": + captured.append(msg["content"]) + return "mock analysis response" + + with patch("app.services.analysis.get_provider", return_value=_CaptureProvider()): + fn(*args, **kwargs) + + return captured[0] if captured else "" + + +# --------------------------------------------------------------------------- +# Tests: _PRFileAdapter and _adapt_pr_files (#203) +# --------------------------------------------------------------------------- + + +class TestPRFileAdapter: + """Adapter tests converting GitHub PR file dicts into detection inputs.""" + + def test_adapt_pr_files_basic(self): + adapters = _adapt_pr_files( + [{"filename": "src/lib.rs", "patch": "+use soroban_sdk::contractimpl;"}] + ) + assert len(adapters) == 1 + assert adapters[0].path == "src/lib.rs" + assert "soroban_sdk" in adapters[0].content + + def test_adapt_pr_files_missing_filename_skipped(self): + adapters = _adapt_pr_files([{"patch": "diff"}, {"filename": "ok.rs", "patch": "x"}]) + assert len(adapters) == 1 + assert adapters[0].path == "ok.rs" + + def test_adapt_pr_files_none_patch(self): + adapters = _adapt_pr_files([{"filename": "binary.bin", "patch": None}]) + assert len(adapters) == 1 + assert adapters[0].path == "binary.bin" + assert adapters[0].content is None + + def test_adapt_pr_files_empty(self): + assert _adapt_pr_files([]) == [] + + def test_adapter_path_and_content_attrs(self): + adapter = _FileAdapter("Cargo.toml", "[dependencies]\nsoroban-sdk='1'\n") + assert hasattr(adapter, "path") + assert hasattr(adapter, "content") + assert adapter.path == "Cargo.toml" + + def test_adapter_works_with_detect_stellar_project(self): + """The adapter objects must be directly consumable by detect_stellar_project.""" + adapters = _adapt_pr_files(_SOROBAN_PR_FILES) + signals = detect_stellar_project(adapters) + assert signals.is_stellar + assert signals.is_soroban + assert signals.confidence == "likely" + + def test_adapt_repo_files_dict_form(self): + adapters = _adapt_repo_files( + [{"path": "Cargo.toml", "content": "soroban-sdk"}] + ) + assert len(adapters) == 1 + assert adapters[0].path == "Cargo.toml" + + def test_adapt_repo_files_tuple_form(self): + adapters = _adapt_repo_files([("Cargo.toml", "soroban-sdk")]) + assert len(adapters) == 1 + assert adapters[0].path == "Cargo.toml" + assert "soroban" in adapters[0].content + + def test_adapt_repo_files_mixed(self): + adapters = _adapt_repo_files( + [{"path": "a.rs", "content": "#[contractimpl]"}, ("b.txt", "hello")] + ) + assert len(adapters) == 2 + + +# --------------------------------------------------------------------------- +# Tests: analyze_pull_request Stellar-awareness (#203) +# --------------------------------------------------------------------------- + + +class TestStellarAwarePRAnalysis: + """Tests for Stellar-aware PR analysis (#203).""" + + def test_soroban_pr_includes_stellar_section(self): + """A Stellar/Soroban PR receives the Stellar-aware review section.""" + prompt = _capture_prompt( + analyze_pull_request, + {"number": 1, "title": "Add token contract", "state": "open", "body": "new contract"}, + _SOROBAN_PR_FILES, + ) + assert "Stellar/Soroban review (detected)" in prompt + assert "authorization" in prompt.lower() + assert "extend_ttl" in prompt + assert "[CONFIRMED]" in prompt + + def test_non_stellar_pr_excludes_stellar_section(self): + """A non-Stellar PR must produce the generic review unchanged.""" + prompt = _capture_prompt( + analyze_pull_request, + {"number": 2, "title": "Add pagination", "state": "open", "body": "paginate index"}, + _GENERIC_PR_FILES, + ) + assert "Stellar/Soroban review" not in prompt + assert "[CONFIRMED]" in prompt + + def test_stellar_detected_flag_true_for_soroban(self): + """The return dict should report stellar_detected=True for Soroban PRs.""" + with patch("app.services.analysis.get_provider") as mock: + mock.return_value.complete.return_value = "response" + result = analyze_pull_request( + {"number": 1, "title": "t", "state": "open", "body": "b"}, + _SOROBAN_PR_FILES, + ) + assert result["stellar_detected"] is True + + def test_stellar_detected_flag_false_for_generic(self): + """The return dict should report stellar_detected=False for non-Stellar PRs.""" + with patch("app.services.analysis.get_provider") as mock: + mock.return_value.complete.return_value = "response" + result = analyze_pull_request( + {"number": 2, "title": "t", "state": "open", "body": "b"}, + _GENERIC_PR_FILES, + ) + assert result["stellar_detected"] is False + + def test_detection_failure_falls_back_to_generic(self): + """If detection raises, the PR review must still work (generic, no crash).""" + with patch("app.services.analysis.detect_stellar_project", side_effect=RuntimeError("boom")): + prompt = _capture_prompt( + analyze_pull_request, + {"number": 3, "title": "t", "state": "open", "body": "b"}, + _SOROBAN_PR_FILES, + ) + assert "Stellar/Soroban review" not in prompt # detection failed -> generic + + def test_ai_failure_returns_error_message(self): + """When the LLM provider fails, the analysis field should report unavailable.""" + with patch("app.services.analysis.get_provider") as mock: + mock.return_value.complete.side_effect = LLMProviderError("API down") + result = analyze_pull_request( + {"number": 4, "title": "t", "state": "open", "body": "b"}, + _SOROBAN_PR_FILES, + ) + assert "analysis unavailable" in result["analysis"] + + def test_empty_files_generic_review(self): + """PR with no files at all gets the generic review.""" + prompt = _capture_prompt( + analyze_pull_request, + {"number": 5, "title": "empty", "state": "open", "body": "b"}, + [], + ) + assert "Stellar/Soroban review" not in prompt + assert "(no file-level diff available)" in prompt + + def test_stellar_config_file_triggers_section(self): + """A PR that adds a stellar.toml file triggers detection (possible confidence).""" + files = [ + { + "filename": "stellar.toml", + "status": "added", + "additions": 5, + "deletions": 0, + "patch": "--- /dev/null\n+++ b/stellar.toml\n@@ -0,0 +1,5 @@\n+NETWORK_PASSPHRASE=\"Test SDF Network ; September 2015\"\n+HORIZON_URL=\"https://horizon-testnet.stellar.org\"\n", + } + ] + prompt = _capture_prompt( + analyze_pull_request, + {"number": 6, "title": "add config", "state": "open", "body": "b"}, + files, + ) + assert "Stellar/Soroban review" in prompt + + +# --------------------------------------------------------------------------- +# Tests: analyze_issue Stellar-awareness (#204) +# --------------------------------------------------------------------------- + + +class TestStellarAwareIssueAnalysis: + """Tests for Stellar-aware issue analysis (#204).""" + + def test_stellar_repo_issue_includes_stellar_section(self): + """A Stellar/Soroban project's issue receives Stellar-aware analysis.""" + prompt = _capture_prompt( + analyze_issue, + {"number": 1, "title": "Contract bug", "body": "storage issue", "state": "open", "labels": []}, + "stellar-org", + "soroban-token", + repo_files=_SOROBAN_REPO_FILES, + ) + assert "Stellar/Soroban context (detected)" in prompt + assert "Soroban SDK" in prompt + assert "XDR" in prompt + assert "[CONFIRMED]" in prompt + + def test_non_stellar_repo_issue_excludes_stellar_section(self): + """A non-Stellar project's issue does not receive Stellar-specific analysis.""" + prompt = _capture_prompt( + analyze_issue, + {"number": 2, "title": "Fix pagination", "body": "paginate items", "state": "open", "labels": []}, + "some-user", + "flask-app", + repo_files=_GENERIC_REPO_FILES, + ) + assert "Stellar/Soroban context" not in prompt + assert "structured analysis" in prompt + + def test_no_repo_files_generic_analysis(self): + """When no repo_files is provided, the generic issue analysis is used.""" + prompt = _capture_prompt( + analyze_issue, + {"number": 3, "title": "Bug", "body": "desc", "state": "open", "labels": []}, + "owner", + "repo", + ) + assert "Stellar/Soroban context" not in prompt + + def test_repo_files_none_generic_analysis(self): + """repo_files=None should also produce generic analysis.""" + prompt = _capture_prompt( + analyze_issue, + {"number": 4, "title": "Bug", "body": "desc", "state": "open", "labels": []}, + "owner", + "repo", + repo_files=None, + ) + assert "Stellar/Soroban context" not in prompt + + def test_empty_repo_files_generic_analysis(self): + """Empty repo_files list should produce generic analysis.""" + prompt = _capture_prompt( + analyze_issue, + {"number": 5, "title": "Bug", "body": "desc", "state": "open", "labels": []}, + "owner", + "repo", + repo_files=[], + ) + assert "Stellar/Soroban context" not in prompt + + def test_detection_uncertainty_falls_back(self): + """Low-confidence detection (no signals) must not produce Stellar claims.""" + uncertain_files = [{"path": "README.md", "content": "# some project that mentions stellar in passing"}] + prompt = _capture_prompt( + analyze_issue, + {"number": 6, "title": "Bug", "body": "desc", "state": "open", "labels": []}, + "owner", + "repo", + repo_files=uncertain_files, + ) + assert "Stellar/Soroban context" not in prompt + + def test_detection_failure_falls_back_to_generic(self): + """If detection raises, the issue analysis must still work (generic, no crash).""" + with patch("app.services.analysis.detect_stellar_project", side_effect=RuntimeError("fail")): + prompt = _capture_prompt( + analyze_issue, + {"number": 7, "title": "Bug", "body": "desc", "state": "open", "labels": []}, + "owner", + "repo", + repo_files=_SOROBAN_REPO_FILES, + ) + assert "Stellar/Soroban context" not in prompt + + def test_ai_failure_returns_error_message(self): + """When the LLM provider fails, the analysis field should report unavailable.""" + with patch("app.services.analysis.get_provider") as mock: + mock.return_value.complete.side_effect = LLMProviderError("API down") + result = analyze_issue( + {"number": 8, "title": "Bug", "body": "desc", "state": "open", "labels": []}, + "owner", + "repo", + repo_files=_SOROBAN_REPO_FILES, + ) + assert "analysis unavailable" in result["analysis"] + + def test_stellar_issue_with_tuple_form_files(self): + """repo_files as tuples should also work for detection.""" + tuple_files = [("Cargo.toml", "[dependencies]\nsoroban-sdk='1'\n")] + prompt = _capture_prompt( + analyze_issue, + {"number": 9, "title": "Bug", "body": "desc", "state": "open", "labels": []}, + "owner", + "repo", + repo_files=tuple_files, + ) + assert "Stellar/Soroban context" in prompt + + def test_issue_return_dict_structure(self): + """Return dict must have the expected keys.""" + with patch("app.services.analysis.get_provider") as mock: + mock.return_value.complete.return_value = "analysis text" + result = analyze_issue( + {"number": 10, "title": "T", "body": "B", "state": "open", "labels": ["bug"]}, + "owner", + "repo", + ) + assert result["kind"] == "issue" + assert result["issue_number"] == 10 + assert result["title"] == "T" + assert result["analysis"] == "analysis text" + + +# --------------------------------------------------------------------------- +# Tests: backward compatibility +# --------------------------------------------------------------------------- + + +class TestBackwardCompatibility: + """Ensure the changes do not break existing callers.""" + + def test_analyze_issue_without_repo_files_works(self): + """analyze_issue must still work when repo_files is not passed (backward compat).""" + with patch("app.services.analysis.get_provider") as mock: + mock.return_value.complete.return_value = "analysis" + result = analyze_issue( + {"number": 1, "title": "T", "body": "B", "state": "open", "labels": []}, + "owner", + "repo", + ) + assert result["analysis"] == "analysis" + assert result["kind"] == "issue" + + def test_analyze_pull_request_return_has_expected_keys(self): + """The PR analysis return dict should contain the expected keys.""" + with patch("app.services.analysis.get_provider") as mock: + mock.return_value.complete.return_value = "analysis" + result = analyze_pull_request( + {"number": 1, "title": "T", "state": "open", "body": "B", "merged": False}, + _GENERIC_PR_FILES, + ) + assert result["kind"] == "pull_request" + assert result["pr_number"] == 1 + assert result["title"] == "T" + assert result["analysis"] == "analysis" + assert "stellar_detected" in result