blob: a32b9e0405015f9699e0079a310e2f231c2392b3 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for bridge.groovy write subcommands.
Strategy: spin up a lightweight HTTP mock server that records requests
and returns canned JIRA API responses, then invoke bridge.groovy via
subprocess against it. Requires ``groovy`` on PATH.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
import pytest
BRIDGE = Path(__file__).parent.parent / "bridge.groovy"
GROOVY = shutil.which("groovy")
pytestmark = pytest.mark.skipif(GROOVY is None, reason="groovy not found on PATH")
# ---------------------------------------------------------------------------
# Mock JIRA server
# ---------------------------------------------------------------------------
class _RequestLog:
def __init__(self) -> None:
self.requests: list[dict[str, Any]] = []
def clear(self) -> None:
self.requests.clear()
_log = _RequestLog()
_canned_responses: dict[str, tuple[int, Any]] = {}
class _JiraHandler(BaseHTTPRequestHandler):
def _read_body(self) -> bytes:
length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length) if length else b""
def _handle(self, method: str) -> None:
body = self._read_body()
entry: dict[str, Any] = {
"method": method,
"path": self.path,
"headers": dict(self.headers),
}
if body:
content_type = self.headers.get("Content-Type", "")
if "application/json" in content_type:
entry["body"] = json.loads(body)
else:
entry["body_raw"] = body
_log.requests.append(entry)
key = f"{method} {self.path.split('?')[0]}"
if key in _canned_responses:
status, payload = _canned_responses[key]
else:
status, payload = 200, {}
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
if status != 204 and payload is not None:
self.wfile.write(json.dumps(payload).encode())
def do_GET(self) -> None:
self._handle("GET")
def do_POST(self) -> None:
self._handle("POST")
def do_PUT(self) -> None:
self._handle("PUT")
def log_message(self, format: str, *args: Any) -> None:
pass # suppress console noise
@pytest.fixture(scope="module")
def mock_server():
server = HTTPServer(("127.0.0.1", 0), _JiraHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
@pytest.fixture(autouse=True)
def _clear_state():
_log.clear()
_canned_responses.clear()
yield
_log.clear()
_canned_responses.clear()
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _run(
mock_url: str,
args: list[str],
*,
token: str = "dGVzdDp0b2tlbg==",
env_extra: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", "/tmp"),
"ISSUE_TRACKER_URL": mock_url,
"ISSUE_TRACKER_PROJECT": "TEST",
}
if token:
env["JIRA_API_TOKEN"] = token
if env_extra:
env.update(env_extra)
assert GROOVY is not None
return subprocess.run(
[GROOVY, str(BRIDGE)] + args,
env=env,
capture_output=True,
text=True,
timeout=30,
)
# ---------------------------------------------------------------------------
# comment
# ---------------------------------------------------------------------------
class TestComment:
def test_post_comment(self, mock_server: str, tmp_path: Path) -> None:
body_file = tmp_path / "comment.txt"
body_file.write_text("This is a test comment.")
_canned_responses["POST /rest/api/2/issue/FOO-1/comment"] = (
201,
{"id": "12345", "body": "This is a test comment."},
)
result = _run(mock_server, ["comment", "FOO-1", "--body-file", str(body_file)])
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["ok"] is True
assert out["key"] == "FOO-1"
assert out["commentId"] == "12345"
req = _log.requests[-1]
assert req["method"] == "POST"
assert req["body"]["body"] == "This is a test comment."
def test_missing_body_file_flag(self, mock_server: str) -> None:
result = _run(mock_server, ["comment", "FOO-1"])
assert result.returncode != 0
assert "--body-file" in result.stderr
def test_nonexistent_body_file(self, mock_server: str) -> None:
result = _run(mock_server, ["comment", "FOO-1", "--body-file", "/no/such/file"])
assert result.returncode != 0
assert "not found" in result.stderr
def test_invalid_key(self, mock_server: str, tmp_path: Path) -> None:
body_file = tmp_path / "c.txt"
body_file.write_text("x")
result = _run(
mock_server, ["comment", "bad-key", "--body-file", str(body_file)]
)
assert result.returncode != 0
assert "not a valid" in result.stderr
# ---------------------------------------------------------------------------
# transition
# ---------------------------------------------------------------------------
class TestTransition:
def test_transition_by_name(self, mock_server: str) -> None:
_canned_responses["GET /rest/api/2/issue/FOO-2/transitions"] = (
200,
{
"transitions": [
{"id": "11", "name": "Start Progress"},
{"id": "21", "name": "Resolve Issue"},
]
},
)
_canned_responses["POST /rest/api/2/issue/FOO-2/transitions"] = (204, None)
result = _run(mock_server, ["transition", "FOO-2", "Resolve Issue"])
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["ok"] is True
assert out["transition"] == "Resolve Issue"
assert out["transitionId"] == "21"
def test_case_insensitive_match(self, mock_server: str) -> None:
_canned_responses["GET /rest/api/2/issue/FOO-2/transitions"] = (
200,
{"transitions": [{"id": "11", "name": "Start Progress"}]},
)
_canned_responses["POST /rest/api/2/issue/FOO-2/transitions"] = (204, None)
result = _run(mock_server, ["transition", "FOO-2", "start progress"])
assert result.returncode == 0
def test_unknown_transition(self, mock_server: str) -> None:
_canned_responses["GET /rest/api/2/issue/FOO-2/transitions"] = (
200,
{"transitions": [{"id": "11", "name": "Start Progress"}]},
)
result = _run(mock_server, ["transition", "FOO-2", "NoSuchTransition"])
assert result.returncode != 0
assert "not found" in result.stderr
def test_missing_transition_name(self, mock_server: str) -> None:
result = _run(mock_server, ["transition", "FOO-2"])
assert result.returncode != 0
assert "transition requires" in result.stderr
# ---------------------------------------------------------------------------
# label
# ---------------------------------------------------------------------------
class TestLabel:
def test_add_label(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-3"] = (204, None)
result = _run(mock_server, ["label", "FOO-3", "--add", "new-label"])
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["added"] == ["new-label"]
assert out["removed"] == []
req = _log.requests[-1]
assert req["method"] == "PUT"
assert req["body"]["update"]["labels"] == [{"add": "new-label"}]
def test_remove_label(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-3"] = (204, None)
result = _run(mock_server, ["label", "FOO-3", "--remove", "drop"])
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["removed"] == ["drop"]
req = _log.requests[-1]
assert req["body"]["update"]["labels"] == [{"remove": "drop"}]
def test_add_and_remove(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-3"] = (204, None)
result = _run(mock_server, ["label", "FOO-3", "--add", "c", "--remove", "a"])
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["added"] == ["c"]
assert out["removed"] == ["a"]
req = _log.requests[-1]
assert req["body"]["update"]["labels"] == [{"add": "c"}, {"remove": "a"}]
def test_no_flags(self, mock_server: str) -> None:
result = _run(mock_server, ["label", "FOO-3"])
assert result.returncode != 0
assert "--add or --remove" in result.stderr
# ---------------------------------------------------------------------------
# assign
# ---------------------------------------------------------------------------
class TestAssign:
def test_assign_user(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-4/assignee"] = (204, None)
result = _run(mock_server, ["assign", "FOO-4", "jdoe"])
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["ok"] is True
assert out["assignee"] == "jdoe"
req = _log.requests[-1]
assert req["body"]["name"] == "jdoe"
def test_missing_username(self, mock_server: str) -> None:
result = _run(mock_server, ["assign", "FOO-4"])
assert result.returncode != 0
assert "username" in result.stderr
# ---------------------------------------------------------------------------
# field
# ---------------------------------------------------------------------------
class TestField:
def test_set_field(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-5"] = (204, None)
result = _run(
mock_server, ["field", "FOO-5", "customfield_10100", "--value", "high"]
)
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["field"] == "customfield_10100"
assert out["value"] == "high"
req = _log.requests[-1]
assert req["body"]["fields"]["customfield_10100"] == "high"
def test_missing_field_name(self, mock_server: str) -> None:
result = _run(mock_server, ["field", "FOO-5"])
assert result.returncode != 0
assert "field requires" in result.stderr
def test_missing_value(self, mock_server: str) -> None:
result = _run(mock_server, ["field", "FOO-5", "customfield_10100"])
assert result.returncode != 0
assert "--value" in result.stderr
def test_value_json_object(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-5"] = (204, None)
result = _run(
mock_server,
["field", "FOO-5", "priority", "--value-json", '{"name":"High"}'],
)
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["value"] == {"name": "High"}
req = _log.requests[-1]
assert req["body"]["fields"]["priority"] == {"name": "High"}
def test_value_json_array(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-5"] = (204, None)
result = _run(
mock_server,
["field", "FOO-5", "fixVersions", "--value-json", '[{"name":"1.2.3"}]'],
)
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["value"] == [{"name": "1.2.3"}]
req = _log.requests[-1]
assert req["body"]["fields"]["fixVersions"] == [{"name": "1.2.3"}]
# ---------------------------------------------------------------------------
# attach
# ---------------------------------------------------------------------------
class TestAttach:
def test_attach_file(self, mock_server: str, tmp_path: Path) -> None:
attachment = tmp_path / "report.txt"
attachment.write_text("file content here")
_canned_responses["POST /rest/api/2/issue/FOO-6/attachments"] = (
200,
[{"id": "99", "filename": "report.txt"}],
)
result = _run(mock_server, ["attach", "FOO-6", str(attachment)])
assert result.returncode == 0
out = json.loads(result.stdout)
assert out["ok"] is True
assert out["attachments"][0]["filename"] == "report.txt"
req = _log.requests[-1]
assert req["method"] == "POST"
assert "X-Atlassian-Token" in req["headers"]
def test_nonexistent_file(self, mock_server: str) -> None:
result = _run(mock_server, ["attach", "FOO-6", "/no/such/file.txt"])
assert result.returncode != 0
assert "not found" in result.stderr
def test_missing_file_arg(self, mock_server: str) -> None:
result = _run(mock_server, ["attach", "FOO-6"])
assert result.returncode != 0
assert "file path" in result.stderr
# ---------------------------------------------------------------------------
# auth requirement for writes
# ---------------------------------------------------------------------------
class TestAuth:
def test_comment_requires_token(self, mock_server: str, tmp_path: Path) -> None:
body_file = tmp_path / "c.txt"
body_file.write_text("x")
result = _run(
mock_server, ["comment", "FOO-1", "--body-file", str(body_file)], token=""
)
assert result.returncode != 0
assert "JIRA_API_TOKEN" in result.stderr
def test_assign_requires_token(self, mock_server: str) -> None:
result = _run(mock_server, ["assign", "FOO-1", "user"], token="")
assert result.returncode != 0
assert "JIRA_API_TOKEN" in result.stderr
def test_basic_auth_header(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-1/assignee"] = (204, None)
result = _run(
mock_server, ["assign", "FOO-1", "jdoe"], token="dGVzdDp0b2tlbg=="
)
assert result.returncode == 0
req = _log.requests[-1]
assert req["headers"]["Authorization"] == "Basic dGVzdDp0b2tlbg=="
def test_bearer_auth_scheme(self, mock_server: str) -> None:
_canned_responses["PUT /rest/api/2/issue/FOO-1/assignee"] = (204, None)
result = _run(
mock_server,
["assign", "FOO-1", "jdoe"],
token="my-pat-token",
env_extra={"JIRA_AUTH_SCHEME": "Bearer"},
)
assert result.returncode == 0
req = _log.requests[-1]
assert req["headers"]["Authorization"] == "Bearer my-pat-token"
# ---------------------------------------------------------------------------
# unknown subcommand
# ---------------------------------------------------------------------------
class TestUnknown:
def test_unknown_subcommand(self, mock_server: str) -> None:
result = _run(mock_server, ["bogus"])
assert result.returncode != 0
assert "unknown subcommand" in result.stderr
def test_no_args(self, mock_server: str) -> None:
result = _run(mock_server, [])
assert result.returncode != 0