3131
3232
3333def _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
4338def _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