From 5b1513300ae2c6fe10de5c7134e89514dff400c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 17 Jul 2026 12:44:14 +0200 Subject: [PATCH] feat(ci): add SDK proto sync CI with drift detection and dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add automated proto drift detection for multi-language SDKs: - sdk_sync.py: Python script with six subcommands (drift-report, dashboard, issue-body, wiki-push, manage-issue, dispatch) for testable, lintable sync logic - sdk_sync_test.py: 22 pytest tests covering drift detection, dashboard generation, issue body formatting, log truncation, wiki push, issue management, repository dispatch, and agent prompt - go:proto:drift mise task: calls sdk_sync.py drift-report - go:proto:build-check mise task: runs full sync+gen+build+test pipeline - sdk-proto-check.yml: PR gate workflow with non-blocking warning annotations - sdk-sync-dashboard.yml: scheduled daily workflow for wiki dashboard, agent-actionable issue creation, and repository_dispatch Auto-created drift issues include an "Agent Instructions" section with a structured prompt that an AI agent can directly consume to fix the drift and create a PR. The prompt includes the scope, affected files, converter/types paths, fix steps, and commands to verify. Signed-off-by: Roland Huß --- .github/workflows/sdk-proto-check.yml | 83 ++++ .github/workflows/sdk-sync-dashboard.yml | 148 ++++++ tasks/go.toml | 47 ++ tasks/scripts/sdk_sync.py | 574 +++++++++++++++++++++++ tasks/scripts/sdk_sync_test.py | 306 ++++++++++++ 5 files changed, 1158 insertions(+) create mode 100644 .github/workflows/sdk-proto-check.yml create mode 100644 .github/workflows/sdk-sync-dashboard.yml create mode 100644 tasks/go.toml create mode 100644 tasks/scripts/sdk_sync.py create mode 100644 tasks/scripts/sdk_sync_test.py diff --git a/.github/workflows/sdk-proto-check.yml b/.github/workflows/sdk-proto-check.yml new file mode 100644 index 0000000000..83138c4190 --- /dev/null +++ b/.github/workflows/sdk-proto-check.yml @@ -0,0 +1,83 @@ +name: SDK Proto Check + +on: + push: + branches: + - "pull-request/[0-9]+" + workflow_dispatch: + +permissions: + contents: read + packages: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr_metadata: + name: Resolve PR metadata + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + should_run: ${{ steps.gate.outputs.should_run }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - id: gate + uses: ./.github/actions/pr-gate + + sdk_proto_drift: + name: Proto Drift (${{ matrix.sdk.name }}) + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: ubuntu-latest + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + strategy: + fail-fast: false + matrix: + sdk: + - name: go + drift_task: "go:proto:drift" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Check proto drift + id: drift + run: | + REPORT=$(uv run python tasks/scripts/sdk_sync.py drift-report \ + --sdk ${{ matrix.sdk.name }} \ + --upstream-path proto \ + --sdk-path sdk/${{ matrix.sdk.name }}/proto 2>&1) || true + + if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then + SYNCED=$(echo "$REPORT" | jq -r '.synced') + { + echo "report<> "$GITHUB_OUTPUT" + echo "synced=$SYNCED" >> "$GITHUB_OUTPUT" + else + echo "::warning::Proto drift check failed: $REPORT" + echo "synced=error" >> "$GITHUB_OUTPUT" + fi + + - name: Annotate drift warning + if: steps.drift.outputs.synced == 'false' + env: + DRIFT_REPORT: ${{ steps.drift.outputs.report }} + SDK_NAME: ${{ matrix.sdk.name }} + run: | + SUMMARY=$(echo "$DRIFT_REPORT" | jq -r '.summary') + FILES=$(echo "$DRIFT_REPORT" | jq -r '.files[] | select(.status != "synced") | " - \(.name) (\(.status), \(.diff_lines) lines changed)"' | sed ':a;N;$!ba;s/\n/%0A/g') + echo "::warning::SDK proto drift detected for ${SDK_NAME}: ${SUMMARY}%0A${FILES}" diff --git a/.github/workflows/sdk-sync-dashboard.yml b/.github/workflows/sdk-sync-dashboard.yml new file mode 100644 index 0000000000..0342b3fdba --- /dev/null +++ b/.github/workflows/sdk-sync-dashboard.yml @@ -0,0 +1,148 @@ +name: SDK Sync Dashboard + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + actions: read + contents: write + packages: read + issues: write + +concurrency: + group: sdk-sync-dashboard + cancel-in-progress: true + +jobs: + sdk_sync_check: + name: Sync Check (${{ matrix.sdk.name }}) + runs-on: ubuntu-latest + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + strategy: + fail-fast: false + matrix: + sdk: + - name: go + build_task: "go:proto:build-check" + label: "sdk-sync:go" + outputs: + drift_report: ${{ steps.drift.outputs.report }} + has_drift: ${{ steps.drift.outputs.has_drift }} + build_report: ${{ steps.build.outputs.report || '' }} + build_failed: ${{ steps.build.outputs.build_failed || 'false' }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Check proto drift + id: drift + run: | + REPORT=$(uv run python tasks/scripts/sdk_sync.py drift-report \ + --sdk ${{ matrix.sdk.name }} \ + --upstream-path proto \ + --sdk-path sdk/${{ matrix.sdk.name }}/proto 2>/dev/null) || true + + if echo "$REPORT" | jq -e '.synced' >/dev/null 2>&1; then + SYNCED=$(echo "$REPORT" | jq -r '.synced') + { + echo "report<> "$GITHUB_OUTPUT" + [ "$SYNCED" = "true" ] && echo "has_drift=false" >> "$GITHUB_OUTPUT" || echo "has_drift=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Proto drift check failed" + echo "report={}" >> "$GITHUB_OUTPUT" + echo "has_drift=error" >> "$GITHUB_OUTPUT" + fi + + - name: Run build check + id: build + if: steps.drift.outputs.has_drift == 'true' + env: + BUILD_TASK: ${{ matrix.sdk.build_task }} + run: | + REPORT=$(mise run "$BUILD_TASK" 2>&1) && BUILD_OK=true || BUILD_OK=false + if echo "$REPORT" | jq -e '.sdk' >/dev/null 2>&1; then + { echo "report<> "$GITHUB_OUTPUT" + else + echo "report={}" >> "$GITHUB_OUTPUT" + fi + [ "$BUILD_OK" = "true" ] && echo "build_failed=false" >> "$GITHUB_OUTPUT" || echo "build_failed=true" >> "$GITHUB_OUTPUT" + + wiki_dashboard: + name: Update Wiki Dashboard + needs: sdk_sync_check + if: always() && needs.sdk_sync_check.result != 'cancelled' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Generate and push dashboard + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRIFT_REPORT: ${{ needs.sdk_sync_check.outputs.drift_report }} + BUILD_REPORT: ${{ needs.sdk_sync_check.outputs.build_report }} + run: | + DASHBOARD=$(mktemp) + uv run python tasks/scripts/sdk_sync.py dashboard \ + --drift-report "${DRIFT_REPORT:-{}}" \ + --build-report "${BUILD_REPORT:-{}}" \ + --output "$DASHBOARD" + + uv run python tasks/scripts/sdk_sync.py wiki-push \ + --content "$DASHBOARD" \ + --page-name SDK-Sync-Status \ + --repo "$GITHUB_REPOSITORY" + + issue_management: + name: Manage Drift Issues + needs: sdk_sync_check + if: needs.sdk_sync_check.outputs.has_drift == 'true' && needs.sdk_sync_check.outputs.build_failed == 'true' + runs-on: ubuntu-latest + outputs: + issue_url: ${{ steps.issue.outputs.issue_url }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Create or update drift issue + id: issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRIFT_REPORT: ${{ needs.sdk_sync_check.outputs.drift_report }} + BUILD_REPORT: ${{ needs.sdk_sync_check.outputs.build_report }} + run: | + RESULT=$(uv run python tasks/scripts/sdk_sync.py manage-issue \ + --drift-report "${DRIFT_REPORT:-{}}" \ + --build-report "${BUILD_REPORT:-{}}" \ + --sdk go \ + --repo "$GITHUB_REPOSITORY" \ + --label "sdk-sync:go") + echo "issue_url=$(echo "$RESULT" | jq -r '.issue_url')" >> "$GITHUB_OUTPUT" + + - name: Fire repository_dispatch + if: steps.issue.outputs.issue_url != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRIFT_REPORT: ${{ needs.sdk_sync_check.outputs.drift_report }} + run: | + DRIFT_SUMMARY=$(echo "${DRIFT_REPORT:-{}}" | jq -r '.summary // "unknown"') + uv run python tasks/scripts/sdk_sync.py dispatch \ + --repo "$GITHUB_REPOSITORY" \ + --issue-url "${{ steps.issue.outputs.issue_url }}" \ + --sdk go \ + --drift-summary "$DRIFT_SUMMARY" diff --git a/tasks/go.toml b/tasks/go.toml new file mode 100644 index 0000000000..09e95c6094 --- /dev/null +++ b/tasks/go.toml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Go SDK proto drift detection tasks +# NOTE: This file is merged with the full tasks/go.toml from PR #2271. +# The drift and build-check tasks below extend the Go SDK task set. + +["go:proto:drift"] +description = "Check Go SDK proto files for drift against root proto definitions" +run = "uv run python tasks/scripts/sdk_sync.py drift-report --sdk go --upstream-path proto --sdk-path sdk/go/proto" +hide = true + +["go:proto:build-check"] +description = "Run full Go SDK proto sync, generate, build, and test pipeline" +dir = "sdk/go" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +STEP_NAMES=("sync" "gen" "build" "test") +STEP_TASKS=("go:proto:sync" "go:proto:gen" "go:build" "go:test") +LOG_FILE=$(mktemp) +trap 'rm -f "$LOG_FILE"' EXIT + +FAILED_STEP="" +for i in "${!STEP_NAMES[@]}"; do + STEP="${STEP_NAMES[$i]}" + TASK="${STEP_TASKS[$i]}" + + if ! mise run "$TASK" > "$LOG_FILE" 2>&1; then + FAILED_STEP="$STEP" + break + fi +done + +LOG_CONTENT=$(tail -500 "$LOG_FILE") + +if [ -z "$FAILED_STEP" ]; then + jq -n -c --arg sdk "go" \ + '{sdk: $sdk, success: true, failed_step: null, log: ""}' +else + jq -n -c --arg sdk "go" --arg step "$FAILED_STEP" --arg log "$LOG_CONTENT" \ + '{sdk: $sdk, success: false, failed_step: $step, log: $log}' + exit 1 +fi +''' +hide = true diff --git a/tasks/scripts/sdk_sync.py b/tasks/scripts/sdk_sync.py new file mode 100644 index 0000000000..0ddd99468e --- /dev/null +++ b/tasks/scripts/sdk_sync.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""SDK proto sync utilities. + +Subcommands: + drift-report Compare proto files between upstream and SDK copies + dashboard Generate wiki dashboard markdown from drift/build reports + issue-body Generate GitHub issue body for a drifted SDK + wiki-push Clone wiki repo, update a page, commit, push + manage-issue Create or update a GitHub drift issue (deduplicates by label) + dispatch Fire a repository_dispatch event for agent pickup +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import os +import shutil +import subprocess +import sys +import tempfile +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + + +@dataclass(frozen=True) +class FileDrift: + name: str + status: str # synced, modified, added, removed + diff_lines: int + + +@dataclass(frozen=True) +class DriftReport: + sdk: str + synced: bool + files: list[FileDrift] + summary: str + + +def compute_drift( + sdk: str, upstream_path: Path, sdk_path: Path, proto_files: list[str] +) -> DriftReport: + drifted = 0 + files: list[FileDrift] = [] + + for name in proto_files: + upstream = upstream_path / name + local = sdk_path / name + + if not upstream.exists() and not local.exists(): + continue + + if not upstream.exists() and local.exists(): + files.append(FileDrift(name, "removed", _line_count(local))) + drifted += 1 + elif upstream.exists() and not local.exists(): + files.append(FileDrift(name, "added", _line_count(upstream))) + drifted += 1 + else: + upstream_lines = upstream.read_text().splitlines(keepends=True) + local_lines = local.read_text().splitlines(keepends=True) + diff = list( + difflib.unified_diff(local_lines, upstream_lines, n=0) + ) + if diff: + files.append(FileDrift(name, "modified", len(diff))) + drifted += 1 + else: + files.append(FileDrift(name, "synced", 0)) + + synced = drifted == 0 + summary = "all files synced" if synced else f"{drifted} file(s) drifted" + return DriftReport(sdk=sdk, synced=synced, files=files, summary=summary) + + +def generate_dashboard( + drift_reports: list[dict], + build_reports: list[dict], +) -> str: + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + lines = [ + "# SDK Sync Status", + "", + f"*Last updated: {timestamp}*", + "", + "## Overview", + "", + "| SDK | Proto Synced | Build | Issue |", + "|-----|-------------|-------|-------|", + ] + + drift_details: list[str] = [] + + for drift in drift_reports: + sdk = drift.get("sdk", "unknown") + synced = drift.get("synced", True) + build = _find_build(build_reports, sdk) + + if synced: + proto_status = "synced" + build_status = "n/a" + issue_link = "" + else: + proto_status = "**drifted**" + if build and not build.get("success", True): + build_status = "**failing**" + else: + build_status = "passing" + issue_link = drift.get("issue_url", "") + if issue_link: + issue_num = issue_link.rstrip("/").split("/")[-1] + issue_link = f"[#{issue_num}]({issue_link})" + + lines.append(f"| {sdk.capitalize()} | {proto_status} | {build_status} | {issue_link} |") + + if not synced: + drift_details.extend(_format_drift_details(drift)) + + if drift_details: + lines.append("") + lines.append("## Drift Details") + lines.append("") + lines.extend(drift_details) + + lines.append("") + return "\n".join(lines) + + +def generate_issue_body( + drift_report: dict, + build_report: dict | None, + sdk: str, + max_log_lines: int = 500, +) -> str: + sections: list[str] = [] + + # Proto drift section + sections.append("## Proto Drift Report") + sections.append("") + summary = drift_report.get("summary", "unknown") + sections.append(f"**Summary**: {summary}") + sections.append("") + + files = drift_report.get("files", []) + if files: + sections.append("| File | Status | Diff Lines |") + sections.append("|------|--------|------------|") + for f in files: + if f.get("status") != "synced": + sections.append( + f"| `{f['name']}` | {f['status']} | {f['diff_lines']} |" + ) + sections.append("") + + # Build log section + if build_report: + sections.append("## Build Log") + sections.append("") + failed_step = build_report.get("failed_step", "unknown") + sections.append(f"**Failed step**: `{failed_step}`") + sections.append("") + log = build_report.get("log", "no log available") + log_lines = log.splitlines() + if len(log_lines) > max_log_lines: + log = "\n".join(log_lines[-max_log_lines:]) + sections.append("```") + sections.append(log) + sections.append("```") + sections.append("") + + # Affected files section + sections.append("## Affected Files") + sections.append("") + for f in files: + if f.get("status") != "synced": + sections.append(f"- `sdk/{sdk}/proto/{f['name']}`") + sections.append("") + + # Fix commands section + sections.append("## Fix Commands") + sections.append("") + sections.append("```bash") + sections.append(f"mise run {sdk}:proto:sync # Copy protos from root") + sections.append(f"mise run {sdk}:proto:gen # Regenerate bindings") + sections.append(f"mise run {sdk}:build # Verify build") + sections.append(f"mise run {sdk}:test # Run tests") + sections.append("```") + sections.append("") + + # Agent instructions section + drifted_files = [f for f in files if f.get("status") != "synced"] + drifted_names = ", ".join(f"`{f['name']}`" for f in drifted_files) or "unknown" + failed_step = "" + if build_report: + failed_step = build_report.get("failed_step", "") + + sections.append("## Agent Instructions") + sections.append("") + sections.append( + "This section is a ready-to-consume prompt for an AI agent. " + "Copy it into your agent to produce a fix PR." + ) + sections.append("") + sections.append("
") + sections.append("Agent prompt (click to expand)") + sections.append("") + + prompt_lines = [ + f"Fix proto drift in the {sdk.capitalize()} SDK.", + "", + "## Context", + "", + f"The root `proto/` directory has changed and the {sdk.capitalize()} SDK's local", + f"copies in `sdk/{sdk}/proto/` are out of sync. The drifted files are: {drifted_names}.", + ] + + if failed_step: + prompt_lines.append( + f"The SDK build fails at the `{failed_step}` step after syncing protos." + ) + prompt_lines.append( + "The build log above shows the exact error. Your job is to fix the" + ) + prompt_lines.append( + f"{sdk.capitalize()} SDK code so it compiles and passes tests with the updated protos." + ) + else: + prompt_lines.append("The SDK build status is unknown. Check if it compiles after syncing.") + + prompt_lines.extend([ + "", + "## Steps", + "", + "1. **Sync protos**: Run `mise run {sdk}:proto:sync` to copy the latest proto files from `proto/` to `sdk/{sdk}/proto/`.", + "2. **Regenerate bindings**: Run `mise run {sdk}:proto:gen` to regenerate language-specific bindings from the updated protos.", + "3. **Fix compilation errors**: Read the build log above. Update the SDK source code to handle new/changed/removed proto fields:", + f" - Converter layer: `sdk/{sdk}/openshell/v1/internal/converter/`", + f" - Public types: `sdk/{sdk}/openshell/v1/types/`", + f" - Client methods: `sdk/{sdk}/openshell/v1/`", + "4. **Fix test failures**: Update tests that assert on proto types that changed shape.", + "5. **Verify**: Run `mise run {sdk}:build` and `mise run {sdk}:test` until both pass.", + "6. **Create a PR**: Commit all changes and create a PR referencing this issue.", + "", + "## Scope", + "", + f"- Only modify files under `sdk/{sdk}/`. Do not change root `proto/` files.", + "- Do not change the proto definitions. Adapt the SDK to match them.", + "- Keep changes minimal: only fix what the proto changes broke.", + "", + "## SDK Documentation", + "", + f"- SDK source: `sdk/{sdk}/`", + f"- Mise tasks: `mise tasks --hidden | grep {sdk}:`", + f"- Proto files tracked: `openshell.proto`, `datamodel.proto`, `sandbox.proto`", + f"- Converter pattern: each proto message maps to a domain type in `sdk/{sdk}/openshell/v1/types/` via a converter in `sdk/{sdk}/openshell/v1/internal/converter/`", + ]) + + # Format the prompt lines with the sdk variable resolved + for line in prompt_lines: + sections.append(line.format(sdk=sdk)) + + sections.append("") + sections.append("
") + sections.append("") + + return "\n".join(sections) + + +# --- helpers --- + + +def _line_count(path: Path) -> int: + return len(path.read_text().splitlines()) + + +def _find_build(build_reports: list[dict], sdk: str) -> dict | None: + for b in build_reports: + if b.get("sdk") == sdk: + return b + return None + + +def _format_drift_details(drift: dict) -> list[str]: + sdk = drift.get("sdk", "unknown") + lines = [ + f"### {sdk.capitalize()} SDK", + "", + f"**Status**: {drift.get('summary', 'unknown')}", + "", + "| File | Status | Diff Lines |", + "|------|--------|------------|", + ] + for f in drift.get("files", []): + lines.append(f"| {f['name']} | {f['status']} | {f['diff_lines']} |") + lines.append("") + return lines + + +# --- CLI --- + +DEFAULT_PROTO_FILES = ["openshell.proto", "datamodel.proto", "sandbox.proto"] + + +def cmd_drift_report(args: argparse.Namespace) -> int: + upstream = Path(args.upstream_path) + sdk_path = Path(args.sdk_path) + + if not upstream.is_dir(): + print(f"ERROR: Upstream proto directory not found: {upstream}", file=sys.stderr) + return 1 + + proto_files = args.proto_files or DEFAULT_PROTO_FILES + report = compute_drift(args.sdk, upstream, sdk_path, proto_files) + print(json.dumps(asdict(report), separators=(",", ":"))) + return 0 if report.synced else 1 + + +def cmd_dashboard(args: argparse.Namespace) -> int: + drift_reports = _load_json_arg(args.drift_report) + if not isinstance(drift_reports, list): + drift_reports = [drift_reports] + + build_reports = [] + if args.build_report: + br = _load_json_arg(args.build_report) + build_reports = br if isinstance(br, list) else [br] + + md = generate_dashboard(drift_reports, build_reports) + + if args.output: + Path(args.output).write_text(md) + print(f"Dashboard written to {args.output}") + else: + print(md) + return 0 + + +def cmd_issue_body(args: argparse.Namespace) -> int: + drift_report = _load_json_arg(args.drift_report) + build_report = None + if args.build_report: + build_report = _load_json_arg(args.build_report) + + md = generate_issue_body( + drift_report, build_report, args.sdk, max_log_lines=args.max_log_lines + ) + print(md) + return 0 + + +def wiki_push(content_path: Path, page_name: str, repo: str) -> dict: + token = os.environ.get("GITHUB_TOKEN", "") + if not token: + return {"success": False, "reason": "GITHUB_TOKEN not set"} + + work_dir = tempfile.mkdtemp() + try: + clone_url = f"https://x-access-token:{token}@github.com/{repo}.wiki.git" + result = _run_cmd(["git", "clone", clone_url, work_dir], capture=True) + if result.returncode != 0: + return {"success": False, "reason": "Failed to clone wiki repository"} + + shutil.copy2(str(content_path), os.path.join(work_dir, f"{page_name}.md")) + + _run_cmd(["git", "config", "user.name", "github-actions[bot]"], cwd=work_dir) + _run_cmd(["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"], cwd=work_dir) + _run_cmd(["git", "add", f"{page_name}.md"], cwd=work_dir) + + diff = _run_cmd(["git", "diff", "--cached", "--quiet"], cwd=work_dir, capture=True) + if diff.returncode == 0: + return {"success": True, "reason": "No changes to dashboard"} + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + _run_cmd(["git", "commit", "-m", f"Update {page_name} ({timestamp})"], cwd=work_dir) + + push = _run_cmd(["git", "push"], cwd=work_dir, capture=True) + if push.returncode != 0: + return {"success": False, "reason": "Failed to push wiki update"} + + return {"success": True, "reason": "Wiki updated"} + finally: + shutil.rmtree(work_dir, ignore_errors=True) + + +def manage_issue( + drift_report: dict, + build_report: dict | None, + sdk: str, + repo: str, + label: str, +) -> dict: + _ensure_label(repo, label, f"Proto drift detected for {sdk} SDK") + + body = generate_issue_body(drift_report, build_report, sdk) + title = f"SDK proto drift: {sdk}" + + existing = _find_open_issue(repo, label) + if existing: + result = _run_cmd( + ["gh", "issue", "edit", existing["number"], "--repo", repo, "--body", body], + capture=True, + ) + if result.returncode == 0: + return {"issue_url": existing["url"], "action": "updated"} + return {"issue_url": "", "action": "skipped", "reason": "Failed to update issue"} + + result = _run_cmd( + ["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body, "--label", label], + capture=True, + ) + if result.returncode == 0: + url = result.stdout.strip() + return {"issue_url": url, "action": "created"} + return {"issue_url": "", "action": "skipped", "reason": "Failed to create issue"} + + +def fire_dispatch(repo: str, issue_url: str, sdk: str, drift_summary: str) -> dict: + payload = json.dumps({"issue_url": issue_url, "sdk": sdk, "drift_summary": drift_summary}) + result = _run_cmd( + ["gh", "api", f"repos/{repo}/dispatches", "--method", "POST", + "-f", "event_type=sdk-sync-drift", "--argjson", "client_payload", payload], + capture=True, + ) + if result.returncode == 0: + return {"success": True} + return {"success": False, "reason": result.stderr.strip() or "dispatch failed"} + + +def _run_cmd( + cmd: list[str], cwd: str | None = None, capture: bool = False +) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, cwd=cwd, capture_output=capture, text=True, + ) + + +def _ensure_label(repo: str, label: str, description: str) -> None: + check = _run_cmd(["gh", "label", "view", label, "--repo", repo], capture=True) + if check.returncode != 0: + _run_cmd( + ["gh", "label", "create", label, "--repo", repo, + "--description", description, "--color", "D93F0B"], + capture=True, + ) + + +def _find_open_issue(repo: str, label: str) -> dict | None: + result = _run_cmd( + ["gh", "issue", "list", "--repo", repo, "--label", label, + "--state", "open", "--json", "url,number", "--jq", ".[0]"], + capture=True, + ) + if result.returncode == 0 and result.stdout.strip(): + try: + data = json.loads(result.stdout.strip()) + return {"url": data["url"], "number": str(data["number"])} + except (json.JSONDecodeError, KeyError): + pass + return None + + +def cmd_wiki_push(args: argparse.Namespace) -> int: + content = Path(args.content) + if not content.exists(): + print(f"ERROR: Content file not found: {content}", file=sys.stderr) + return 1 + result = wiki_push(content, args.page_name, args.repo) + print(json.dumps(result)) + return 0 if result["success"] else 0 # always exit 0 (graceful degradation) + + +def cmd_manage_issue(args: argparse.Namespace) -> int: + drift_report = _load_json_arg(args.drift_report) + build_report = None + if args.build_report: + build_report = _load_json_arg(args.build_report) + result = manage_issue(drift_report, build_report, args.sdk, args.repo, args.label) + print(json.dumps(result)) + return 0 + + +def cmd_dispatch(args: argparse.Namespace) -> int: + result = fire_dispatch(args.repo, args.issue_url, args.sdk, args.drift_summary) + print(json.dumps(result)) + return 0 + + +def _load_json_arg(value: str) -> dict | list: + if value == "-": + return json.load(sys.stdin) + p = Path(value) + if p.exists(): + return json.loads(p.read_text()) + return json.loads(value) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="SDK proto sync utilities", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command", required=True) + + # drift-report + dr = sub.add_parser("drift-report", help="Compare proto files for drift") + dr.add_argument("--sdk", required=True, help="SDK name (e.g. go)") + dr.add_argument("--upstream-path", required=True, help="Path to upstream proto/") + dr.add_argument("--sdk-path", required=True, help="Path to SDK proto copy") + dr.add_argument( + "--proto-files", nargs="*", help="Proto files to check (default: 3 core files)" + ) + + # dashboard + db = sub.add_parser("dashboard", help="Generate wiki dashboard markdown") + db.add_argument( + "--drift-report", required=True, help="Drift report JSON (file, string, or -)" + ) + db.add_argument("--build-report", help="Build report JSON (file, string, or -)") + db.add_argument("--output", help="Output file path (stdout if omitted)") + + # issue-body + ib = sub.add_parser("issue-body", help="Generate issue body markdown") + ib.add_argument( + "--drift-report", required=True, help="Drift report JSON (file, string, or -)" + ) + ib.add_argument("--build-report", help="Build report JSON (file, string, or -)") + ib.add_argument("--sdk", required=True, help="SDK name (e.g. go)") + ib.add_argument( + "--max-log-lines", type=int, default=500, help="Max build log lines (default: 500)" + ) + + # wiki-push + wp = sub.add_parser("wiki-push", help="Push a page to the GitHub wiki") + wp.add_argument("--content", required=True, help="Path to markdown file to push") + wp.add_argument("--page-name", required=True, help="Wiki page name (without .md)") + wp.add_argument("--repo", required=True, help="GitHub repo (owner/name)") + + # manage-issue + mi = sub.add_parser("manage-issue", help="Create or update a drift issue") + mi.add_argument("--drift-report", required=True, help="Drift report JSON") + mi.add_argument("--build-report", help="Build report JSON") + mi.add_argument("--sdk", required=True, help="SDK name") + mi.add_argument("--repo", required=True, help="GitHub repo (owner/name)") + mi.add_argument("--label", required=True, help="Issue label for deduplication") + + # dispatch + dp = sub.add_parser("dispatch", help="Fire repository_dispatch event") + dp.add_argument("--repo", required=True, help="GitHub repo (owner/name)") + dp.add_argument("--issue-url", required=True, help="Issue URL") + dp.add_argument("--sdk", required=True, help="SDK name") + dp.add_argument("--drift-summary", required=True, help="Drift summary text") + + args = parser.parse_args() + handlers = { + "drift-report": cmd_drift_report, + "dashboard": cmd_dashboard, + "issue-body": cmd_issue_body, + "wiki-push": cmd_wiki_push, + "manage-issue": cmd_manage_issue, + "dispatch": cmd_dispatch, + } + return handlers[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/scripts/sdk_sync_test.py b/tasks/scripts/sdk_sync_test.py new file mode 100644 index 0000000000..725d2b91e7 --- /dev/null +++ b/tasks/scripts/sdk_sync_test.py @@ -0,0 +1,306 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for tasks/scripts/sdk_sync.py. + +Run via: uv run --no-project --with pytest pytest tasks/scripts/sdk_sync_test.py +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + +from sdk_sync import ( + DriftReport, + FileDrift, + compute_drift, + fire_dispatch, + generate_dashboard, + generate_issue_body, + manage_issue, + wiki_push, +) + + +@pytest.fixture +def proto_dirs(tmp_path: Path): + upstream = tmp_path / "proto" + upstream.mkdir() + sdk = tmp_path / "sdk_proto" + sdk.mkdir() + return upstream, sdk + + +PROTO_FILES = ["openshell.proto", "datamodel.proto", "sandbox.proto"] + + +class TestComputeDrift: + def test_all_synced(self, proto_dirs): + upstream, sdk = proto_dirs + for name in PROTO_FILES: + content = f"syntax = 'proto3';\npackage {name};\n" + (upstream / name).write_text(content) + (sdk / name).write_text(content) + + report = compute_drift("go", upstream, sdk, PROTO_FILES) + assert report.synced is True + assert report.summary == "all files synced" + assert all(f.status == "synced" for f in report.files) + + def test_modified_file(self, proto_dirs): + upstream, sdk = proto_dirs + for name in PROTO_FILES: + (upstream / name).write_text(f"syntax = 'proto3';\npackage {name};\n") + (sdk / name).write_text(f"syntax = 'proto3';\npackage {name};\n") + + (upstream / "openshell.proto").write_text( + "syntax = 'proto3';\npackage openshell;\nmessage NewField {}\n" + ) + + report = compute_drift("go", upstream, sdk, PROTO_FILES) + assert report.synced is False + assert "1 file(s) drifted" in report.summary + + modified = [f for f in report.files if f.status == "modified"] + assert len(modified) == 1 + assert modified[0].name == "openshell.proto" + assert modified[0].diff_lines > 0 + + def test_added_file(self, proto_dirs): + upstream, sdk = proto_dirs + (upstream / "openshell.proto").write_text("syntax = 'proto3';\n") + + report = compute_drift("go", upstream, sdk, ["openshell.proto"]) + assert report.synced is False + added = [f for f in report.files if f.status == "added"] + assert len(added) == 1 + assert added[0].name == "openshell.proto" + + def test_removed_file(self, proto_dirs): + upstream, sdk = proto_dirs + (sdk / "openshell.proto").write_text("syntax = 'proto3';\n") + + report = compute_drift("go", upstream, sdk, ["openshell.proto"]) + assert report.synced is False + removed = [f for f in report.files if f.status == "removed"] + assert len(removed) == 1 + + def test_missing_both(self, proto_dirs): + upstream, sdk = proto_dirs + report = compute_drift("go", upstream, sdk, ["nonexistent.proto"]) + assert report.synced is True + assert len(report.files) == 0 + + +class TestGenerateDashboard: + def test_all_synced(self): + drift = [{"sdk": "go", "synced": True, "files": [], "summary": "all files synced"}] + md = generate_dashboard(drift, []) + assert "synced" in md + assert "n/a" in md + assert "Drift Details" not in md + + def test_drifted_with_build_failure(self): + drift = [ + { + "sdk": "go", + "synced": False, + "files": [{"name": "openshell.proto", "status": "modified", "diff_lines": 5}], + "summary": "1 file(s) drifted", + } + ] + build = [{"sdk": "go", "success": False, "failed_step": "build", "log": "error"}] + md = generate_dashboard(drift, build) + assert "**drifted**" in md + assert "**failing**" in md + assert "Drift Details" in md + + def test_issue_link_formatting(self): + drift = [ + { + "sdk": "go", + "synced": False, + "files": [], + "summary": "drifted", + "issue_url": "https://github.com/NVIDIA/OpenShell/issues/123", + } + ] + md = generate_dashboard(drift, []) + assert "[#123]" in md + + +class TestGenerateIssueBody: + def test_basic_issue_body(self): + drift = { + "sdk": "go", + "synced": False, + "files": [{"name": "openshell.proto", "status": "modified", "diff_lines": 5}], + "summary": "1 file(s) drifted", + } + md = generate_issue_body(drift, None, "go") + assert "## Proto Drift Report" in md + assert "`openshell.proto`" in md + assert "## Fix Commands" in md + assert "mise run go:proto:sync" in md + + def test_with_build_log(self): + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + build = {"sdk": "go", "success": False, "failed_step": "build", "log": "error here"} + md = generate_issue_body(drift, build, "go") + assert "## Build Log" in md + assert "`build`" in md + assert "error here" in md + + def test_log_truncation(self): + long_log = "\n".join(f"line {i}" for i in range(1000)) + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + build = {"sdk": "go", "success": False, "failed_step": "test", "log": long_log} + md = generate_issue_body(drift, build, "go", max_log_lines=500) + log_section = md.split("```")[1] + assert log_section.strip().count("\n") <= 500 + assert "line 999" in md + assert "line 0" not in md + + def test_affected_files_section(self): + drift = { + "sdk": "go", + "synced": False, + "files": [ + {"name": "a.proto", "status": "modified", "diff_lines": 3}, + {"name": "b.proto", "status": "synced", "diff_lines": 0}, + ], + "summary": "1 file(s) drifted", + } + md = generate_issue_body(drift, None, "go") + assert "`sdk/go/proto/a.proto`" in md + assert "b.proto" not in md.split("## Affected Files")[1].split("## Fix")[0] + + def test_agent_instructions_present(self): + drift = { + "sdk": "go", + "synced": False, + "files": [{"name": "openshell.proto", "status": "modified", "diff_lines": 5}], + "summary": "1 file(s) drifted", + } + build = {"sdk": "go", "success": False, "failed_step": "build", "log": "error"} + md = generate_issue_body(drift, build, "go") + assert "## Agent Instructions" in md + assert "Agent prompt" in md + assert "mise run go:proto:sync" in md + assert "sdk/go/openshell/v1/internal/converter/" in md + assert "sdk/go/openshell/v1/types/" in md + assert "Create a PR" in md + + def test_agent_instructions_references_drifted_files(self): + drift = { + "sdk": "go", + "synced": False, + "files": [ + {"name": "openshell.proto", "status": "modified", "diff_lines": 10}, + {"name": "sandbox.proto", "status": "added", "diff_lines": 3}, + ], + "summary": "2 file(s) drifted", + } + md = generate_issue_body(drift, None, "go") + agent_section = md.split("## Agent Instructions")[1] + assert "`openshell.proto`" in agent_section + assert "`sandbox.proto`" in agent_section + + def test_agent_instructions_includes_failed_step(self): + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + build = {"sdk": "go", "success": False, "failed_step": "test", "log": "fail"} + md = generate_issue_body(drift, build, "go") + agent_section = md.split("## Agent Instructions")[1] + assert "`test`" in agent_section + assert "fails at" in agent_section.lower() + + +def _mock_run(returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr) + + +class TestWikiPush: + @patch("sdk_sync._run_cmd") + @patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) + def test_successful_push(self, mock_run, tmp_path): + content = tmp_path / "dashboard.md" + content.write_text("# Dashboard") + + mock_run.side_effect = [ + _mock_run(0), # git clone + _mock_run(0), # git config user.name + _mock_run(0), # git config user.email + _mock_run(0), # git add + _mock_run(1), # git diff --cached --quiet (has changes) + _mock_run(0), # git commit + _mock_run(0), # git push + ] + + result = wiki_push(content, "SDK-Sync-Status", "NVIDIA/OpenShell") + assert result["success"] is True + + @patch("sdk_sync._run_cmd") + @patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) + def test_clone_failure(self, mock_run, tmp_path): + content = tmp_path / "dashboard.md" + content.write_text("# Dashboard") + mock_run.return_value = _mock_run(128) + + result = wiki_push(content, "SDK-Sync-Status", "NVIDIA/OpenShell") + assert result["success"] is False + assert "clone" in result["reason"].lower() + + @patch.dict("os.environ", {}, clear=True) + def test_missing_token(self, tmp_path): + content = tmp_path / "dashboard.md" + content.write_text("# Dashboard") + os.environ.pop("GITHUB_TOKEN", None) + result = wiki_push(content, "SDK-Sync-Status", "NVIDIA/OpenShell") + assert result["success"] is False + assert "TOKEN" in result["reason"] + + +class TestManageIssue: + @patch("sdk_sync._find_open_issue") + @patch("sdk_sync._ensure_label") + @patch("sdk_sync._run_cmd") + def test_create_new_issue(self, mock_run, mock_label, mock_find): + mock_find.return_value = None + mock_run.return_value = _mock_run(0, stdout="https://github.com/org/repo/issues/42\n") + + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + result = manage_issue(drift, None, "go", "org/repo", "sdk-sync:go") + assert result["action"] == "created" + assert "42" in result["issue_url"] + + @patch("sdk_sync._find_open_issue") + @patch("sdk_sync._ensure_label") + @patch("sdk_sync._run_cmd") + def test_update_existing_issue(self, mock_run, mock_label, mock_find): + mock_find.return_value = {"url": "https://github.com/org/repo/issues/10", "number": "10"} + mock_run.return_value = _mock_run(0) + + drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} + result = manage_issue(drift, None, "go", "org/repo", "sdk-sync:go") + assert result["action"] == "updated" + assert "10" in result["issue_url"] + + +class TestFireDispatch: + @patch("sdk_sync._run_cmd") + def test_successful_dispatch(self, mock_run): + mock_run.return_value = _mock_run(0) + result = fire_dispatch("org/repo", "https://github.com/org/repo/issues/42", "go", "1 file(s) drifted") + assert result["success"] is True + + @patch("sdk_sync._run_cmd") + def test_dispatch_failure(self, mock_run): + mock_run.return_value = _mock_run(1, stderr="permission denied") + result = fire_dispatch("org/repo", "https://github.com/org/repo/issues/42", "go", "drifted") + assert result["success"] is False