Skip to content

Commit 4c6ef71

Browse files
fix(dso-7nos): add AcliClient class + migrate to Go ACLI v1.3+ syntax
Root cause: bridge-inbound.py expects an AcliClient class with search_issues/get_server_info/get_comments/set_relationship methods, but acli-integration.py only had standalone functions with legacy Java ACLI --action syntax. Changes: - Add AcliClient class with credential injection via subprocess env - search_issues: caches full --paginate results, slices by start_at/max_results - get_server_info: connectivity check + UTC timezone (Jira Cloud invariant) - set_relationship: raises on failure (no silent error swallowing) - Migrate all standalone functions from --action syntax to Go subcommands (jira workitem create/edit/view/comment/search) - Remove obsolete JAVA_TOOL_OPTIONS timezone injection from _build_env Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bc822f0 commit 4c6ef71

File tree

2 files changed

+186
-94
lines changed

2 files changed

+186
-94
lines changed

.tickets/.sync-state.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,8 @@
449449
"last_synced": "2026-03-19T18:38:35Z",
450450
"local_hash": "14c516947a151a3db8bdec4010e2fd6e"
451451
},
452-
"last_pull_timestamp": "2026-03-23T04:18:28Z",
453-
"last_sync_commit": "56a55e69cc9402da5aa9c9b163da2c07aaf2022f",
452+
"last_pull_timestamp": "2026-03-23T04:29:36Z",
453+
"last_sync_commit": "bc822f030eb394ef13e5f0bd21e64707e0885fcf",
454454
"w21-5cqr": {
455455
"jira_hash": "bce29d76f01c58613ee99cb1dd03920d",
456456
"jira_key": "DIG-61",

plugins/dso/scripts/acli-integration.py

Lines changed: 184 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,8 @@
3131

3232

3333
def _build_env() -> dict[str, str]:
34-
"""Build subprocess environment with ACLI JVM timezone flag."""
35-
env = os.environ.copy()
36-
java_opts = env.get("JAVA_TOOL_OPTIONS", "")
37-
tz_flag = "-Duser.timezone=UTC"
38-
if tz_flag not in java_opts:
39-
env["JAVA_TOOL_OPTIONS"] = f"{java_opts} {tz_flag}".strip()
40-
return env
34+
"""Build subprocess environment for ACLI."""
35+
return os.environ.copy()
4136

4237

4338
def _run_acli(
@@ -96,31 +91,18 @@ def create_issue(
9691
acli_cmd: list[str] | None = None,
9792
**kwargs: Any,
9893
) -> dict[str, Any]:
99-
"""Create a Jira issue via ACLI and verify it exists.
100-
101-
Args:
102-
project: Jira project key (e.g. "PROJ").
103-
issue_type: Issue type (e.g. "Task", "Story").
104-
summary: Issue summary text.
105-
acli_cmd: Override the ACLI base command (for testing).
106-
**kwargs: Additional fields (currently unused).
107-
108-
Returns:
109-
dict with the created issue data (including 'key').
110-
111-
Raises:
112-
subprocess.CalledProcessError: If ACLI fails after retries.
113-
RuntimeError: If verify-after-create fails (issue not found).
114-
"""
94+
"""Create a Jira issue via ACLI and verify it exists."""
11595
cmd = [
116-
"--action",
117-
"createIssue",
96+
"jira",
97+
"workitem",
98+
"create",
11899
"--project",
119100
project,
120101
"--type",
121102
issue_type,
122103
"--summary",
123104
summary,
105+
"--json",
124106
]
125107
result = _run_acli(cmd, acli_cmd=acli_cmd)
126108
created = json.loads(result.stdout)
@@ -130,7 +112,6 @@ def create_issue(
130112
msg = f"ACLI create returned no key: {created}"
131113
raise RuntimeError(msg)
132114

133-
# Verify-after-create: confirm the issue exists
134115
verified = get_issue(jira_key=jira_key, acli_cmd=acli_cmd)
135116
if not verified:
136117
msg = f"Verify-after-create failed: issue {jira_key} not found"
@@ -145,24 +126,14 @@ def update_issue(
145126
acli_cmd: list[str] | None = None,
146127
**kwargs: Any,
147128
) -> dict[str, Any]:
148-
"""Update a Jira issue via ACLI.
149-
150-
Args:
151-
jira_key: Jira issue key (e.g. "PROJ-99").
152-
acli_cmd: Override the ACLI base command (for testing).
153-
**kwargs: Fields to update (e.g. status="In Progress").
154-
155-
Returns:
156-
dict with the updated issue data.
157-
158-
Raises:
159-
subprocess.CalledProcessError: If ACLI fails after retries.
160-
"""
129+
"""Update a Jira issue via ACLI."""
161130
cmd = [
162-
"--action",
163-
"updateIssue",
164-
"--issue",
131+
"jira",
132+
"workitem",
133+
"edit",
134+
"--key",
165135
jira_key,
136+
"--json",
166137
]
167138
for field, value in kwargs.items():
168139
cmd.extend([f"--{field}", str(value)])
@@ -176,23 +147,13 @@ def get_issue(
176147
*,
177148
acli_cmd: list[str] | None = None,
178149
) -> dict[str, Any]:
179-
"""Get a Jira issue via ACLI.
180-
181-
Args:
182-
jira_key: Jira issue key (e.g. "PROJ-7").
183-
acli_cmd: Override the ACLI base command (for testing).
184-
185-
Returns:
186-
dict with issue data (key, summary, status, etc.).
187-
188-
Raises:
189-
subprocess.CalledProcessError: If ACLI fails after retries.
190-
"""
150+
"""Get a Jira issue via ACLI."""
191151
cmd = [
192-
"--action",
193-
"getIssue",
194-
"--issue",
152+
"jira",
153+
"workitem",
154+
"view",
195155
jira_key,
156+
"--json",
196157
]
197158
result = _run_acli(cmd, acli_cmd=acli_cmd)
198159
return json.loads(result.stdout)
@@ -204,26 +165,17 @@ def add_comment(
204165
*,
205166
acli_cmd: list[str] | None = None,
206167
) -> dict[str, Any]:
207-
"""Add a comment to a Jira issue via ACLI.
208-
209-
Args:
210-
jira_key: Jira issue key (e.g. "PROJ-42").
211-
body: Comment body text (passed unchanged, may include markers).
212-
acli_cmd: Override the ACLI base command (for testing).
213-
214-
Returns:
215-
dict with comment data (id, body, etc.).
216-
217-
Raises:
218-
subprocess.CalledProcessError: If ACLI fails after retries.
219-
"""
168+
"""Add a comment to a Jira issue via ACLI."""
220169
cmd = [
221-
"--action",
222-
"addComment",
223-
"--issue",
170+
"jira",
171+
"workitem",
172+
"comment",
173+
"create",
174+
"--key",
224175
jira_key,
225-
"--comment",
176+
"--body",
226177
body,
178+
"--json",
227179
]
228180
result = _run_acli(cmd, acli_cmd=acli_cmd)
229181
return json.loads(result.stdout)
@@ -234,26 +186,166 @@ def get_comments(
234186
*,
235187
acli_cmd: list[str] | None = None,
236188
) -> list[dict[str, Any]]:
237-
"""Get all comments on a Jira issue via ACLI.
238-
239-
Args:
240-
jira_key: Jira issue key (e.g. "PROJ-55").
241-
acli_cmd: Override the ACLI base command (for testing).
242-
243-
Returns:
244-
list of dicts, each with comment data (id, body, etc.).
245-
Returns empty list if no comments exist.
246-
247-
Raises:
248-
subprocess.CalledProcessError: If ACLI fails after retries.
249-
"""
189+
"""Get all comments on a Jira issue via ACLI."""
250190
cmd = [
251-
"--action",
252-
"getComments",
253-
"--issue",
191+
"jira",
192+
"workitem",
193+
"comment",
194+
"list",
195+
"--key",
254196
jira_key,
197+
"--json",
255198
]
256199
result = _run_acli(cmd, acli_cmd=acli_cmd)
257-
# ACLI may output `null` for issues with no comments; `or []` ensures we always
258-
# return a list as documented in the docstring.
259200
return json.loads(result.stdout) or []
201+
202+
203+
# ---------------------------------------------------------------------------
204+
# AcliClient class — used by bridge-inbound.py and bridge-outbound.py
205+
# ---------------------------------------------------------------------------
206+
207+
208+
class AcliClient:
209+
"""Client wrapping ACLI Go binary for Jira operations.
210+
211+
Provides the method interface expected by bridge-inbound.py:
212+
search_issues, get_server_info, get_comments, set_relationship.
213+
214+
Credentials are injected into the subprocess environment on each call
215+
so ACLI can authenticate without requiring prior ``acli auth`` setup.
216+
"""
217+
218+
def __init__(
219+
self,
220+
jira_url: str,
221+
user: str,
222+
api_token: str,
223+
*,
224+
acli_cmd: list[str] | None = None,
225+
) -> None:
226+
self.jira_url = jira_url
227+
self.user = user
228+
self.api_token = api_token
229+
self._acli_cmd = acli_cmd
230+
231+
def _run(self, cmd: list[str]) -> subprocess.CompletedProcess[str]:
232+
"""Run an ACLI command with credentials injected into env."""
233+
base = self._acli_cmd if self._acli_cmd is not None else _DEFAULT_ACLI_CMD
234+
full_cmd = base + cmd
235+
env = _build_env()
236+
env["JIRA_URL"] = self.jira_url
237+
env["JIRA_USER"] = self.user
238+
env["JIRA_API_TOKEN"] = self.api_token
239+
240+
last_error: subprocess.CalledProcessError | None = None
241+
for attempt in range(_MAX_ATTEMPTS):
242+
try:
243+
return subprocess.run(
244+
full_cmd,
245+
capture_output=True,
246+
text=True,
247+
check=True,
248+
env=env,
249+
)
250+
except subprocess.CalledProcessError as exc:
251+
last_error = exc
252+
if exc.returncode == _AUTH_FAILURE_CODE:
253+
raise
254+
if attempt < _MAX_ATTEMPTS - 1:
255+
delay = 2 ** (attempt + 1)
256+
time.sleep(delay)
257+
258+
assert last_error is not None
259+
raise last_error
260+
261+
def search_issues(
262+
self,
263+
jql: str,
264+
start_at: int = 0,
265+
max_results: int = 50,
266+
) -> list[dict[str, Any]]:
267+
"""Search Jira issues via JQL, returning a page slice.
268+
269+
ACLI Go has no offset flag, so --paginate fetches all results in one
270+
call. Results are cached per-JQL to avoid redundant fetches when the
271+
caller paginates. Returns a slice of ``[start_at:start_at+max_results]``
272+
to satisfy the bridge's pagination loop contract.
273+
"""
274+
# Cache the full result set for this JQL to avoid re-fetching
275+
if not hasattr(self, "_search_cache"):
276+
self._search_cache: dict[str, list[dict[str, Any]]] = {}
277+
278+
if jql not in self._search_cache:
279+
cmd = [
280+
"jira",
281+
"workitem",
282+
"search",
283+
"--jql",
284+
jql,
285+
"--paginate",
286+
"--json",
287+
]
288+
result = self._run(cmd)
289+
parsed = json.loads(result.stdout)
290+
if isinstance(parsed, list):
291+
all_issues = parsed
292+
elif isinstance(parsed, dict) and "issues" in parsed:
293+
all_issues = parsed["issues"]
294+
else:
295+
all_issues = []
296+
self._search_cache[jql] = all_issues
297+
298+
all_issues = self._search_cache[jql]
299+
return all_issues[start_at : start_at + max_results]
300+
301+
def get_server_info(self) -> dict[str, Any]:
302+
"""Get Jira server info for timezone verification.
303+
304+
ACLI Go has no direct server-info command. Returns timeZone=UTC
305+
because Jira Cloud always stores timestamps in UTC, and the ACLI Go
306+
binary does not apply JVM timezone transformations like the legacy
307+
Java ACLI did.
308+
"""
309+
# Connectivity check — verify ACLI can reach Jira
310+
cmd = ["jira", "project", "list", "--json"]
311+
self._run(cmd) # raises on auth/network failure
312+
return {"timeZone": "UTC", "serverTitle": "Jira Cloud"}
313+
314+
def get_comments(self, jira_key: str) -> list[dict[str, Any]]:
315+
"""Get all comments on a Jira issue."""
316+
cmd = [
317+
"jira",
318+
"workitem",
319+
"comment",
320+
"list",
321+
"--key",
322+
jira_key,
323+
"--json",
324+
]
325+
result = self._run(cmd)
326+
return json.loads(result.stdout) or []
327+
328+
def set_relationship(
329+
self,
330+
from_key: str,
331+
to_key: str,
332+
link_type: str = "Blocks",
333+
) -> dict[str, Any]:
334+
"""Create a link between two Jira issues.
335+
336+
Raises subprocess.CalledProcessError on ACLI failure.
337+
"""
338+
cmd = [
339+
"jira",
340+
"workitem",
341+
"link",
342+
"create",
343+
"--out",
344+
from_key,
345+
"--in",
346+
to_key,
347+
"--type",
348+
link_type,
349+
]
350+
self._run(cmd) # raises on failure — no silent swallowing
351+
return {"status": "created", "from": from_key, "to": to_key}

0 commit comments

Comments
 (0)