Skip to content

Commit 27596e0

Browse files
committed
Add support for updating Jira issue status
JIRA: RHELWF-13506 Assisted-by: Opus 4.6
1 parent b982a8f commit 27596e0

6 files changed

Lines changed: 523 additions & 2 deletions

File tree

src/retasc/jira_client.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from functools import cached_property
3+
from unittest.mock import ANY
34

45
from atlassian import Jira
56
from opentelemetry import trace
@@ -132,6 +133,30 @@ def add_comment(self, issue_key: str, comment: str) -> dict:
132133

133134
raise RuntimeError(f"Unexpected response: {data!r}")
134135

136+
@tracer.start_as_current_span("JiraClient.get_issue_transitions")
137+
def get_issue_transitions(self, issue_key: str) -> list[dict]:
138+
"""
139+
Get available transitions for a Jira issue.
140+
141+
:param issue_key: The key of the Jira issue.
142+
:return: list of dicts with keys: name, id, to
143+
"""
144+
data = self.jira.get_issue_transitions(issue_key)
145+
if isinstance(data, list):
146+
return data
147+
148+
raise RuntimeError(f"Unexpected response: {data!r}")
149+
150+
@tracer.start_as_current_span("JiraClient.transition_issue")
151+
def transition_issue(self, issue_key: str, transition_id: str) -> None:
152+
"""
153+
Transition a Jira issue using a transition ID.
154+
155+
:param issue_key: The key of the Jira issue.
156+
:param transition_id: The ID of the transition to execute.
157+
"""
158+
self.jira.set_issue_status_by_transition_id(issue_key, transition_id)
159+
135160
@tracer.start_as_current_span("JiraClient.get_issue_comments")
136161
def get_issue_comments(self, issue_key: str) -> dict:
137162
"""
@@ -162,6 +187,14 @@ def add_comment(self, issue_key: str, comment: str) -> dict:
162187
# Skip adding comments in dry-run mode and return dummy data.
163188
return {"id": "1", "body": comment}
164189

190+
def get_issue_transitions(self, issue_key: str) -> list[dict]:
191+
# Return a catch-all transition so status updates never fail in dry-run.
192+
return [{"id": "0", "name": "Dry Run", "to": ANY}]
193+
194+
def transition_issue(self, issue_key: str, transition_id: str) -> None:
195+
# Skip transitioning issues in dry-run mode.
196+
pass
197+
165198
def get_issue_comments(self, issue_key: str) -> dict:
166199
# Skip fetching comments in dry-run mode and return empty list.
167200
return {"comments": []}

src/retasc/models/prerequisites/jira_issue.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pydantic import Field
1010

1111
from retasc.models.prerequisites.exceptions import PrerequisiteUpdateStateError
12+
from retasc.models.prerequisites.jira_status import update_issue_status
1213
from retasc.models.release_rule_state import ReleaseRuleState
1314
from retasc.utils import to_comma_separated
1415
from retasc.yaml import yaml
@@ -29,7 +30,9 @@
2930
' relative to the "jira_template_path" configuration.'
3031
)
3132
FIELDS_DESCRIPTION = "Jira fields, override fields in the template"
32-
JIRA_REQUIRED_FIELDS = frozenset(["labels", "resolution"])
33+
JIRA_REQUIRED_FIELDS = frozenset(
34+
["issuetype", "labels", "project", "resolution", "status"]
35+
)
3336

3437

3538
def _is_resolved(issue: dict) -> bool:
@@ -219,6 +222,25 @@ class JiraIssueTemplate(PrerequisiteBase):
219222
"and a comment with the same text does not exist already."
220223
),
221224
)
225+
status: str | None = Field(
226+
default=None,
227+
description=(
228+
"Optional Jinja2 template for the desired Jira issue status. "
229+
"If set, the issue will be transitioned to this status."
230+
),
231+
)
232+
transitions: list[str] = Field(
233+
default_factory=list,
234+
description=(
235+
"Ordered list of intermediate statuses to try when the target"
236+
" status is not directly reachable. At each step, ReTaSC first"
237+
" attempts a direct transition to the target status. If that is"
238+
" not available, it picks the latest (rightmost) status from the"
239+
" remaining intermediates that is available as a Jira transition,"
240+
" executes it, and repeats. Entries at or before the issue's"
241+
" current status are skipped automatically."
242+
),
243+
)
222244

223245
def _update_issue_data(
224246
self,
@@ -267,6 +289,9 @@ def _update_issue(
267289
if self.comment is not None:
268290
_add_issue_comment(issue["key"], self.comment, context)
269291

292+
if self.status is not None:
293+
update_issue_status(issue, self.status, self.transitions, context)
294+
270295
issue["fields"] = {
271296
context.config.from_jira_field_name(f): v
272297
for f, v in issue["fields"].items()
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
import logging
3+
4+
from retasc.models.prerequisites.exceptions import PrerequisiteUpdateStateError
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
def _find_transition(transitions: list[dict], status: str) -> dict | None:
10+
"""Find a transition targeting the given status."""
11+
return next((t for t in transitions if t["to"] == status), None)
12+
13+
14+
def _find_next_transition(
15+
available: list[dict],
16+
desired_status: str,
17+
remaining: list[str],
18+
) -> tuple[dict | None, list[str]]:
19+
"""
20+
Pick the next transition to execute.
21+
22+
Tries the target status first; if unavailable, picks the latest
23+
available intermediate from remaining (searching from the end).
24+
25+
Returns a (transition, new_remaining) tuple, where new_remaining is
26+
the slice of the list after the chosen intermediate (or empty when
27+
the target status is reached directly). transition is None when no
28+
suitable transition is available.
29+
"""
30+
transition = _find_transition(available, desired_status)
31+
if transition is not None:
32+
return transition, []
33+
34+
for i, status in enumerate(reversed(remaining)):
35+
transition = _find_transition(available, status)
36+
if transition is not None:
37+
return transition, remaining[len(remaining) - i :]
38+
39+
return None, remaining
40+
41+
42+
def update_issue_status(
43+
issue: dict,
44+
status_template: str,
45+
transitions: list[str],
46+
context,
47+
) -> None:
48+
"""
49+
Transition a Jira issue to the desired status if needed.
50+
51+
At each step the algorithm first attempts a direct transition to the
52+
target status. If that is not available, it picks the latest
53+
(rightmost) status from the remaining intermediates that is offered
54+
as a Jira transition, executes it, and repeats. Entries at or
55+
before the issue's current status are skipped automatically.
56+
57+
:param issue: The Jira issue dict.
58+
:param status_template: Jinja2 template for the desired status.
59+
:param transitions: Ordered list of intermediate statuses to try
60+
when the target is not directly reachable.
61+
:param context: The execution context.
62+
:raises PrerequisiteUpdateStateError: If the desired status is unreachable.
63+
"""
64+
desired_status = context.template.render(status_template)
65+
current_status = issue["fields"].get("status", {}).get("name", "")
66+
remaining = (
67+
transitions[transitions.index(current_status) + 1 :]
68+
if current_status in transitions
69+
else transitions
70+
)
71+
72+
visited = [current_status]
73+
while current_status != desired_status:
74+
available = context.jira.get_issue_transitions(issue["key"])
75+
transition, remaining = _find_next_transition(
76+
available, desired_status, remaining
77+
)
78+
if transition is None:
79+
available_names = [t["to"] for t in available]
80+
raise PrerequisiteUpdateStateError(
81+
f"Cannot transition issue {issue['key']} to {desired_status!r};"
82+
f" available transitions: {available_names}"
83+
)
84+
85+
current_status = transition["to"]
86+
visited.append(current_status)
87+
logger.info(
88+
"Transitioning %r to %r via %r",
89+
issue["key"],
90+
current_status,
91+
transition["name"],
92+
)
93+
context.jira.transition_issue(issue["key"], transition["id"])
94+
95+
context.report.set("status_transitions", visited)

tests/test_jira_client.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,50 @@ def test_unexpected_response_get_issue_comments(jira_api, requests_mock):
170170
requests_mock.get(f"{JIRA_URL}/rest/api/2/issue/{ISSUE_KEY}/comment", json=[])
171171
with raises(RuntimeError, match=r"Unexpected response: \[\]"):
172172
jira_api.get_issue_comments(ISSUE_KEY)
173+
174+
175+
TRANSITIONS_URL = f"{JIRA_URL}/rest/api/2/issue/{ISSUE_KEY}/transitions"
176+
177+
TRANSITIONS_RESPONSE = {
178+
"transitions": [
179+
{"id": "11", "name": "Start Progress", "to": {"name": "In Progress"}},
180+
{"id": "21", "name": "Close", "to": {"name": "Closed"}},
181+
]
182+
}
183+
184+
185+
def test_get_issue_transitions(jira_api, requests_mock):
186+
requests_mock.get(TRANSITIONS_URL, json=TRANSITIONS_RESPONSE)
187+
result = jira_api.get_issue_transitions(ISSUE_KEY)
188+
assert result == [
189+
{"name": "Start Progress", "id": 11, "to": "In Progress"},
190+
{"name": "Close", "id": 21, "to": "Closed"},
191+
]
192+
193+
194+
def test_get_issue_transitions_dryrun(dryrun_jira_api, requests_mock):
195+
result = dryrun_jira_api.get_issue_transitions(ISSUE_KEY)
196+
assert len(result) == 1
197+
assert result[0]["to"] == "any status matches"
198+
assert len(requests_mock.request_history) == 0
199+
200+
201+
def test_transition_issue(jira_api, requests_mock):
202+
"""Transition issue by transition ID."""
203+
requests_mock.post(TRANSITIONS_URL, status_code=204)
204+
jira_api.transition_issue(ISSUE_KEY, "21")
205+
posts = [r for r in requests_mock.request_history if r.method == "POST"]
206+
assert len(posts) == 1
207+
# atlassian library converts string ID to int
208+
assert posts[0].json() == {"transition": {"id": "21"}}
209+
210+
211+
def test_transition_issue_dryrun(dryrun_jira_api, requests_mock):
212+
dryrun_jira_api.transition_issue(ISSUE_KEY, "21")
213+
assert len(requests_mock.request_history) == 0
214+
215+
216+
def test_unexpected_response_get_issue_transitions(jira_api):
217+
with patch.object(jira_api.jira, "get_issue_transitions", return_value="bad"):
218+
with raises(RuntimeError, match=r"Unexpected response"):
219+
jira_api.get_issue_transitions(ISSUE_KEY)

0 commit comments

Comments
 (0)