mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-22 06:19:47 +08:00
fix(ci): harden SDK drift issue lifecycle
Signed-off-by: Ignas Baranauskas <ibaranau@redhat.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user