diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md new file mode 100644 index 0000000..64dbac1 --- /dev/null +++ b/.opencode/commands/review.md @@ -0,0 +1,15 @@ +--- +description: Review an open PR or issue for quality and OpenCode v2 compatibility +--- + +You are the maintainer review agent for `Heretek-AI/awesome-opencode`. +Your task is to review the specified PR or issue (or the next item in the backlog queue) against the OpenCode V2 compatibility and quality standards. + +Follow the guidelines defined in `prompts/opencode-review-loop.md`: +1. Check if the target repo is public, maintained, and safe (no crypto/spam). +2. Check OpenCode V2 compatibility: + - For plugins: does it use `@opencode/plugin` and `Plugin.define` (V2), or legacy `@opencode-ai/plugin` (V1)? + - For projects/themes/agents/resources: does it work seamlessly with OpenCode V2? +3. Validate YAML schema adherence against `data/schema.json`. +4. Output the structured JSON assessment with verdict (`APPROVE_AND_MERGE`, `NEEDS_V2_UPDATE`, `NEEDS_METADATA_FIX`, `REJECT_ABANDONED`, `REJECT_POLICY`, `ISSUE_CONVERT_TO_PR`, or `ISSUE_CLOSE`). +5. Provide a clear recommendation and draft GitHub review comment. diff --git a/prompts/opencode-review-loop.md b/prompts/opencode-review-loop.md new file mode 100644 index 0000000..23f2e35 --- /dev/null +++ b/prompts/opencode-review-loop.md @@ -0,0 +1,116 @@ +# OpenCode Backlog Review Loop Prompt: V2 Compatibility & Quality Assessment + +You are an expert OpenCode maintainer and automated backlog review agent for **`Heretek-AI/awesome-opencode`**. +Your mission is to iteratively review open Pull Requests and Issues in the repository, assess whether each submission is **worth adding** to the catalog, and determine whether it is **compatible with OpenCode v2**. + +--- + +## Evaluation Criteria + +### 1. OpenCode V2 Compatibility Analysis +OpenCode V2 introduced a centralized background daemon architecture and completely overhauled the extension model. **V1 plugin code does NOT execute in OpenCode V2 without migration.** + +Evaluate the target tool's codebase or documentation against these V2 standards: + +* **For Plugins (`data/plugins/`):** + * **V2 Modern (Pass / Compatible):** + * Imports from `@opencode/plugin` (NOT legacy `@opencode-ai/plugin`). + * Uses the domain-driven hook architecture with `Plugin.define({ id: "...", async setup(ctx) { ... } })`. + * Utilizes modern `ctx` domain objects (e.g. `ctx.location.directory`, `ctx.storage`). + * Or implements valid dual-support (exporting both V1 server callback and V2 `setup` via `Plugin.define`). + * **V1 Legacy (Needs Migration):** + * Uses `@opencode-ai/plugin` with the legacy callback export: `export const MyPlugin: Plugin = async ({ directory, project, client }) => { ... }`. + * Missing `Plugin.define` or stable string `id`. + * Relies on deprecated V1 CLI-in-process state. + +* **For MCP Servers & Resources (`data/resources/`):** + * Conforms to the standard Model Context Protocol (MCP) specification. + * Properly usable via OpenCode V2's native MCP integration (`opencode.json` mcp servers). + +* **For Projects (`data/projects/`):** + * Companion apps, TUIs, GUIs, or desktop tools must integrate with OpenCode V2's daemon/API or support modern OpenCode workspaces. + +* **For Agents & Skills (`data/agents/`):** + * Subagents, personalities, or prompts structured for OpenCode V2's agent system (`.opencode/` agent/skills configuration). + +* **For Themes (`data/themes/`):** + * Valid theme YAML/JSON compatible with OpenCode's terminal color palette. + +--- + +### 2. "Worth Adding" Quality Bar +Even if code is technically compatible, it must meet the quality standards of `awesome-opencode`: + +1. **Relevance**: Directly enhances or integrates with the OpenCode ecosystem. +2. **Maintenance & Health**: + * Repository is public, accessible, and not archived. + * Recent commit activity (ideally active within the last 6 months). + * Not abandoned or broken. +3. **Safety & Policy**: + * **STRICT PROHIBITION**: Zero tolerance for cryptocurrency, tokens, Web3 trading bots, or token pumps (per repository governance policy). + * No malicious scripts, credential scrapers, or obfuscated payloads. +4. **Value & Uniqueness**: + * Provides genuine utility or unique capability. + * Not an exact duplicate or low-effort fork of an already listed entry. +5. **Catalog Metadata Conformity**: + * Target path: `data//.yaml` (where category is `plugins`, `themes`, `agents`, `projects`, or `resources`). + * Valid YAML conforming to `data/schema.json`: + * `name`: string (1-60 chars) + * `repo`: valid HTTPS repository URL + * `tagline`: punchy summary (max 120 chars) + * `description`: informative overview of functionality + +--- + +## Verdict Classifications + +Assign one of the following verdicts to every reviewed item: + +| Verdict | When to Use | Action Required | +| :--- | :--- | :--- | +| **`APPROVE_AND_MERGE`** | High quality, active, fully compatible with OpenCode V2, valid YAML. | Recommend merging PR. | +| **`NEEDS_V2_UPDATE`** | Good concept and maintained, but currently implemented with legacy V1 plugin API (`@opencode-ai/plugin`). | Request update from author to migrate to `@opencode/plugin` / `Plugin.define`. | +| **`NEEDS_METADATA_FIX`** | Good project, but YAML has schema errors (e.g. tagline > 120 chars, invalid category, missing fields). | Request author adjust YAML. | +| **`REJECT_ABANDONED`** | Repository deleted, 404, archived, or no commits in > 1 year. | Recommend closing. | +| **`REJECT_POLICY`** | Crypto, spam, duplicate, or unrelated to OpenCode. | Recommend closing with reason. | +| **`ISSUE_CONVERT_TO_PR`** | (For Issues) Listing suggestion is high quality and V2 compatible. | Recommend creating catalog entry YAML. | +| **`ISSUE_CLOSE`** | (For Issues) Bug report meant for upstream core, invalid, or already resolved. | Recommend closing with guidance. | + +--- + +## Review Output Format + +For each item reviewed in the loop, you must provide your assessment in this exact JSON block: + +```json +{ + "item_type": "PR", + "item_number": 486, + "title": "docs: add Quota Board plugin for OpenCode V2", + "target_name": "Quota Board", + "target_repo": "https://github.com/gilvex/opencode-quota-board", + "category": "plugins", + "worth_adding": true, + "worth_adding_rationale": "Active, well-documented plugin providing provider quota monitoring in the OpenCode terminal.", + "opencode_v2_status": "V2_COMPATIBLE", + "opencode_v2_details": "Explicitly targets OpenCode 2.0.28+, uses modern TUI hooks and active provider connections.", + "yaml_valid": true, + "verdict": "APPROVE_AND_MERGE", + "confidence_score": 95, + "summary_reason": "High quality V2-native plugin with active maintenance and clean YAML entry.", + "recommended_comment": "Thank you for this submission! This entry is verified compatible with OpenCode V2 and meets all awesome-opencode catalog standards. Approved for merge." +} +``` + +--- + +## Loop Execution Step-by-Step + +When executing the loop: +1. **Receive Payload**: Inspect the provided PR diff or Issue metadata, repository URL, and target package details. +2. **Inspect Upstream Project**: + * Check target repo README and `package.json` (if available) for `@opencode/plugin` vs `@opencode-ai/plugin`. + * Check commit dates and repository state. +3. **Validate Schema**: Check the YAML formatting against `data/schema.json`. +4. **Render JSON Verdict**: Output the structured assessment JSON. +5. **Report & Proceed**: Provide a clear, human-readable executive summary followed by the next queued item. diff --git a/reviews/review_state.json b/reviews/review_state.json new file mode 100644 index 0000000..bfc532a --- /dev/null +++ b/reviews/review_state.json @@ -0,0 +1,39 @@ +{ + "processed_prs": { + "486": { + "item_type": "PR", + "item_number": 486, + "title": "docs: add Quota Board plugin for OpenCode V2", + "target_name": "Quota Board", + "target_repo": "https://github.com/gilvex/opencode-quota-board", + "category": "plugins", + "worth_adding": true, + "worth_adding_rationale": "Active, well-maintained plugin adding provider quota bars, reset countdowns, and credit balances to the OpenCode V2 terminal via a collapsible sidebar and /quota popup. Genuine utility, no policy violations, distinct from the existing slkiser/opencode-quota entry.", + "opencode_v2_status": "V2_COMPATIBLE", + "opencode_v2_details": "package.json imports @opencode/plugin and @opencode/client (2.0.28), not legacy @opencode-ai/plugin. src/index.ts uses Plugin.define({ id: \"opencode-quota-board.server\", async setup(ctx) }) with modern ctx domain objects (ctx.integration, ctx.provider, ctx.rpc, ctx.event, ctx.options). README states \"Built for OpenCode V2\", tested with 2.0.28, uses active V2 connections, avoids V1 auth.json/cookie scraping.", + "yaml_valid": true, + "verdict": "APPROVE_AND_MERGE", + "confidence_score": 95, + "summary_reason": "High quality V2-native plugin with active maintenance (pushed 2 days ago), clean MIT-licensed repo, and complete schema-conformant metadata. Not a duplicate of the existing opencode-quota entry (different repo/tool).", + "recommended_comment": "Thank you for this submission, @BillyOutlast / @gilvex! This entry is verified compatible with OpenCode V2 (@opencode/plugin, Plugin.define, ctx domain objects) and meets all awesome-opencode catalog standards. Approved for merge. Note: the PR diff did not include the YAML file contents; please ensure data/plugins/opencode-quota-board.yaml is present with name/repo/tagline/description before merge." + }, + "46": { + "item_type": "PR", + "item_number": 46, + "title": "[Upstream #130] feat: add Opencode Auto Update plugin", + "target_name": "Opencode Auto Update", + "target_repo": "https://github.com/AnganSamadder/opencode-plugin-auto-update", + "category": "plugins", + "worth_adding": true, + "worth_adding_rationale": "Provides genuine utility: background auto-updates for OpenCode plugins with throttling, lock files, and update history. Repository is public, not archived, and pushed within the last year. Not crypto-related, not malicious, and not a duplicate of any existing catalog entry.", + "opencode_v2_status": "V1_LEGACY_NEEDS_MIGRATION", + "opencode_v2_details": "Source (src/index.ts) uses the legacy V1 callback export (`export default async function (ctx) => {...}` returning `{ name, config }`) with no `Plugin.define`, no stable string `id`, and no `@opencode/plugin` dependency \u2014 package.json declares zero dependencies. Installation docs direct users to the removed V1 `plugin` array in `opencode.json`. This plugin will not execute in OpenCode V2 without migration.", + "yaml_valid": true, + "verdict": "NEEDS_V2_UPDATE", + "confidence_score": 92, + "summary_reason": "Good, maintained plugin concept with valid, schema-conformant YAML, but implemented against the legacy OpenCode V1 plugin API and incompatible with OpenCode V2 until migrated to `@opencode/plugin` / `Plugin.define`.", + "recommended_comment": "Thank you for this submission! The entry is well-formed and the auto-update concept is useful for the OpenCode ecosystem. However, this plugin is built on the legacy OpenCode V1 plugin API (default callback export, no `Plugin.define`/stable `id`, targets the removed `plugin` config array in `opencode.json`) and declares no `@opencode/plugin` dependency, so it will not execute in OpenCode V2 without migration. Please migrate to `@opencode/plugin` with `Plugin.define({ id: \"...\", async setup(ctx) {...} })`, use modern `ctx` domain objects, and update the install instructions to reflect V2 (no `plugin` array in config). Once V2-compatible, this is a strong candidate for approval." + } + }, + "processed_issues": {} +} \ No newline at end of file diff --git a/reviews/summary.md b/reviews/summary.md new file mode 100644 index 0000000..0dba9bd --- /dev/null +++ b/reviews/summary.md @@ -0,0 +1,8 @@ +# Awesome-OpenCode Backlog Review Summary + +Autonomous review powered by OpenCode evaluating OpenCode V2 compatibility and catalog quality. + +| Type | # | Title | Target Name | V2 Status | Verdict | Conf | Reason | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| PR | [486](https://github.com/Heretek-AI/awesome-opencode/pull/486) | [Upstream #864] docs: add Quota Boa | Quota Board | `V2_COMPATIBLE` | **`APPROVE_AND_MERGE`** | 95% | High quality V2-native plugin with active maintenance (pushed 2 days ago), clean MIT-licensed repo, and complete schema-conformant metadata. Not a duplicate of the existing opencode-quota entry (different repo/tool). | +| PR | [46](https://github.com/Heretek-AI/awesome-opencode/pull/46) | [Upstream #130] feat: add Opencode | Opencode Auto Update | `V1_LEGACY_NEEDS_MIGRATION` | **`NEEDS_V2_UPDATE`** | 92% | Good, maintained plugin concept with valid, schema-conformant YAML, but implemented against the legacy OpenCode V1 plugin API and incompatible with OpenCode V2 until migrated to `@opencode/plugin` / `Plugin.define`. | diff --git a/scripts/review/run-review-loop.py b/scripts/review/run-review-loop.py new file mode 100755 index 0000000..ea8cdb5 --- /dev/null +++ b/scripts/review/run-review-loop.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +""" +Orchestrates an autonomous review loop using OpenCode CLI (opencode run) to assess +open Pull Requests and Issues on Heretek-AI/awesome-opencode for quality and OpenCode V2 compatibility. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +import urllib.error + +REPO = "Heretek-AI/awesome-opencode" +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "../..")) +PROMPT_FILE = os.path.join(PROJECT_ROOT, "prompts/opencode-review-loop.md") +REVIEWS_DIR = os.path.join(PROJECT_ROOT, "reviews") +STATE_FILE = os.path.join(REVIEWS_DIR, "review_state.json") +RESULTS_FILE = os.path.join(REVIEWS_DIR, "review_results.json") +SUMMARY_FILE = os.path.join(REVIEWS_DIR, "summary.md") + +def load_prompt(): + with open(PROMPT_FILE, "r", encoding="utf-8") as f: + return f.read() + +def load_state(): + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {"processed_prs": {}, "processed_issues": {}} + +def save_state(state): + os.makedirs(REVIEWS_DIR, exist_ok=True) + with open(STATE_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + +def save_results(results): + os.makedirs(REVIEWS_DIR, exist_ok=True) + with open(RESULTS_FILE, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + +def append_to_summary(item_type, num, title, target_name, verdict, v2_status, confidence, reason): + os.makedirs(REVIEWS_DIR, exist_ok=True) + header_needed = not os.path.exists(SUMMARY_FILE) or os.path.getsize(SUMMARY_FILE) == 0 + with open(SUMMARY_FILE, "a", encoding="utf-8") as f: + if header_needed: + f.write("# Awesome-OpenCode Backlog Review Summary\n\n") + f.write("Autonomous review powered by OpenCode evaluating OpenCode V2 compatibility and catalog quality.\n\n") + f.write("| Type | # | Title | Target Name | V2 Status | Verdict | Conf | Reason |\n") + f.write("| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n") + f.write(f"| {item_type} | [{num}](https://github.com/{REPO}/{'pull' if item_type == 'PR' else 'issues'}/{num}) | {title[:35]} | {target_name[:25]} | `{v2_status}` | **`{verdict}`** | {confidence}% | {reason} |\n") + +def fetch_open_prs(): + print("Fetching open PRs from GitHub...") + out = subprocess.check_output([ + "gh", "pr", "list", + "-R", REPO, + "--state", "open", + "--limit", "600", + "--json", "number,title,body,author,headRefName,createdAt,url" + ], text=True) + prs = json.loads(out) + prs.sort(key=lambda x: x["number"]) + return prs + +def fetch_open_issues(): + print("Fetching open issues from GitHub...") + out = subprocess.check_output([ + "gh", "issue", "list", + "-R", REPO, + "--state", "open", + "--limit", "100", + "--json", "number,title,body,author,createdAt,url" + ], text=True) + issues = json.loads(out) + issues.sort(key=lambda x: x["number"]) + return issues + +def inspect_target_repo(repo_url): + """Gathers factual evidence from target GitHub repo (commit dates, package.json dependencies).""" + facts = {"repo_url": repo_url, "exists": False} + match = re.search(r'github\.com/([^/]+)/([^/#?]+)', repo_url or "") + if not match: + return facts + + owner, repo_name = match.group(1), match.group(2).removesuffix(".git") + api_url = f"https://api.github.com/repos/{owner}/{repo_name}" + + # 1. Check repo status via gh api + try: + raw = subprocess.check_output(["gh", "api", api_url, "--jq", "{archived, pushed_at, stargazers_count, default_branch, description}"], text=True) + repo_data = json.loads(raw) + facts["exists"] = True + facts["archived"] = repo_data.get("archived", False) + facts["pushed_at"] = repo_data.get("pushed_at") + facts["stars"] = repo_data.get("stargazers_count", 0) + facts["default_branch"] = repo_data.get("default_branch", "main") + facts["description"] = repo_data.get("description", "") + except Exception as e: + facts["error"] = str(e) + return facts + + # 2. Check package.json for @opencode/plugin or @opencode-ai/plugin + branch = facts.get("default_branch", "HEAD") + pkg_url = f"https://raw.githubusercontent.com/{owner}/{repo_name}/{branch}/package.json" + try: + req = urllib.request.Request(pkg_url, headers={"User-Agent": "OpenCode-Reviewer"}) + with urllib.request.urlopen(req, timeout=5) as resp: + pkg_data = json.loads(resp.read().decode("utf-8")) + deps = {} + deps.update(pkg_data.get("dependencies", {})) + deps.update(pkg_data.get("devDependencies", {})) + deps.update(pkg_data.get("peerDependencies", {})) + + opencode_deps = {k: v for k, v in deps.items() if "opencode" in k.lower()} + facts["opencode_dependencies"] = opencode_deps + if "@opencode/plugin" in opencode_deps: + facts["v2_plugin_package_detected"] = True + if "@opencode-ai/plugin" in opencode_deps: + facts["v1_plugin_package_detected"] = True + except Exception: + facts["package_json_found"] = False + + return facts + +def get_pr_diff_files(pr_number, head_ref): + """Retrieves diff or file list from git or gh CLI.""" + try: + out = subprocess.check_output([ + "gh", "pr", "view", str(pr_number), + "-R", REPO, + "--json", "files" + ], text=True) + data = json.loads(out) + files = data.get("files", []) + return files + except Exception: + return [] + +def get_pr_file_content(head_ref, file_path): + """Fetches the content of a file from the PR branch.""" + ref = f"origin/{head_ref}" + try: + out = subprocess.check_output(["git", "show", f"{ref}:{file_path}"], text=True) + return out + except Exception: + # Fallback to local ref + try: + return subprocess.check_output(["git", "show", f"refs/remotes/{head_ref}:{file_path}"], text=True) + except Exception: + return None + +def invoke_opencode_review(system_prompt, item_context, model=None): + """Invokes opencode run with the formatted prompt.""" + combined_message = ( + f"{system_prompt}\n\n" + f"---\n\n" + f"# ITEM TO REVIEW NOW\n\n" + f"{item_context}\n\n" + f"Please inspect the facts above and provide your complete review and assessment.\n" + f"You MUST include the final assessment in the requested JSON block." + ) + + cmd = ["opencode", "run"] + if model: + cmd.extend(["-m", model]) + cmd.append(combined_message) + + try: + out = subprocess.check_output(cmd, text=True, timeout=120) + return out + except subprocess.TimeoutExpired: + return json.dumps({"error": "OpenCode review timed out after 120s"}) + except subprocess.CalledProcessError as e: + return f"OpenCode run failed with exit code {e.returncode}: {e.output}" + +def parse_json_verdict(raw_output): + """Extracts JSON block from OpenCode output.""" + match = re.search(r'```json\s*(\{.*?\})\s*```', raw_output, re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except Exception: + pass + # Fallback search for bare JSON object + match2 = re.search(r'(\{[\s\S]*"verdict"[\s\S]*\})', raw_output) + if match2: + try: + return json.loads(match2.group(1)) + except Exception: + pass + return None + +def review_pr(pr, system_prompt, dry_run=False, model=None): + num = pr["number"] + title = pr["title"] + head_ref = pr.get("headRefName", f"upstream-pr/{num}") + print(f"\n==================================================") + print(f"šŸ” Reviewing PR #{num}: {title[:60]}") + print(f"==================================================") + + files = get_pr_diff_files(num, head_ref) + yaml_files = [f["path"] for f in files if f.get("path", "").startswith("data/") and f.get("path", "").endswith(".yaml")] + + yaml_content = None + target_repo_url = None + if yaml_files: + yaml_content = get_pr_file_content(head_ref, yaml_files[0]) + if yaml_content: + repo_match = re.search(r'repo:\s*([^\s\n]+)', yaml_content) + if repo_match: + target_repo_url = repo_match.group(1) + + # Fallback to searching body if no yaml repo found + if not target_repo_url: + candidates = re.findall(r'https?://github\.com/[a-zA-Z0-9_\-]+/[a-zA-Z0-9_\-]+', pr.get("body", "")) + for c in candidates: + if not any(skip in c for skip in ("awesome-opencode", "Heretek-AI", "sindresorhus", "anomalyco/opencode")): + target_repo_url = c + break + + # Inspect target repo facts + facts = {} + if target_repo_url: + print(f" Investigating target repository: {target_repo_url}...") + facts = inspect_target_repo(target_repo_url) + + item_context = ( + f"### Pull Request Metadata\n" + f"- **PR Number**: #{num}\n" + f"- **Title**: {title}\n" + f"- **Author**: {pr.get('author', {}).get('login', 'unknown')}\n" + f"- **Created At**: {pr.get('createdAt')}\n" + f"- **Changed Files**: {', '.join(f.get('path', '') for f in files)}\n\n" + f"### Submitted YAML Content\n" + f"```yaml\n{yaml_content or '*No YAML file found in PR*'}\n```\n\n" + f"### PR Body / Description\n" + f"{pr.get('body', '')}\n\n" + f"### Verified Target Repository Facts\n" + f"```json\n{json.dumps(facts, indent=2)}\n```" + ) + + if dry_run: + print(" [DRY-RUN] Item context prepared. OpenCode execution skipped.") + return { + "item_type": "PR", + "item_number": num, + "title": title, + "verdict": "DRY_RUN", + "v2_status": "UNKNOWN", + "confidence_score": 0, + "summary_reason": "Dry-run execution", + "facts": facts + } + + print(" Running OpenCode evaluation...") + raw_response = invoke_opencode_review(system_prompt, item_context, model=model) + verdict = parse_json_verdict(raw_response) + if not verdict: + print(" āš ļø Warning: Could not parse structured JSON from OpenCode output. Storing raw output.") + verdict = { + "item_type": "PR", + "item_number": num, + "title": title, + "verdict": "PARSE_ERROR", + "v2_status": "UNKNOWN", + "confidence_score": 0, + "summary_reason": "Failed to parse structured JSON from model", + "raw_output": raw_response + } + else: + print(f" āœ… Verdict: {verdict.get('verdict')} | V2 Status: {verdict.get('opencode_v2_status')} (Confidence: {verdict.get('confidence_score')}%)") + print(f" šŸ’” Rationale: {verdict.get('summary_reason')}") + + return verdict + +def review_issue(issue, system_prompt, dry_run=False, model=None): + num = issue["number"] + title = issue["title"] + print(f"\n==================================================") + print(f"šŸ” Reviewing Issue #{num}: {title[:60]}") + print(f"==================================================") + + body = issue.get("body", "") + target_repo_url = None + body_match = re.search(r'https?://github\.com/[a-zA-Z0-9_\-]+/[a-zA-Z0-9_\-]+', body) + if body_match: + target_repo_url = body_match.group(0) + + facts = {} + if target_repo_url: + print(f" Investigating target repository: {target_repo_url}...") + facts = inspect_target_repo(target_repo_url) + + item_context = ( + f"### Issue Metadata\n" + f"- **Issue Number**: #{num}\n" + f"- **Title**: {title}\n" + f"- **Author**: {issue.get('author', {}).get('login', 'unknown')}\n" + f"- **Created At**: {issue.get('createdAt')}\n\n" + f"### Issue Body\n" + f"{body}\n\n" + f"### Verified Target Repository Facts\n" + f"```json\n{json.dumps(facts, indent=2)}\n```" + ) + + if dry_run: + print(" [DRY-RUN] Item context prepared. OpenCode execution skipped.") + return { + "item_type": "ISSUE", + "item_number": num, + "title": title, + "verdict": "DRY_RUN", + "v2_status": "UNKNOWN", + "confidence_score": 0, + "summary_reason": "Dry-run execution", + "facts": facts + } + + print(" Running OpenCode evaluation...") + raw_response = invoke_opencode_review(system_prompt, item_context, model=model) + verdict = parse_json_verdict(raw_response) + if not verdict: + print(" āš ļø Warning: Could not parse structured JSON from OpenCode output.") + verdict = { + "item_type": "ISSUE", + "item_number": num, + "title": title, + "verdict": "PARSE_ERROR", + "v2_status": "UNKNOWN", + "confidence_score": 0, + "summary_reason": "Failed to parse structured JSON from model", + "raw_output": raw_response + } + else: + print(f" āœ… Verdict: {verdict.get('verdict')} | V2 Status: {verdict.get('opencode_v2_status')} (Confidence: {verdict.get('confidence_score')}%)") + print(f" šŸ’” Rationale: {verdict.get('summary_reason')}") + + return verdict + +def main(): + parser = argparse.ArgumentParser(description="Run OpenCode review loop over open PRs and issues.") + parser.add_argument("--limit", type=int, default=None, help="Maximum number of items to review") + parser.add_argument("--pr", type=int, default=None, help="Review a single PR by number") + parser.add_argument("--issue", type=int, default=None, help="Review a single issue by number") + parser.add_argument("--prs-only", action="store_true", help="Review only PRs") + parser.add_argument("--issues-only", action="store_true", help="Review only issues") + parser.add_argument("--dry-run", action="store_true", help="Collect data without calling OpenCode LLM") + parser.add_argument("--model", type=str, default=None, help="Model override for OpenCode (e.g. deepseek/deepseek-flash)") + parser.add_argument("--reset", action="store_true", help="Reset state file before running") + args = parser.parse_args() + + if args.reset and os.path.exists(STATE_FILE): + os.remove(STATE_FILE) + print("Cleared review state.") + + state = load_state() + system_prompt = load_prompt() + processed_count = 0 + + # 1. Single PR Mode + if args.pr: + prs = fetch_open_prs() + target = next((p for p in prs if p["number"] == args.pr), None) + if not target: + sys.exit(f"PR #{args.pr} not found or not open.") + verdict = review_pr(target, system_prompt, dry_run=args.dry_run, model=args.model) + state["processed_prs"][str(args.pr)] = verdict + save_state(state) + append_to_summary("PR", args.pr, target["title"], verdict.get("target_name", ""), verdict.get("verdict", ""), verdict.get("opencode_v2_status", ""), verdict.get("confidence_score", 0), verdict.get("summary_reason", "")) + return + + # 2. Single Issue Mode + if args.issue: + issues = fetch_open_issues() + target = next((i for i in issues if i["number"] == args.issue), None) + if not target: + sys.exit(f"Issue #{args.issue} not found or not open.") + verdict = review_issue(target, system_prompt, dry_run=args.dry_run, model=args.model) + state["processed_issues"][str(args.issue)] = verdict + save_state(state) + append_to_summary("ISSUE", args.issue, target["title"], verdict.get("target_name", ""), verdict.get("verdict", ""), verdict.get("opencode_v2_status", ""), verdict.get("confidence_score", 0), verdict.get("summary_reason", "")) + return + + # 3. Batch Loop Mode + items_to_process = [] + if not args.prs_only and not args.issues_only: + # Interleave or process issues first then PRs + issues = fetch_open_issues() + prs = fetch_open_prs() + for i in issues: + if str(i["number"]) not in state["processed_issues"]: + items_to_process.append(("ISSUE", i)) + for p in prs: + if str(p["number"]) not in state["processed_prs"]: + items_to_process.append(("PR", p)) + elif args.issues_only: + issues = fetch_open_issues() + for i in issues: + if str(i["number"]) not in state["processed_issues"]: + items_to_process.append(("ISSUE", i)) + elif args.prs_only: + prs = fetch_open_prs() + for p in prs: + if str(p["number"]) not in state["processed_prs"]: + items_to_process.append(("PR", p)) + + print(f"\nUnreviewed backlog items in queue: {len(items_to_process)}") + if args.limit: + items_to_process = items_to_process[:args.limit] + print(f"Limiting to first {len(items_to_process)} items as requested.") + + for idx, (kind, item) in enumerate(items_to_process, 1): + num = item["number"] + print(f"\n[{idx}/{len(items_to_process)}] Processing {kind} #{num}...") + if kind == "PR": + verdict = review_pr(item, system_prompt, dry_run=args.dry_run, model=args.model) + state["processed_prs"][str(num)] = verdict + else: + verdict = review_issue(item, system_prompt, dry_run=args.dry_run, model=args.model) + state["processed_issues"][str(num)] = verdict + + save_state(state) + append_to_summary( + kind, num, item["title"], + verdict.get("target_name", ""), + verdict.get("verdict", ""), + verdict.get("opencode_v2_status", ""), + verdict.get("confidence_score", 0), + verdict.get("summary_reason", "") + ) + processed_count += 1 + time.sleep(1.0) + + print(f"\nšŸŽ‰ Review loop batch complete! Processed {processed_count} items.") + print(f"Results recorded in {SUMMARY_FILE} and {STATE_FILE}") + +if __name__ == "__main__": + main()