From 41f050e15cc6928f31f5dbb1212bf3d366728444 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 7 Mar 2024 17:16:15 -0500 Subject: [PATCH] Introduce a timesensitive rank rule --- config/konflux.yaml | 2 +- src/rules/team/__init__.py | 2 + src/rules/team/timesensitive_rank.py | 227 +++++++++++++++++++++++++++ src/tests/conftest.py | 39 ++++- src/tests/test_rank.py | 13 +- src/tests/test_timesensitive_rank.py | 50 ++++++ 6 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 src/rules/team/timesensitive_rank.py create mode 100644 src/tests/test_timesensitive_rank.py diff --git a/config/konflux.yaml b/config/konflux.yaml index 8bd48fb..55bac80 100644 --- a/config/konflux.yaml +++ b/config/konflux.yaml @@ -19,7 +19,7 @@ team_automation: Feature: # collector: get_issues group_rules: - - check_rank + - check_timesensitive_rank Epic: collector: get_child_issues rules: diff --git a/src/rules/team/__init__.py b/src/rules/team/__init__.py index 324599c..010e60e 100644 --- a/src/rules/team/__init__.py +++ b/src/rules/team/__init__.py @@ -5,6 +5,7 @@ from .quarter_label import check_quarter_label from .rank import check_rank from .target_dates import check_target_dates +from .timesensitive_rank import check_timesensitive_rank __all__ = [ check_due_date, @@ -14,4 +15,5 @@ check_quarter_label, check_rank, check_target_dates, + check_timesensitive_rank, ] diff --git a/src/rules/team/timesensitive_rank.py b/src/rules/team/timesensitive_rank.py new file mode 100644 index 0000000..ff59611 --- /dev/null +++ b/src/rules/team/timesensitive_rank.py @@ -0,0 +1,227 @@ +""" +The reranking here works much the same as in the basic rank check, +except here issues with an upcoming duedate are sorted into a +special "timesensitive" block that is ranked to the top of the list. +""" + +import datetime + +import jira + + +def check_timesensitive_rank( + issues: list[jira.resources.Issue], + context: dict, + dry_run: bool, +) -> None: + """Rerank all issues""" + jira_client = context["jira_client"] + + # Get blocks and current ranking + blocks = Blocks(issues) + old_ranking = blocks.get_issues() + + # Sort blocks and generate new ranking + blocks.sort() + new_ranking = blocks.get_issues() + + # Apply new ranking + _set_rank(jira_client, old_ranking, new_ranking, dry_run) + + +def _set_rank( + jira_client: jira.client.JIRA, + old_ranking: list[jira.resources.Issue], + new_ranking: list[jira.resources.Issue], + dry_run: bool, +) -> None: + print("\n### Reranking issues") + previous_issue = None + total = len(new_ranking) + rerank = False + + for index, issue in enumerate(new_ranking): + if issue != old_ranking[index]: + # Once we start reranking, we don't stop. + # This should avoid any edge case, but it's slow. + rerank = True + if rerank and previous_issue is not None: + if dry_run: + print(f" > {issue.key}") + else: + jira_client.rank(issue=issue.key, prev_issue=previous_issue.key) + previous_issue = issue + print(f"\r{100 * (index + 1) // total}%", end="", flush=True) + + +class Block: + """A block groups a parent and all its children issues""" + + def __init__(self, parent): + self.parent_issue = parent + self.issues = [] + + def yield_issues(self): + yield from self.issues + + def parent_is_inprogress(self): + if self.parent_issue is None: + return False + return self.parent_issue.fields.status.statusCategory.name == "In Progress" + + @property + def rank(self): + rank_field_id = self.issues[0].raw["Context"]["Field Ids"]["Rank"] + if self.parent_issue: + return getattr(self.parent_issue.fields, rank_field_id) + + def __str__(self) -> str: + p_key = self.parent_issue.key if self.parent_issue else None + i_keys = [i.key for i in self.issues] + return f"{p_key}: {', '.join(i_keys)}" + + def claims(self, issue) -> bool: + parent_issue = issue.raw["Context"]["Related Issues"]["Parent"] + return self.parent_issue == parent_issue + + +class TimeSensitiveBlock(Block): + """A special-case block that gets ranked to the top""" + + def yield_issues(self): + if not self.issues: + return + duedate_field_id = self.issues[0].raw["Context"]["Field Ids"]["Due Date"] + duedate = lambda issue: getattr(issue.fields, duedate_field_id) + yield from sorted(self.issues, key=duedate) + + @property + def rank(self): + return float("-inf") + + def parent_is_inprogress(self): + return False + + def claims(self, issue) -> bool: + return self._claims(issue) + + @staticmethod + def _claims(issue) -> bool: + duedate_field_id = issue.raw["Context"]["Field Ids"]["Due Date"] + critical_deadline = ( + datetime.datetime.today() + datetime.timedelta(days=30 * 6) + ).strftime("%Y-%m-%d") + duedate = getattr(issue.fields, duedate_field_id) + return duedate and duedate < critical_deadline + + +class Blocks(list): + def __init__(self, issues: list[jira.resources.Issue]) -> None: + self.blocks = [TimeSensitiveBlock(None)] + for issue in issues: + self.add_issue(issue) + + def add_issue(self, issue: jira.resources.Issue) -> None: + """Add an issue to the right block""" + block = None + addBlock = True + parent_issue = issue.raw["Context"]["Related Issues"]["Parent"] + if parent_issue is None: + block = Block(None) + self.blocks.append(block) + else: + for block in self.blocks: + if block.claims(issue): + addBlock = False + break + if addBlock: + parent_issue = issue.raw["Context"]["Related Issues"]["Parent"] + block = Block(parent_issue) + self.blocks.append(block) + block.issues.append(issue) + + def get_issues(self) -> list[jira.resources.Issue]: + """Return a flat list of issues, in the order of appearance in the blocks""" + issues = [] + for block in self.blocks: + if ( + block.parent_issue is not None + and block.parent_issue.fields.project.key + == block.issues[0].fields.project.key + ): + issues.append(block.parent_issue) + for issue in block.yield_issues(): + issues.append(issue) + return issues + + def sort(self): + self._sort_by_project_rank() + self._sort_by_status() + self._prioritize_timesensitive_block() + + def _sort_by_project_rank(self): + """Rerank blocks based on the block's project rank. + Blocks are switch around, but a block can only be switched + with a block of the same parent project. + """ + + # For each project, generate a ranked list of issues + per_project_ranking = {None: []} + for block in self.blocks: + parent_issue = block.parent_issue + if parent_issue is None: + project_key = None + else: + project_key = parent_issue.fields.project.key + project_ranking = per_project_ranking.get(project_key, []) + + if block.rank is None: + per_project_ranking[None].append(block) + continue + + for index, i_block in enumerate(project_ranking): + if block.rank < i_block.rank: + project_ranking.insert(index, block) + break + if block not in project_ranking: + project_ranking.append(block) + + per_project_ranking[project_key] = project_ranking + + # Go through all the blocks, selecting the highest issue for the + # given project. + ranked_blocks = [] + for block in self.blocks: + project = None + if block.parent_issue: + project = block.parent_issue.fields.project.key + ranked_blocks.append(per_project_ranking[project].pop(0)) + + self.blocks = ranked_blocks + + def _sort_by_status(self): + """Issues that are actively being worked on are more important + than issues marked as important but for which no work is on-going.""" + inprogress = [] + new = [] + + for block in self.blocks: + if block.parent_is_inprogress(): + inprogress.append(block) + else: + new.append(block) + + self.blocks = inprogress + new + + def _prioritize_timesensitive_block(self): + """Issues that are time sensitive rise to the top of the list.""" + timesensitive = [] + other = [] + + for block in self.blocks: + if type(block) is TimeSensitiveBlock: + timesensitive.append(block) + else: + other.append(block) + + self.blocks = timesensitive + other diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 1ce9c7b..9614fab 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,13 +1,16 @@ +import datetime + import mock import pytest class MockIssue: - def __init__(self, idx, project, parent, rank): + def __init__(self, idx, project, parent, rank, duedate=None): raw = {} raw["Context"] = {} raw["Context"]["Field Ids"] = {} raw["Context"]["Field Ids"]["Rank"] = "rank" + raw["Context"]["Field Ids"]["Due Date"] = "duedate" raw["Context"]["Related Issues"] = {} raw["Context"]["Related Issues"]["Parent"] = parent @@ -17,18 +20,48 @@ def __init__(self, idx, project, parent, rank): self.fields = mock.MagicMock() self.fields.project.key = project self.fields.rank = rank + self.fields.duedate = duedate def __repr__(self): - return f"<{type(self).__name__} {self.fields.project.key}-{self.idx}>" + return f"<{type(self).__name__} {self.fields.project.key}-{self.idx}({self.fields.rank})>" @pytest.fixture def issues(): project = "TESTPROJECT" + child0 = MockIssue("child0", project, None, 0) parent1 = MockIssue("parent1", project, None, 1) child1 = MockIssue("child1", project, parent1, 2) parent2 = MockIssue("parent2", project, None, 3) child2 = MockIssue("child2", project, parent2, 4) parent3 = MockIssue("parent3", project, None, 5) child3 = MockIssue("child3", project, parent3, 6) - return dict(child1=child1, child2=child2, child3=child3) + child4 = MockIssue("child4", project, None, 7) + return dict( + child0=child0, child1=child1, child2=child2, child3=child3, child4=child4 + ) + + +@pytest.fixture +def issues_with_due_dates(): + project = "TESTPROJECT" + parent1 = MockIssue("parent1", project, None, 1) + child1 = MockIssue("child1", project, parent1, 2) + parent2 = MockIssue("parent2", project, None, 3) + child2 = MockIssue("child2", project, parent2, 4) + fmt = "%Y-%m-%d" + duedate = (datetime.datetime.today() + datetime.timedelta(days=30 * 7)).strftime( + fmt + ) + child3 = MockIssue("child3", project, parent2, 5, duedate=duedate) + duedate = (datetime.datetime.today() + datetime.timedelta(days=30 * 2)).strftime( + fmt + ) + child4 = MockIssue("child4", project, parent2, 6, duedate=duedate) + duedate = (datetime.datetime.today() + datetime.timedelta(days=30 * 1)).strftime( + fmt + ) + child5 = MockIssue("child5", project, parent2, 7, duedate=duedate) + return dict( + child1=child1, child2=child2, child3=child3, child4=child4, child5=child5 + ) diff --git a/src/tests/test_rank.py b/src/tests/test_rank.py index 038ae15..8c31049 100644 --- a/src/tests/test_rank.py +++ b/src/tests/test_rank.py @@ -16,9 +16,10 @@ def test_rank_single_move(issues): blocks.sort() new_ranking = blocks.get_issues() assert new_ranking != old_ranking - assert new_ranking[0].key == "parent3" - assert new_ranking[1].key == "child3" - assert new_ranking[2].key == "parent1" - assert new_ranking[3].key == "child1" - assert new_ranking[4].key == "parent2" - assert new_ranking[5].key == "child2" + assert new_ranking[0].key == "child0" # Highly ranked orphan child is maintained + assert new_ranking[1].key == "parent3" + assert new_ranking[2].key == "child3" + assert new_ranking[3].key == "parent1" + assert new_ranking[4].key == "child1" + assert new_ranking[5].key == "parent2" + assert new_ranking[6].key == "child2" diff --git a/src/tests/test_timesensitive_rank.py b/src/tests/test_timesensitive_rank.py new file mode 100644 index 0000000..69d8ea8 --- /dev/null +++ b/src/tests/test_timesensitive_rank.py @@ -0,0 +1,50 @@ +import operator as op + +import rules.team.timesensitive_rank + + +def test_rank_idempotence(issues): + issues = list(sorted(issues.values(), key=op.attrgetter("fields.rank"))) + issues = [issue for issue in issues if issue.key not in ("child0", "child4")] + blocks = rules.team.timesensitive_rank.Blocks(issues) + old_ranking = blocks.get_issues() + blocks.sort() + new_ranking = blocks.get_issues() + assert new_ranking == old_ranking + + +def test_rank_single_move(issues): + issues["child3"].raw["Context"]["Related Issues"]["Parent"].fields.rank = -1 + blocks = rules.team.timesensitive_rank.Blocks(list(issues.values())) + old_ranking = blocks.get_issues() + blocks.sort() + new_ranking = blocks.get_issues() + assert new_ranking != old_ranking + assert new_ranking[0].key == "child0" # Highly ranked orphan child is maintained + assert new_ranking[1].key == "parent3" + assert new_ranking[2].key == "child3" + assert new_ranking[3].key == "parent1" + assert new_ranking[4].key == "child1" + assert new_ranking[5].key == "parent2" + assert new_ranking[6].key == "child2" + assert new_ranking[7].key == "child4" + + +def test_rank_with_dates(issues_with_due_dates): + issues = issues_with_due_dates + issues["child3"].raw["Context"]["Related Issues"]["Parent"].fields.rank = -1 + blocks = rules.team.timesensitive_rank.Blocks(list(issues.values())) + old_ranking = blocks.get_issues() + blocks.sort() + new_ranking = blocks.get_issues() + import pprint + + pprint.pprint(new_ranking) + assert new_ranking != old_ranking + assert new_ranking[0].key == "child5" + assert new_ranking[1].key == "child4" + assert new_ranking[2].key == "parent2" + assert new_ranking[3].key == "child2" + assert new_ranking[4].key == "child3" + assert new_ranking[5].key == "parent1" + assert new_ranking[6].key == "child1"