diff --git a/.github/workflows/sdk-sync-dashboard.yml b/.github/workflows/sdk-sync-dashboard.yml index 962d88234..f073de2bd 100644 --- a/.github/workflows/sdk-sync-dashboard.yml +++ b/.github/workflows/sdk-sync-dashboard.yml @@ -59,7 +59,7 @@ jobs: run: | mkdir -p report mise run "$DRIFT_TASK" > report/drift.json 2> report/drift.stderr || true - if ! jq -e 'has("synced") and (.synced | type == "boolean")' report/drift.json >/dev/null 2>&1; then + if ! jq -e 'type == "object" and has("synced") and (.synced | type == "boolean") and (has("error") | not)' report/drift.json >/dev/null 2>&1; then jq -n --arg sdk "$SDK_NAME" '{sdk:$sdk, has_drift:"error", build_failed:"false"}' > report/status.json exit 0 fi @@ -100,6 +100,7 @@ jobs: matrix: sdk: ${{ fromJSON(needs.load_config.outputs.matrix) }} steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sdk-sync-${{ matrix.sdk.name }} @@ -115,35 +116,44 @@ jobs: echo "::error::Drift detection failed for ${{ matrix.sdk.name }} SDK" cat report/drift.stderr exit 1 - - if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools - if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' + if: steps.status.outputs.has_drift == 'true' run: mise install --locked - name: Create or update drift issue - if: steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'true' + if: steps.status.outputs.has_drift == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set +e RESULT=$(uv run python tasks/scripts/sdk_sync.py manage-issue \ --drift-report "$(cat report/drift.json)" \ --build-report "$(cat report/build.json)" \ --sdk "${{ matrix.sdk.name }}" \ --repo "$GITHUB_REPOSITORY" \ --label "${{ matrix.sdk.label }}") - echo "$RESULT" | jq . - ACTION=$(echo "$RESULT" | jq -r '.action // "unknown"') - if [ "$ACTION" = "error" ] || [ "$ACTION" = "unknown" ]; then + STATUS=$? + set -e + if ! printf '%s\n' "$RESULT" | jq .; then + printf '%s\n' "$RESULT" + fi + if [ "$STATUS" -ne 0 ]; then + echo "::error::Issue management command failed with exit code $STATUS" + exit "$STATUS" + fi + if ! ACTION=$(printf '%s\n' "$RESULT" | jq -er '.action // "unknown"'); then + ACTION=unknown + fi + if [ "$ACTION" != "created" ] && [ "$ACTION" != "updated" ]; then echo "::error::Issue management for ${{ matrix.sdk.name }} failed: $ACTION" exit 1 fi - name: Close resolved drift issue - if: steps.status.outputs.has_drift == 'false' || (steps.status.outputs.has_drift == 'true' && steps.status.outputs.build_failed == 'false') + if: steps.status.outputs.has_drift == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | ISSUE=$(gh issue list --repo "$GITHUB_REPOSITORY" --label "${{ matrix.sdk.label }}" --state open --json number --jq '.[0].number') if [ -n "$ISSUE" ]; then - gh issue close "$ISSUE" --repo "$GITHUB_REPOSITORY" --comment "SDK builds and tests pass after proto regeneration. Closing automatically." + gh issue close "$ISSUE" --repo "$GITHUB_REPOSITORY" --comment "The SDK proto drift check passed. Closing automatically." echo "Closed issue #$ISSUE" fi diff --git a/tasks/scripts/sdk_sync.py b/tasks/scripts/sdk_sync.py index 8a0aca3bf..d56893066 100644 --- a/tasks/scripts/sdk_sync.py +++ b/tasks/scripts/sdk_sync.py @@ -73,15 +73,15 @@ Fix proto drift in the {display_name} SDK. ## Context -The root `proto/` directory has changed and the {display_name} SDK's generated bindings are out of sync. The drifted files are: {drifted_names}. +The {display_name} SDK's proto drift check detected drift. The drifted files are: {drifted_names}. {build_context} ## Steps 1. **Regenerate bindings**: Run `mise run {proto_task}` to regenerate language-specific bindings from the updated protos. -2. **Fix compilation errors**: Read the build log above. Update the SDK source code to handle new/changed/removed proto fields: +2. **Fix compilation errors, if any**: Consult the build log if available. Update the SDK source code as needed to handle new/changed/removed proto fields: {source_dirs} -3. **Fix test failures**: Update tests that assert on proto types that changed shape. +3. **Fix test failures, if any**: Update tests that assert on proto types that changed shape. 4. **Verify**: Run `mise run {build_task}` and `mise run {test_task}` until both pass. 5. **Create a PR**: Commit all changes and create a PR referencing this issue. @@ -165,6 +165,19 @@ def _render_agent_section( "The build log above shows the exact error. Your job is to fix the " f"{display_name} SDK code so it compiles and passes tests with the updated protos." ) + elif build_report and build_report.get("success") is True: + if sdk == "go": + build_context = ( + "\nRegeneration, build, and tests passed in CI, but the " + "committed bindings still need to be regenerated and committed." + ) + else: + build_context = ( + "\nThe subsequent regeneration, typecheck, and tests passed in CI. " + "Generated TypeScript bindings are gitignored; rerun " + f"`mise run {paths['drift_task']}` to confirm compatibility " + "before changing SDK source." + ) else: build_context = "\nThe SDK build status is unknown. Check if it compiles after regeneration." @@ -205,26 +218,59 @@ def _run_cmd( def _ensure_label(repo: str, label: str, description: str) -> None: - check = _run_cmd(["gh", "label", "view", label, "--repo", repo], capture=True) + check = _run_cmd( + [ + "gh", + "label", + "list", + "--repo", + repo, + "--search", + label, + "--limit", + "20", + "--json", + "name", + ], + capture=True, + ) if check.returncode != 0: - result = _run_cmd( - [ - "gh", - "label", - "create", - label, - "--repo", - repo, - "--description", - description, - "--color", - "D93F0B", - ], - capture=True, + details = check.stderr.strip() or "unknown error" + raise RuntimeError(f"Failed to look up label '{label}' in {repo}: {details}") + try: + labels = json.loads(check.stdout) + except json.JSONDecodeError as error: + raise RuntimeError(f"Invalid label list for {repo}: {error.msg}") from error + if not isinstance(labels, list) or any( + not isinstance(item, dict) or not isinstance(item.get("name"), str) + for item in labels + ): + raise RuntimeError(f"Invalid label list for {repo}: expected label names") + # GitHub's search is fuzzy; similar SDK labels do not establish existence. + if any(item["name"].casefold() == label.casefold() for item in labels): + return + if len(labels) >= 20: + raise RuntimeError( + f"Label search for '{label}' in {repo} was truncated; cannot confirm absence" ) - if result.returncode != 0: - details = result.stderr.strip() or "unknown error" - raise RuntimeError(f"Failed to create label '{label}': {details}") + result = _run_cmd( + [ + "gh", + "label", + "create", + label, + "--repo", + repo, + "--description", + description, + "--color", + "D93F0B", + ], + capture=True, + ) + if result.returncode != 0: + details = result.stderr.strip() or "unknown error" + raise RuntimeError(f"Failed to create label '{label}': {details}") def _find_open_issue(repo: str, label: str) -> dict | None: @@ -239,20 +285,44 @@ def _find_open_issue(repo: str, label: str) -> dict | None: label, "--state", "open", + "--limit", + "1", "--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, TypeError): - pass - return None + context = f"for {repo} with label '{label}'" + if result.returncode != 0: + details = result.stderr.strip() or "no stderr" + raise RuntimeError( + f"Failed to list open issues {context} " + f"(exit code {result.returncode}): {details}" + ) + try: + issues = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError( + f"Invalid issue list {context}: expected JSON array ({error.msg})" + ) from error + if not isinstance(issues, list): + raise RuntimeError(f"Invalid issue list {context}: expected JSON array") + if not issues: + return None + + issue = issues[0] + if not isinstance(issue, dict): + raise RuntimeError(f"Invalid issue list {context}: expected issue object") + url = issue.get("url") + number = issue.get("number") + if ( + not isinstance(url, str) + or not url.strip() + or type(number) is not int + or number <= 0 + ): + raise RuntimeError(f"Invalid issue list {context}: invalid issue URL or number") + return {"url": url, "number": str(number)} # --- public functions --- @@ -348,18 +418,23 @@ def _manage_issue( # --- CLI --- -def _load_json_arg(value: str) -> dict | list: - if value == "-": - return json.load(sys.stdin) - return json.loads(value) +def _load_json_arg(value: str) -> dict: + report = json.load(sys.stdin) if value == "-" else json.loads(value) + if not isinstance(report, dict): + raise ValueError("Expected report to be a JSON object") + return report 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) + try: + drift_report = _load_json_arg(args.drift_report) + build_report = _load_json_arg(args.build_report) if args.build_report else None + except ValueError as error: + result = {"issue_url": "", "action": "error", "reason": str(error)} + else: + result = manage_issue( + drift_report, build_report, args.sdk, args.repo, args.label + ) print(json.dumps(result)) return 1 if result.get("action") == "error" else 0 diff --git a/tasks/scripts/sdk_sync_test.py b/tasks/scripts/sdk_sync_test.py index a654d6bd1..0198cda3d 100644 --- a/tasks/scripts/sdk_sync_test.py +++ b/tasks/scripts/sdk_sync_test.py @@ -1,198 +1,297 @@ # 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 -""" +"""SDK sync regression tests. Run via: mise run test:sdk-sync.""" from __future__ import annotations +import json +import os import subprocess +from argparse import Namespace +from pathlib import Path from unittest.mock import patch -from sdk_sync import ( - _run_cmd, - generate_issue_body, - manage_issue, +import pytest +import yaml +from sdk_sync import cmd_manage_issue, generate_issue_body + +ISSUE = {"number": 42, "url": "https://github.com/org/repo/issues/42"} +LABELS = [{"name": "SDK:GO:SYNC"}] + + +def _response(stdout="", code=0, stderr=""): + return subprocess.CompletedProcess([], code, stdout=stdout, stderr=stderr) + + +@pytest.fixture +def drift(): + return { + "sdk": "go", + "synced": False, + "summary": "1 file drifted", + "files": [{"name": "openshell.pb.go", "status": "modified", "diff_lines": 5}], + } + + +@pytest.fixture +def issue_args(drift): + return Namespace( + sdk="go", + repo="org/repo", + label="sdk:go:sync", + drift_report=json.dumps(drift), + build_report='{"success":true}', + ) + + +@pytest.mark.parametrize( + ("sdk", "success", "context"), + [ + ("go", None, "build status is unknown"), + ("typescript", None, "not individually tracked"), + ("go", True, "committed bindings still need to be regenerated and committed"), + ("typescript", True, "gitignored"), + ("go", False, "fails at the `build` step"), + ], ) - - -def _mock_run(returncode=0, stdout="", stderr=""): - return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr) - - -class TestGenerateIssueBody: - def test_go_sdk_issue_body(self): - drift = { - "sdk": "go", - "synced": False, - "files": [ - { - "name": "openshellv1/openshell.pb.go", - "status": "modified", - "diff_lines": 5, - } - ], - "summary": "1 file(s) drifted", +def test_issue_body(drift, sdk, success, context): + report = {**drift, "sdk": sdk, "files": drift["files"] if sdk == "go" else []} + build = ( + None + if success is None + else { + "success": success, + "failed_step": None if success else "build", + "log": "compiler error", } - md = generate_issue_body(drift, None, "go") - assert "## Proto Drift Report" in md - assert "`openshellv1/openshell.pb.go`" in md - assert "## Fix Commands" in md - assert "mise run go:proto:gen" in md + ) + body = generate_issue_body(report, build, sdk) - def test_typescript_sdk_issue_body(self): - drift = { - "sdk": "typescript", - "synced": False, - "files": [], - "summary": "typecheck failed after proto regeneration", - } - md = generate_issue_body(drift, None, "typescript") - assert "mise run sdk:ts:proto" in md - assert "mise run sdk:ts:build" in md - assert "mise run sdk:ts:test" in md - assert "sdk/typescript/src/" in md - assert "not individually tracked" in md - assert "mise run sdk:ts:proto && mise run sdk:ts:typecheck" 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_agent_instructions_present(self): - drift = { - "sdk": "go", - "synced": False, - "files": [ - { - "name": "openshellv1/openshell.pb.go", - "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:gen" in md - assert "sdk/go/openshell/v1/internal/converter/" in md - assert "sdk/go/openshell/v1/types/" in md - assert "sdk/go/openshell/v1/" in md - assert "Create a PR" in md - - 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() + assert context in body + assert "## Agent Instructions" in body + assert "Create a PR" in body + assert f"sdk/{sdk}/" in body + assert ("## Build Log" in body) == (success is False) + if sdk == "go": + assert "| `openshell.pb.go` | modified | 5 |" in body + assert "mise run go:proto:gen" in body + else: + assert "mise run sdk:ts:proto" in body + if success is False: + assert "compiler error" in body -class TestManageIssue: - @patch("sdk_sync.subprocess.run") - def test_command_timeout_uses_default_and_becomes_runtime_error(self, mock_run): - mock_run.side_effect = subprocess.TimeoutExpired(["gh", "issue", "list"], 60) +def test_build_log_keeps_only_the_last_lines(drift): + build = {"failed_step": "test", "log": "discarded\nretained\nlast line"} + body = generate_issue_body(drift, build, "go", max_log_lines=2) + assert "discarded" not in body + assert "retained\nlast line" in body - try: - _run_cmd(["gh", "issue", "list"]) - raise AssertionError("expected timeout error") - except RuntimeError as error: - assert str(error) == ("Command timed out after 60 seconds: gh issue list") - assert mock_run.call_args.kwargs["timeout"] == 60 - @patch("sdk_sync._ensure_label") - def test_command_timeout_returns_structured_error(self, mock_label): - mock_label.side_effect = RuntimeError( - "Command timed out after 60 seconds: gh label view" +@pytest.mark.parametrize("labels", [LABELS, [{"name": "area:sdk:go"}]]) +def test_create_then_update_preserves_label_and_issue( + issue_args, drift, labels, capsys +): + """Exercise all lifecycle helpers; mock only the GitHub subprocesses.""" + missing_label = labels != LABELS + responses = [_response(json.dumps(labels))] + operations = ["label list"] + if missing_label: + responses.append(_response()) + operations.append("label create") + responses += [ + _response("[]"), + _response(ISSUE["url"]), + _response(json.dumps(LABELS)), + _response(json.dumps([ISSUE])), + _response(), + ] + operations += [ + "issue list", + "issue create", + "label list", + "issue list", + "issue edit", + ] + + with patch("sdk_sync.subprocess.run", side_effect=responses) as github: + for action in ["created", "updated"]: + issue_args.drift_report = json.dumps({**drift, "summary": action}) + assert cmd_manage_issue(issue_args) == 0 + assert json.loads(capsys.readouterr().out) == { + "action": action, + "issue_url": ISSUE["url"], + } + cmd = github.call_args.args[0] + assert cmd[cmd.index("--body-file") + 1] == "-" + assert f"**Summary**: {action}" in github.call_args.kwargs["input"] + + assert [" ".join(call.args[0][1:3]) for call in github.call_args_list] == operations + assert github.call_args.args[0][3] == str(ISSUE["number"]) + + +@pytest.mark.parametrize( + ("before", "failure"), + [ + ([], _response(code=1, stderr="API unavailable")), + ([], _response("invalid JSON")), + ([], _response('[{"name":null}]')), + ([], _response(json.dumps([{"name": f"other-{i}"} for i in range(20)]))), + ([_response("[]")], _response(code=1, stderr="label creation denied")), + ([_response(json.dumps(LABELS))], _response(code=1, stderr="API unavailable")), + ([_response(json.dumps(LABELS))], _response("invalid JSON")), + ([_response(json.dumps(LABELS))], _response("null")), + ([_response(json.dumps(LABELS))], _response('[{"url":"url","number":true}]')), + ], +) +def test_lookup_errors_stop_before_issue_mutation(issue_args, capsys, before, failure): + with patch("sdk_sync.subprocess.run", side_effect=[*before, failure]) as github: + assert cmd_manage_issue(issue_args) == 1 + result = json.loads(capsys.readouterr().out) + assert result["action"] == "error" + assert result["reason"] + assert github.call_count == len(before) + 1 + assert all( + call.args[0][1:3] not in [["issue", "create"], ["issue", "edit"]] + for call in github.call_args_list + ) + + +def test_timeout_returns_an_error_without_retrying(issue_args, capsys): + with patch( + "sdk_sync.subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 60) + ) as github: + assert cmd_manage_issue(issue_args) == 1 + result = json.loads(capsys.readouterr().out) + assert result["action"] == "error" + assert "timed out after 60 seconds" in result["reason"] + github.assert_called_once() + assert github.call_args.kwargs["timeout"] == 60 + + +@pytest.mark.parametrize( + ("field", "value"), [("drift_report", "invalid JSON"), ("build_report", "[]")] +) +def test_invalid_reports_never_call_github(issue_args, capsys, field, value): + setattr(issue_args, field, value) + with patch("sdk_sync.subprocess.run") as github: + assert cmd_manage_issue(issue_args) == 1 + github.assert_not_called() + assert json.loads(capsys.readouterr().out)["action"] == "error" + + +@pytest.fixture +def dashboard(): + path = ( + Path(__file__).resolve().parents[2] / ".github/workflows/sdk-sync-dashboard.yml" + ) + return yaml.safe_load(path.read_text())["jobs"] + + +def _step(dashboard, job, name): + return next(step for step in dashboard[job]["steps"] if step.get("name") == name) + + +def _run_shell(tmp_path, script, **env): + script = script.replace("${{ matrix.sdk.name }}", "go").replace( + "${{ matrix.sdk.label }}", "sdk:go:sync" + ) + return subprocess.run( + ["sh", "-e"], + input=script, + cwd=tmp_path, + text=True, + capture_output=True, + env={**os.environ, "GITHUB_REPOSITORY": "org/repo", **env}, + ) + + +@pytest.mark.parametrize( + ("report", "build_exit", "expected"), + [ + ('{"synced":true}', "0", "false"), + ('{"synced":false}', "0", "true"), + ('{"synced":false}', "1", "true"), + ('{"synced":false,"error":"generation failed"}', "0", "error"), + ("invalid JSON", "0", "error"), + ], +) +def test_workflow_classifies_drift(dashboard, tmp_path, report, build_exit, expected): + step = _step(dashboard, "sdk_sync_check", "Check proto drift and build") + stub = """mise() { + if [ "$2" = drift ]; then printf '%s\\n' "$REPORT"; return 1; fi + printf '%s\\n' '{}' + return "$BUILD_EXIT" + } + """ + result = _run_shell( + tmp_path, + stub + step["run"], + SDK_NAME="go", + DRIFT_TASK="drift", + BUILD_CHECK_TASK="build", + REPORT=report, + BUILD_EXIT=build_exit, + ) + assert result.returncode == 0, result.stderr + status = json.loads((tmp_path / "report/status.json").read_text()) + assert status["has_drift"] == expected + assert status["build_failed"] == ( + "true" if expected == "true" and build_exit == "1" else "false" + ) + + +@pytest.mark.parametrize( + ("output", "code", "succeeds"), + [ + ('{"action":"created"}', "0", True), + ('{"action":"updated"}', "0", True), + ('{"action":"error"}', "1", False), + ("invalid JSON", "0", False), + ("", "0", False), + ('{"action":"unexpected"}', "0", False), + ], +) +def test_workflow_requires_successful_issue_result( + dashboard, tmp_path, output, code, succeeds +): + step = _step(dashboard, "issue_management", "Create or update drift issue") + report = tmp_path / "report" + report.mkdir() + for name in ["drift", "build"]: + (report / f"{name}.json").write_text("{}") + stub = 'uv() { printf \'%s\\n\' "$OUTPUT"; return "$CODE"; }\n' + result = _run_shell(tmp_path, stub + step["run"], OUTPUT=output, CODE=code) + assert (result.returncode == 0) == succeeds, result.stdout + result.stderr + + +@pytest.mark.parametrize(("number", "code"), [("42", "0"), ("", "0"), ("", "1")]) +def test_workflow_closes_resolved_issue(dashboard, tmp_path, number, code): + step = _step(dashboard, "issue_management", "Close resolved drift issue") + stub = """gh() { + if [ "$2" = list ]; then printf '%s\\n' "$NUMBER"; return "$CODE"; fi + [ "$2" = close ] && [ "$3" = 42 ] || return 9 + printf 'closed\\n' > closed + } + """ + result = _run_shell(tmp_path, stub + step["run"], NUMBER=number, CODE=code) + assert result.returncode == int(code) + assert (tmp_path / "closed").exists() == bool(number) + + +def test_workflow_gates_issue_lifecycle_on_drift(dashboard): + steps = dashboard["issue_management"]["steps"] + checkout = next( + step for step in steps if step.get("uses", "").startswith("actions/checkout@") + ) + assert "if" not in checkout + for name in ["Install tools", "Create or update drift issue"]: + assert ( + _step(dashboard, "issue_management", name)["if"] + == "steps.status.outputs.has_drift == 'true'" ) - - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") - - assert result["action"] == "error" - assert "timed out after 60 seconds" in result["reason"] - - @patch("sdk_sync._find_open_issue") - @patch("sdk_sync._ensure_label") - def test_label_creation_failure_stops_issue_management(self, mock_label, mock_find): - mock_label.side_effect = RuntimeError( - "Failed to create label 'sdk:go:sync': permission denied" - ) - - drift = {"sdk": "go", "synced": False, "files": [], "summary": "drifted"} - result = manage_issue(drift, None, "go", "org/repo", "sdk:go:sync") - - assert result == { - "issue_url": "", - "action": "error", - "reason": "Failed to create label 'sdk:go:sync': permission denied", - } - mock_find.assert_not_called() - - @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:go:sync") - 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:go:sync") - assert result["action"] == "updated" - assert "10" in result["issue_url"] + assert ( + _step(dashboard, "issue_management", "Close resolved drift issue")["if"] + == "steps.status.outputs.has_drift == 'false'" + ) diff --git a/tasks/test.toml b/tasks/test.toml index 8c41e7002..cf34b3fcd 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -91,11 +91,16 @@ hide = true ["test:python"] description = "Run Python tests" -depends = ["python:proto"] +depends = ["python:proto", "test:sdk-sync"] env = { UV_NO_SYNC = "1" } run = "uv run pytest python/" hide = true +["test:sdk-sync"] +description = "Test SDK drift reports and issue lifecycle management" +run = "uv run --no-project --with pytest --with pyyaml pytest tasks/scripts/sdk_sync_test.py" +hide = true + ["e2e:rust"] description = "Run Rust CLI e2e tests against a Docker-backed gateway" depends = ["e2e:conformance:build"]