fix(ci): harden SDK drift issue lifecycle

Signed-off-by: Ignas Baranauskas <ibaranau@redhat.com>
This commit is contained in:
Ignas Baranauskas
2026-09-14 09:36:27 +01:00
parent 1820aeab3d
commit 8170db0cea
4 changed files with 420 additions and 231 deletions
+20 -10
View File
@@ -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
+114 -39
View File
@@ -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
+280 -181
View File
@@ -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'"
)
+6 -1
View File
@@ -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"]