diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f13a1c3a..905a12c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,18 @@ on: pull_request: branches: [main] +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: planning-governance: name: Planning Governance runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -62,6 +70,7 @@ jobs: backend-tests: name: Backend Tests runs-on: ubuntu-latest + timeout-minutes: 20 env: UDOS_ROOT: ${{ github.workspace }}/.. UDOS_HOME: ${{ github.workspace }}/.ci-udos @@ -79,21 +88,21 @@ jobs: uses: actions/checkout@v4 with: repository: fredporter/uFlow - ref: work/2026-08-18-stabilise + ref: main path: external/uFlow - name: Checkout uKnowledge uses: actions/checkout@v4 with: repository: fredporter/uKnowledge - ref: work/2026-08-18-stabilise + ref: main path: external/uKnowledge - name: Checkout uCode runtime uses: actions/checkout@v4 with: repository: fredporter/uCode - ref: work/2026-08-18-stabilise + ref: main path: external/uCode - name: Install backend dependencies @@ -114,6 +123,7 @@ jobs: frontend-build: name: Frontend Build runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 @@ -127,7 +137,7 @@ jobs: uses: actions/checkout@v4 with: repository: fredporter/uCode - ref: work/2026-08-18-stabilise + ref: main path: external/uCode - name: Expose sibling uCode source contract diff --git a/.github/workflows/snackmachine-smoke.yml b/.github/workflows/snackmachine-smoke.yml deleted file mode 100644 index fb5e1bdf..00000000 --- a/.github/workflows/snackmachine-smoke.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: SnackMachine Smoke - -on: - workflow_dispatch: - inputs: - ucore_url: - description: "uCore backend base URL" - required: false - default: "http://127.0.0.1:8484" - type: string - frontend_url: - description: "uCore frontend base URL" - required: false - default: "http://localhost:5175" - type: string - snackmachine_path: - description: "Absolute path to SnackMachine checkout on runner" - required: false - default: "$HOME/Code/SnackMachine" - type: string - -jobs: - smoke: - name: Run SnackMachine Integration Smoke - runs-on: self-hosted - - steps: - - name: Checkout uCore - uses: actions/checkout@v4 - - - name: Prepare smoke environment - run: | - echo "UCORE_URL=${{ inputs.ucore_url }}" >> "$GITHUB_ENV" - echo "FRONTEND_URL=${{ inputs.frontend_url }}" >> "$GITHUB_ENV" - echo "UCORE_SNACKMACHINE_PATH=${{ inputs.snackmachine_path }}" >> "$GITHUB_ENV" - - - name: Validate SnackMachine contract - run: python3 scripts/check_snackmachine_contract.py - - - name: Run integration smoke - run: bash scripts/smoke_snackmachine_integration.sh diff --git a/README.md b/README.md index 44d130d6..5bb45499 100644 --- a/README.md +++ b/README.md @@ -55,31 +55,6 @@ curl http://localhost:8484/api/health ./scripts/install.sh --uninstall ``` -### Manual Smoke Workflow (GitHub Actions) - -uCore includes a manual workflow for SnackMachine integration smoke: - -- Workflow name: SnackMachine Smoke -- Workflow file: .github/workflows/snackmachine-smoke.yml -- Trigger: Actions -> SnackMachine Smoke -> Run workflow - -Inputs: - -- ucore_url (default: http://127.0.0.1:8484) -- frontend_url (default: http://localhost:5175) -- snackmachine_path (default: $HOME/Code/SnackMachine) - -Self-hosted runner prerequisites: - -- uCore backend running and reachable at ucore_url -- uCore frontend running and reachable at frontend_url -- SnackMachine repo checked out at snackmachine_path -- Python 3 available on runner PATH - -What it runs: - -- bash scripts/smoke_snackmachine_integration.sh - ## Architecture uCore is the **host platform core**. Optional capabilities — workflow, diff --git a/backend/app/api/developer_api.py b/backend/app/api/developer_api.py index a8f5d33f..f01eeee5 100644 --- a/backend/app/api/developer_api.py +++ b/backend/app/api/developer_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import subprocess from pathlib import Path from typing import Any @@ -184,6 +185,80 @@ def _git_output(repo_path: Path, *args: str) -> str: return result.stdout.strip() +def _github_json(repo_path: Path, *args: str) -> Any: + """Run one read-only gh query against a repository remote.""" + try: + result = subprocess.run( + ["gh", *args], + cwd=repo_path, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return None + + +def _repo_github_status(repo_name: str) -> dict[str, Any]: + """Return the GitHub/Actions state used by the Developer surface.""" + repo_path = _repo_path(repo_name) + branch = _git_output(repo_path, "rev-parse", "--abbrev-ref", "HEAD") or "unknown" + repository = _github_json( + repo_path, + "repo", + "view", + "--json", + "nameWithOwner,url,defaultBranchRef", + ) + if not isinstance(repository, dict): + return { + "repo": repo_name, + "configured": False, + "branch": branch, + "error": "GitHub CLI is unavailable, unauthenticated, or the remote is not on GitHub", + } + + runs = _github_json( + repo_path, + "run", + "list", + "--branch", + branch, + "--limit", + "5", + "--json", + "databaseId,workflowName,status,conclusion,headBranch,event,createdAt,url", + ) + prs = _github_json( + repo_path, + "pr", + "list", + "--head", + branch, + "--state", + "open", + "--limit", + "1", + "--json", + "number,title,state,isDraft,url,statusCheckRollup", + ) + return { + "repo": repo_name, + "configured": True, + "branch": branch, + "repository": repository, + "pull_request": prs[0] if isinstance(prs, list) and prs else None, + "runs": runs if isinstance(runs, list) else [], + } + + def _repo_file_count(repo_path: Path, limit: int = 500) -> int: count = 0 for path in repo_path.rglob("*"): @@ -700,6 +775,19 @@ async def handle_list_repos(request: web.Request) -> web.Response: return web.json_response({"repos": repos, "count": len(repos)}) +async def handle_repo_github_status(request: web.Request) -> web.Response: + """GET /api/developer/repos/{repo_name}/github — PR and Actions state.""" + repo_name = request.match_info["repo_name"] + try: + payload = _repo_github_status(repo_name) + except FileNotFoundError: + return web.json_response( + {"error": f"Repository not found: {repo_name}"}, + status=404, + ) + return web.json_response(payload) + + async def handle_list_repo_files(request: web.Request) -> web.Response: repo_name = request.match_info["repo_name"] include_hidden = _to_bool(request.query.get("include_hidden"), default=False) @@ -939,6 +1027,7 @@ async def handle_developer_chat(request: web.Request) -> web.Response: "• /api/developer/repos/{name}/files — list files in a repo\n" "• /api/developer/repos/{name}/review — git status review of changes\n" "• /api/developer/repos/{name}/status — staged/unstaged file status\n" + "• /api/developer/repos/{name}/github — GitHub PR and Actions status\n" "• /api/developer/repos/{name}/diff?path=... — view file diff\n" "• /api/developer/repos/{name}/file-preview?path=... — preview file content\n" "• /api/developer/repos/{name}/stage — stage a file (POST)\n" diff --git a/backend/app/api/github.py b/backend/app/api/github.py deleted file mode 100644 index 04d85f8a..00000000 --- a/backend/app/api/github.py +++ /dev/null @@ -1,277 +0,0 @@ -"""GitHub API — web endpoints for GitHub automation""" -from __future__ import annotations - -import hashlib -import hmac -import json -import logging -import os - -from aiohttp import web - -from ..services.mcp.github_tools import get_github_tools - -log = logging.getLogger("ucore.api.github") - -# GitHub webhook secret (set via env var) -WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "") - - -def register_github_routes(app: web.Application) -> None: - """Register GitHub API routes.""" - app.router.add_get("/api/github/status", github_status_handler) - app.router.add_post("/api/github/webhook", github_webhook_handler) - app.router.add_post("/api/github/trigger/{tool}", github_trigger_handler) - app.router.add_get("/api/github/repos", github_repos_handler) - log.info("GitHub API routes registered") - - -async def github_status_handler(request: web.Request) -> web.Response: - """Get GitHub organization status dashboard. - - GET /api/github/status - - Returns summary of repos, workflows, issues across org. - """ - try: - token = request.query.get("token") or os.getenv("GITHUB_TOKEN") - tools = get_github_tools(token=token) - - # Get repos - repos = tools.client.list_repos() - - # Get CI status for all repos - ci_status = tools.actions_status() - - # Count issues across repos - total_issues = 0 - for repo in repos: - issues = tools.client.list_issues(repo["name"]) - total_issues += len(issues) - - return web.json_response({ - "success": True, - "org": tools.org, - "repos": { - "total": len(repos), - "list": repos[:10], # Limit response size - }, - "ci": { - "total_runs": ci_status.get("total_runs", 0), - "failures": ci_status.get("failures", 0), - "failed_runs": ci_status.get("failed_runs", []), - }, - "issues": { - "total_open": total_issues, - }, - }) - except Exception as e: - log.error(f"GitHub status error: {e}") - return web.json_response({ - "success": False, - "error": str(e), - }, status=500) - - -async def github_webhook_handler(request: web.Request) -> web.Response: - """Handle GitHub webhook events. - - POST /api/github/webhook - - Processes GitHub webhooks for automation triggers. - """ - try: - # Verify webhook signature - signature = request.headers.get("X-Hub-Signature-256", "") - body = await request.read() - - if WEBHOOK_SECRET and not verify_webhook_signature( - body, signature, WEBHOOK_SECRET, - ): - return web.json_response({ - "success": False, - "error": "Invalid signature", - }, status=401) - - # Parse event - event_type = request.headers.get("X-GitHub-Event", "") - payload = json.loads(body) - - log.info(f"Received GitHub webhook: {event_type}") - - token = os.getenv("GITHUB_TOKEN") - tools = get_github_tools(token=token) - - result = {"success": True, "event": event_type} - - # Handle different event types - if event_type == "push": - # On push to main, check CI status - if payload.get("ref") == "refs/heads/main": - repo_name = payload["repository"]["name"] - ci_result = tools.actions_status( - repo_name=repo_name, - auto_retry_failed=True, - ) - result["ci_check"] = ci_result - - elif event_type == "pull_request": - # On PR opened, could auto-label or check - action = payload.get("action") - if action == "opened": - pr_number = payload["pull_request"]["number"] - repo_name = payload["repository"]["name"] - result["action"] = f"PR #{pr_number} opened" - - elif event_type == "issues": - # On issue opened, auto-triage - action = payload.get("action") - if action == "opened": - repo_name = payload["repository"]["name"] - heal_result = tools.heal_issues( - repo_name=repo_name, - auto_label=True, - ) - result["triage"] = heal_result - - elif event_type == "workflow_run": - # On workflow completion, check for failures - conclusion = payload.get("workflow_run", {}).get("conclusion") - if conclusion == "failure": - repo_name = payload["repository"]["name"] - run_id = payload["workflow_run"]["id"] - result["workflow_failed"] = { - "repo": repo_name, - "run_id": run_id, - } - - return web.json_response(result) - - except Exception as e: - log.error(f"Webhook error: {e}") - return web.json_response({ - "success": False, - "error": str(e), - }, status=500) - - -async def github_trigger_handler(request: web.Request) -> web.Response: - """Manually trigger a GitHub tool. - - POST /api/github/trigger/{tool} - - Body: tool-specific parameters - """ - try: - tool_name = request.match_info["tool"] - params = await request.json() if request.body_exists else {} - - token = params.get("token") or os.getenv("GITHUB_TOKEN") - tools = get_github_tools(token=token) - - # Route to appropriate tool - if tool_name == "publish_release": - result = tools.publish_release( - repo_name=params.get("repo_name"), - version=params.get("version"), - draft=params.get("draft", False), - ) - - elif tool_name == "sync_repos": - result = tools.sync_repos( - local_dir=params.get("local_dir"), - ) - - elif tool_name == "create_pr": - result = tools.create_pr( - repo_name=params.get("repo_name"), - title=params.get("title"), - body=params.get("body"), - base=params.get("base", "main"), - ) - - elif tool_name == "heal_issues": - result = tools.heal_issues( - repo_name=params.get("repo_name"), - auto_label=params.get("auto_label", True), - auto_close_stale=params.get("auto_close_stale", False), - ) - - elif tool_name == "actions_status": - result = tools.actions_status( - repo_name=params.get("repo_name"), - auto_retry_failed=params.get("auto_retry_failed", False), - ) - - elif tool_name == "approve_pr": - result = tools.approve_pr( - repo_name=params.get("repo_name"), - pr_number=params.get("pr_number"), - auto_merge=params.get("auto_merge", False), - ) - - else: - return web.json_response({ - "success": False, - "error": f"Unknown tool: {tool_name}", - }, status=400) - - return web.json_response(result) - - except Exception as e: - log.error(f"Tool trigger error: {e}") - return web.json_response({ - "success": False, - "error": str(e), - }, status=500) - - -async def github_repos_handler(request: web.Request) -> web.Response: - """List all org repositories. - - GET /api/github/repos - """ - try: - token = request.query.get("token") or os.getenv("GITHUB_TOKEN") - tools = get_github_tools(token=token) - - repos = tools.client.list_repos() - - return web.json_response({ - "success": True, - "count": len(repos), - "repos": repos, - }) - except Exception as e: - log.error(f"List repos error: {e}") - return web.json_response({ - "success": False, - "error": str(e), - }, status=500) - - -def verify_webhook_signature(payload: bytes, signature: str, - secret: str) -> bool: - """Verify GitHub webhook signature. - - Args: - payload: Request body bytes - signature: X-Hub-Signature-256 header value - secret: Webhook secret - - Returns: - True if signature is valid - - """ - if not signature.startswith("sha256="): - return False - - expected_sig = hmac.new( - secret.encode(), - payload, - hashlib.sha256, - ).hexdigest() - - received_sig = signature[7:] # Remove "sha256=" prefix - - return hmac.compare_digest(expected_sig, received_sig) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 765f38af..5d0c6e3c 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -38,6 +38,7 @@ def register_routes(app: web.Application) -> None: handle_list_repo_files, handle_list_repo_review, handle_list_repos, + handle_repo_github_status, handle_repo_status, handle_stage_repo_file, handle_start_developer, @@ -72,7 +73,6 @@ def register_routes(app: web.Application) -> None: handle_quality_score, handle_quality_stats, ) - from .github import register_github_routes from .gridsmith_api import ( handle_gridsmith_grid_create, handle_gridsmith_import_basic, @@ -194,6 +194,9 @@ def register_routes(app: web.Application) -> None: app.router.add_get("/api/developer/repos/{repo_name}/diff", handle_get_repo_file_diff) app.router.add_get("/api/developer/repos/{repo_name}/review", handle_list_repo_review) app.router.add_get("/api/developer/repos/{repo_name}/status", handle_repo_status) + app.router.add_get( + "/api/developer/repos/{repo_name}/github", handle_repo_github_status + ) app.router.add_post("/api/developer/repos/{repo_name}/stage", handle_stage_repo_file) app.router.add_post("/api/developer/repos/{repo_name}/unstage", handle_unstage_repo_file) app.router.add_post("/api/developer/repos/{repo_name}/commit", handle_commit_repo_files) @@ -381,7 +384,6 @@ async def handle_autonomy_state(_request: web.Request) -> web.Response: register_surface_routes(app) register_snack_routes(app) register_container_routes(app) - register_github_routes(app) # ── Spool / Activity Feed ─────────────────────────────────────── try: diff --git a/backend/tests/test_developer_github_status.py b/backend/tests/test_developer_github_status.py new file mode 100644 index 00000000..45f41d8a --- /dev/null +++ b/backend/tests/test_developer_github_status.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +from app.api import developer_api + + +def test_repo_github_status_reports_actions_and_pr(monkeypatch, tmp_path): + repo = tmp_path / "uCore" + repo.mkdir() + monkeypatch.setattr(developer_api, "_repo_path", lambda _name: repo) + monkeypatch.setattr( + developer_api, + "_git_output", + lambda _repo, *_args: "codex/github-alignment", + ) + + responses = [ + {"nameWithOwner": "fredporter/uCore", "url": "https://github.com/fredporter/uCore", "defaultBranchRef": {"name": "main"}}, + [{"workflowName": "CI", "status": "completed", "conclusion": "success"}], + [{"number": 3, "title": "Align GitHub", "state": "OPEN", "isDraft": False, "url": "https://github.com/fredporter/uCore/pull/3", "statusCheckRollup": []}], + ] + + def fake_run(*_args, **_kwargs): + return SimpleNamespace(returncode=0, stdout=json.dumps(responses.pop(0)), stderr="") + + monkeypatch.setattr(developer_api.subprocess, "run", fake_run) + + payload = developer_api._repo_github_status("uCore") + + assert payload["configured"] is True + assert payload["repository"]["nameWithOwner"] == "fredporter/uCore" + assert payload["runs"][0]["conclusion"] == "success" + assert payload["pull_request"]["number"] == 3 + + +def test_repo_github_status_degrades_when_gh_is_unavailable(monkeypatch, tmp_path): + repo = tmp_path / "uCore" + repo.mkdir() + monkeypatch.setattr(developer_api, "_repo_path", lambda _name: repo) + monkeypatch.setattr(developer_api, "_git_output", lambda _repo, *_args: "main") + monkeypatch.setattr( + developer_api.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=1, stdout="", stderr="no auth"), + ) + + payload = developer_api._repo_github_status("uCore") + + assert payload["configured"] is False + assert payload["branch"] == "main" diff --git a/docs/DEVELOPER_GITHUB_CONTRACT.md b/docs/DEVELOPER_GITHUB_CONTRACT.md new file mode 100644 index 00000000..6437b8e1 --- /dev/null +++ b/docs/DEVELOPER_GITHUB_CONTRACT.md @@ -0,0 +1,31 @@ +# Developer GitHub Contract + +**Status:** Canonical pre-release contract +**Updated:** 2026-08-19 + +Developer is the repository, code, editor, review, and GitHub handoff surface. +It does not own tasks, providers, agent configuration, or operational dashboards. + +For each local repository, Developer exposes local branch/worktree state and a +read-only GitHub summary from the repository's configured `origin`: repository +identity, the open pull request for the current branch, and recent Actions runs. +The `gh` CLI is the single authenticated transport. Tokens are never accepted +from frontend query parameters or returned to the browser. + +Mutation follows the review sequence: edit, inspect diff, stage, test, commit, +push, open PR, observe required Actions, then merge. Push, PR, review, rerun, +merge, release, and ruleset changes are external writes and require explicit +authorization. GitHub is the remote source of truth; uFlow owns durable task and +evidence state. + +Core CI tests uCore with uFlow, uKnowledge, and uCode from their `main` branches. +Temporary stabilization branches are not valid pre-release dependencies. + +The retired SnackMachine self-hosted smoke workflow was not a reliable CI gate: +it referenced a deleted validator and depended on a pre-running workstation. +Future extension integration tests must create their own reproducible fixtures +or run in the owning extension repository. + +The standalone `/api/github/*` automation API is retired. It duplicated the +Developer workflow and exposed unrelated release, repo-sync, issue-healing, +retry, approval, and merge mutations behind one generic trigger endpoint. diff --git a/frontend-vue/src/surfaces/developer/DeveloperSurface.vue b/frontend-vue/src/surfaces/developer/DeveloperSurface.vue index 7a40114e..88d63545 100644 --- a/frontend-vue/src/surfaces/developer/DeveloperSurface.vue +++ b/frontend-vue/src/surfaces/developer/DeveloperSurface.vue @@ -42,6 +42,15 @@