Skip to content

Commit

Permalink
Introduce a timesensitive rank rule
Browse files Browse the repository at this point in the history
  • Loading branch information
ralphbean committed Mar 7, 2024
1 parent 3138245 commit e380f4a
Show file tree
Hide file tree
Showing 6 changed files with 323 additions and 10 deletions.
2 changes: 1 addition & 1 deletion config/konflux.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ team_automation:
Feature:
# collector: get_issues
group_rules:
- check_rank
- check_timesensitive_rank
Epic:
collector: get_child_issues
rules:
Expand Down
2 changes: 2 additions & 0 deletions src/rules/team/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,4 +15,5 @@
check_quarter_label,
check_rank,
check_target_dates,
check_timesensitive_rank,
]
227 changes: 227 additions & 0 deletions src/rules/team/timesensitive_rank.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 36 additions & 3 deletions src/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
)
13 changes: 7 additions & 6 deletions src/tests/test_rank.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
50 changes: 50 additions & 0 deletions src/tests/test_timesensitive_rank.py
Original file line number Diff line number Diff line change
@@ -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"

0 comments on commit e380f4a

Please sign in to comment.