backlogops 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backlogops/__init__.py +87 -0
- backlogops/apply_format_rules.py +95 -0
- backlogops/available_teams.py +205 -0
- backlogops/available_teams_config.py +538 -0
- backlogops/available_teams_wizard.py +448 -0
- backlogops/backlog.py +465 -0
- backlogops/backlog_helpers.py +658 -0
- backlogops/backlog_releases.py +299 -0
- backlogops/backlog_releases_io.py +200 -0
- backlogops/console_yes_no_bridge.py +45 -0
- backlogops/date_ranges.py +59 -0
- backlogops/demo_backlog.py +129 -0
- backlogops/estimate_ready_date.py +379 -0
- backlogops/format_rules.py +73 -0
- backlogops/io_config.py +277 -0
- backlogops/key_list_io.py +227 -0
- backlogops/levels.py +165 -0
- backlogops/move_keys_first.py +166 -0
- backlogops/no_text_io.py +100 -0
- backlogops/order_by_dependencies.py +381 -0
- backlogops/person.py +25 -0
- backlogops/py.typed +0 -0
- backlogops/release_backlog_updates.py +239 -0
- backlogops/release_change_io.py +134 -0
- backlogops/releases.py +170 -0
- backlogops/table_create.py +47 -0
- backlogops/table_rows.py +125 -0
- backlogops/team.py +190 -0
- backlogops/work_hours.py +155 -0
- backlogops-0.1.dist-info/METADATA +238 -0
- backlogops-0.1.dist-info/RECORD +34 -0
- backlogops-0.1.dist-info/WHEEL +5 -0
- backlogops-0.1.dist-info/licenses/LICENSE.txt +22 -0
- backlogops-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""A demonstration backlog and releases for manual tests and examples.
|
|
3
|
+
|
|
4
|
+
The demo data has three level-2 items (epics), twenty level-1 items
|
|
5
|
+
(stories) and two level-0 items (tasks). The two tasks share the same
|
|
6
|
+
story as parent, and fifteen of the stories have an epic as parent. A few
|
|
7
|
+
dependencies are added between items. Two releases exist: ``Next`` with a
|
|
8
|
+
planned date one month ahead, and ``Later`` with no planned date. Five
|
|
9
|
+
items are assigned to ``Next`` and five to ``Later``; the rest have no
|
|
10
|
+
release. The items are returned in a deliberately mixed order, so the
|
|
11
|
+
backlog is neither dependency-ordered nor release-ordered, while still
|
|
12
|
+
passing all consistency checks.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
16
|
+
# MIT License
|
|
17
|
+
|
|
18
|
+
from calendar import monthrange
|
|
19
|
+
from datetime import date
|
|
20
|
+
from typing import Optional
|
|
21
|
+
from backlogops.backlog import BacklogItem, Status
|
|
22
|
+
from backlogops.backlog_releases import BacklogReleases
|
|
23
|
+
from backlogops.releases import Release
|
|
24
|
+
|
|
25
|
+
_POINTS = (1, 2, 3, 5, 8, 13)
|
|
26
|
+
"""Story point values cycled over the demo items."""
|
|
27
|
+
|
|
28
|
+
_STATUSES = (Status.TODO, Status.IN_PROGRESS)
|
|
29
|
+
"""Statuses cycled over the demo items."""
|
|
30
|
+
|
|
31
|
+
_NEXT_KEYS = ('E1', 'S2', 'S7', 'S16', 'T1')
|
|
32
|
+
"""Demo items delivered in the ``Next`` release."""
|
|
33
|
+
|
|
34
|
+
_LATER_KEYS = ('E2', 'S3', 'S9', 'S18', 'T2')
|
|
35
|
+
"""Demo items delivered in the ``Later`` release."""
|
|
36
|
+
|
|
37
|
+
_DEPENDENCIES = (
|
|
38
|
+
('S2', 'depends_on_f2s', 'S3'),
|
|
39
|
+
('S5', 'depends_on_s2s', 'S6'),
|
|
40
|
+
('S10', 'depends_on_f2f', 'S11'),
|
|
41
|
+
('T1', 'depends_on_f2s', 'T2'),
|
|
42
|
+
('E3', 'depends_on_f2f', 'E1'))
|
|
43
|
+
"""A few demo dependencies as (item key, dependency field, target key)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _make_item(key: str, level: int, title: str, index: int,
|
|
47
|
+
parent_key: Optional[str] = None) -> BacklogItem:
|
|
48
|
+
"""Build one demo backlog item with cycled points and status."""
|
|
49
|
+
return BacklogItem(key=key, level=level, title=title,
|
|
50
|
+
story_points=_POINTS[index % len(_POINTS)],
|
|
51
|
+
status=_STATUSES[index % len(_STATUSES)],
|
|
52
|
+
parent_key=parent_key)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _epics() -> list[BacklogItem]:
|
|
56
|
+
"""Return the three level-2 epics."""
|
|
57
|
+
return [_make_item(f'E{i + 1}', 2, f'Epic {i + 1}', i) for i in range(3)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _story_parent(index: int) -> Optional[str]:
|
|
61
|
+
"""Return the epic parent for a story, or None for the last five."""
|
|
62
|
+
return f'E{index % 3 + 1}' if index < 15 else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _stories() -> list[BacklogItem]:
|
|
66
|
+
"""Return the twenty level-1 stories, fifteen with an epic parent."""
|
|
67
|
+
return [_make_item(f'S{i + 1}', 1, f'Story {i + 1}', i, _story_parent(i))
|
|
68
|
+
for i in range(20)]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _tasks() -> list[BacklogItem]:
|
|
72
|
+
"""Return the two level-0 tasks, both children of story ``S1``."""
|
|
73
|
+
return [_make_item(f'T{i + 1}', 0, f'Task {i + 1}', i, 'S1')
|
|
74
|
+
for i in range(2)]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _apply_releases(by_key: dict[str, BacklogItem]) -> None:
|
|
78
|
+
"""Assign the ``Next`` and ``Later`` releases to five items each."""
|
|
79
|
+
for key in _NEXT_KEYS:
|
|
80
|
+
by_key[key].release = 'Next'
|
|
81
|
+
for key in _LATER_KEYS:
|
|
82
|
+
by_key[key].release = 'Later'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _apply_dependencies(by_key: dict[str, BacklogItem]) -> None:
|
|
86
|
+
"""Add the demo dependencies between items."""
|
|
87
|
+
for key, field_name, target in _DEPENDENCIES:
|
|
88
|
+
getattr(by_key[key], field_name).append(target)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _mixed_order(epics: list[BacklogItem], stories: list[BacklogItem],
|
|
92
|
+
tasks: list[BacklogItem]) -> list[BacklogItem]:
|
|
93
|
+
"""Interleave the items so they are neither level nor release sorted."""
|
|
94
|
+
items = list(stories)
|
|
95
|
+
items.insert(3, epics[0])
|
|
96
|
+
items.insert(9, tasks[0])
|
|
97
|
+
items.insert(14, epics[1])
|
|
98
|
+
items.insert(20, tasks[1])
|
|
99
|
+
items.insert(23, epics[2])
|
|
100
|
+
return items
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _one_month_ahead() -> date:
|
|
104
|
+
"""Return the date one calendar month after today."""
|
|
105
|
+
today = date.today()
|
|
106
|
+
month = today.month % 12 + 1
|
|
107
|
+
year = today.year + (1 if today.month == 12 else 0)
|
|
108
|
+
return date(year, month, min(today.day, monthrange(year, month)[1]))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_demo_backlog() -> BacklogReleases:
|
|
112
|
+
"""Return a demonstration backlog and its releases.
|
|
113
|
+
|
|
114
|
+
The returned data passes :meth:`BacklogReleases.check_consistency`.
|
|
115
|
+
It is useful for manual tests and for developers building
|
|
116
|
+
applications on top of this library.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A backlog with epics, stories and tasks, and the ``Next`` and
|
|
120
|
+
``Later`` releases.
|
|
121
|
+
"""
|
|
122
|
+
epics, stories, tasks = _epics(), _stories(), _tasks()
|
|
123
|
+
by_key = {item.key: item for item in epics + stories + tasks}
|
|
124
|
+
_apply_releases(by_key)
|
|
125
|
+
_apply_dependencies(by_key)
|
|
126
|
+
backlog = _mixed_order(epics, stories, tasks)
|
|
127
|
+
releases = [Release(name='Next', planned_date=_one_month_ahead()),
|
|
128
|
+
Release(name='Later')]
|
|
129
|
+
return BacklogReleases(backlog=backlog, releases=releases)
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Estimate the ready date of backlog items."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026, Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from datetime import date, timedelta
|
|
10
|
+
from typing import Optional, TextIO
|
|
11
|
+
from backlogops.backlog import Backlog, BacklogItem, Status
|
|
12
|
+
from backlogops.available_teams import AvailableTeams, membership_fte_on
|
|
13
|
+
from backlogops.person import Person
|
|
14
|
+
from backlogops.team import Team
|
|
15
|
+
from backlogops.work_hours import CompanyWorkHours, ExceptionWorkHours, WeekDay
|
|
16
|
+
|
|
17
|
+
_ONE_DAY = timedelta(days=1)
|
|
18
|
+
"""One calendar day, the step used when working through a schedule."""
|
|
19
|
+
|
|
20
|
+
_HORIZON = timedelta(days=366 * 100)
|
|
21
|
+
"""How far ahead work is followed before it counts as never finished."""
|
|
22
|
+
|
|
23
|
+
_EPSILON = 1e-9
|
|
24
|
+
"""Tolerance for treating accumulated story points as fully done."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _week_day(day: date) -> WeekDay:
|
|
28
|
+
"""Return the WeekDay value of a calendar day (Monday is first)."""
|
|
29
|
+
return WeekDay(day.weekday() + 1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _exception_on(exceptions: list[ExceptionWorkHours],
|
|
33
|
+
day: date) -> Optional[ExceptionWorkHours]:
|
|
34
|
+
"""Return the work-hours exception covering a day, or None.
|
|
35
|
+
|
|
36
|
+
The exceptions in one list do not overlap, so at most one of them
|
|
37
|
+
covers any given day.
|
|
38
|
+
"""
|
|
39
|
+
for exception in exceptions:
|
|
40
|
+
if exception.start_date <= day <= exception.end_date:
|
|
41
|
+
return exception
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _apply_exception(base: float, exception: ExceptionWorkHours) -> float:
|
|
46
|
+
"""Return the work hours after applying an exception to a baseline.
|
|
47
|
+
|
|
48
|
+
On a day that is closed in the baseline the exception only adds
|
|
49
|
+
hours when its new_work_days flag is set; otherwise the closed day
|
|
50
|
+
stays closed.
|
|
51
|
+
"""
|
|
52
|
+
if base > 0.0 or exception.new_work_days:
|
|
53
|
+
return exception.hours_per_day
|
|
54
|
+
return base
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _scheduled_hours(company: CompanyWorkHours, day: date) -> float:
|
|
58
|
+
"""Return the company work hours on a day, with company exceptions."""
|
|
59
|
+
base = company.work_hours.get(_week_day(day), 0.0)
|
|
60
|
+
exception = _exception_on(company.exceptions, day)
|
|
61
|
+
if exception is None:
|
|
62
|
+
return base
|
|
63
|
+
return _apply_exception(base, exception)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _person_hours(person: Person, company: CompanyWorkHours,
|
|
67
|
+
day: date) -> float:
|
|
68
|
+
"""Return the work hours of one person on a day.
|
|
69
|
+
|
|
70
|
+
The company schedule, including the company exceptions, is the
|
|
71
|
+
person's baseline. A personal work-hours exception overrides that
|
|
72
|
+
baseline, modelling vacation, part-time or ordered over-time.
|
|
73
|
+
"""
|
|
74
|
+
base = _scheduled_hours(company, day)
|
|
75
|
+
exception = _exception_on(person.exceptions, day)
|
|
76
|
+
if exception is None:
|
|
77
|
+
return base
|
|
78
|
+
return _apply_exception(base, exception)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, order=True)
|
|
82
|
+
class _Cursor:
|
|
83
|
+
"""A team's progress: the day it works and points spent that day.
|
|
84
|
+
|
|
85
|
+
Keeping the points already spent on the current day lets a team
|
|
86
|
+
finish several small items on the same day instead of losing the
|
|
87
|
+
rest of the day to one item. Cursors order by day and then by spent
|
|
88
|
+
points, so a smaller cursor is the team that is free earlier.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
day: date
|
|
92
|
+
used: float
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class _Workforce:
|
|
97
|
+
"""The workforce together with the length of a full work day.
|
|
98
|
+
|
|
99
|
+
The standard work day is the longest day in the company weekly
|
|
100
|
+
schedule. It is the reference a person's actual work hours are
|
|
101
|
+
measured against, so that a normal full day counts as one full-time
|
|
102
|
+
equivalent, a half day as one half, and ordered over-time as more.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
teams: AvailableTeams
|
|
106
|
+
standard_hours: float
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def create(teams: AvailableTeams) -> '_Workforce':
|
|
110
|
+
"""Create a workforce, deriving the standard full work day."""
|
|
111
|
+
hours = teams.company_work_hours.work_hours.values()
|
|
112
|
+
return _Workforce(teams, max(hours, default=0.0))
|
|
113
|
+
|
|
114
|
+
def _team_fte(self, team: Team, day: date) -> float:
|
|
115
|
+
"""Return the team's effective full-time equivalent on a day.
|
|
116
|
+
|
|
117
|
+
Each member contributes the full-time equivalent it gives the
|
|
118
|
+
team that day, scaled by how much of a standard work day the
|
|
119
|
+
person actually works. Weekends, holidays and vacation make a
|
|
120
|
+
member contribute nothing.
|
|
121
|
+
"""
|
|
122
|
+
if self.standard_hours <= 0.0:
|
|
123
|
+
return 0.0
|
|
124
|
+
company = self.teams.company_work_hours
|
|
125
|
+
total = 0.0
|
|
126
|
+
for member in team.members:
|
|
127
|
+
person = self.teams.persons.get(member.person_name.lower())
|
|
128
|
+
if person is None:
|
|
129
|
+
continue
|
|
130
|
+
fte = membership_fte_on(member, day)
|
|
131
|
+
if fte <= 0.0:
|
|
132
|
+
continue
|
|
133
|
+
hours = _person_hours(person, company, day)
|
|
134
|
+
total += fte * hours / self.standard_hours
|
|
135
|
+
return total
|
|
136
|
+
|
|
137
|
+
def points_on(self, team: Team, day: date) -> float:
|
|
138
|
+
"""Return the story points the team completes on one day.
|
|
139
|
+
|
|
140
|
+
The team velocity is the story points done in one sprint at the
|
|
141
|
+
recorded summed full-time equivalent. It is rescaled by the
|
|
142
|
+
team's effective full-time equivalent on the day and spread over
|
|
143
|
+
the working days of a sprint.
|
|
144
|
+
"""
|
|
145
|
+
if team.sprint_length <= 0 or team.sum_fte_at_velocity <= 0.0:
|
|
146
|
+
return 0.0
|
|
147
|
+
per_day = team.velocity / team.sprint_length
|
|
148
|
+
return per_day * self._team_fte(team, day) / team.sum_fte_at_velocity
|
|
149
|
+
|
|
150
|
+
def advance(self, team: Team, points: int,
|
|
151
|
+
cursor: _Cursor) -> Optional[tuple[date, _Cursor]]:
|
|
152
|
+
"""Return the ready date and new cursor after doing some work.
|
|
153
|
+
|
|
154
|
+
The team works from the cursor, which is the day it is on and
|
|
155
|
+
the story points already spent on that day, so the day's leftover
|
|
156
|
+
capacity carries to the next item and several small items can
|
|
157
|
+
finish on the same day. The ready date is the day the work is
|
|
158
|
+
finished. Work with no story points is ready at the cursor day
|
|
159
|
+
and leaves the cursor unchanged. None is returned when the work
|
|
160
|
+
does not finish within the horizon, which means the team has no
|
|
161
|
+
capacity for it.
|
|
162
|
+
"""
|
|
163
|
+
if points <= 0:
|
|
164
|
+
return cursor.day, cursor
|
|
165
|
+
remaining = float(points)
|
|
166
|
+
day, used = cursor.day, cursor.used
|
|
167
|
+
limit = day + _HORIZON
|
|
168
|
+
while day <= limit:
|
|
169
|
+
available = self.points_on(team, day) - used
|
|
170
|
+
if available > 0.0:
|
|
171
|
+
if remaining <= available + _EPSILON:
|
|
172
|
+
return day, _Cursor(day, used + remaining)
|
|
173
|
+
remaining -= available
|
|
174
|
+
day += _ONE_DAY
|
|
175
|
+
used = 0.0
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class _Estimator:
|
|
181
|
+
"""Assign teams to backlog items and date the team's own work.
|
|
182
|
+
|
|
183
|
+
The estimator keeps, for each team, a cursor with the day and the
|
|
184
|
+
points spent that day. It dates the work a team itself does on an
|
|
185
|
+
item; lifting a parent's date to its children is done afterwards by
|
|
186
|
+
:class:`_ParentRollup`.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
workforce: _Workforce
|
|
190
|
+
cursor: dict[str, _Cursor]
|
|
191
|
+
by_label: dict[str, Team]
|
|
192
|
+
stderr_file: TextIO
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def create(teams: AvailableTeams, start: date,
|
|
196
|
+
stderr_file: TextIO) -> '_Estimator':
|
|
197
|
+
"""Create an estimator with every team free on the start date."""
|
|
198
|
+
cursor = {team.name: _Cursor(start, 0.0) for team in teams.teams}
|
|
199
|
+
by_label: dict[str, Team] = {}
|
|
200
|
+
for team in teams.teams:
|
|
201
|
+
for label in [team.name, *team.aliases]:
|
|
202
|
+
by_label[label.lower()] = team
|
|
203
|
+
return _Estimator(_Workforce.create(teams), cursor, by_label,
|
|
204
|
+
stderr_file)
|
|
205
|
+
|
|
206
|
+
def _warn(self, item: BacklogItem, reason: str) -> None:
|
|
207
|
+
"""Report that an item cannot be dated and why."""
|
|
208
|
+
print(f'Cannot estimate {item.key!r}: {reason}', file=self.stderr_file)
|
|
209
|
+
|
|
210
|
+
def _earliest_team(self) -> Optional[Team]:
|
|
211
|
+
"""Return the team that becomes free earliest, or None."""
|
|
212
|
+
teams = self.workforce.teams.teams
|
|
213
|
+
if not teams:
|
|
214
|
+
return None
|
|
215
|
+
best = teams[0]
|
|
216
|
+
for team in teams[1:]:
|
|
217
|
+
if self.cursor[team.name] < self.cursor[best.name]:
|
|
218
|
+
best = team
|
|
219
|
+
return best
|
|
220
|
+
|
|
221
|
+
def _team_for(self, item: BacklogItem) -> Optional[Team]:
|
|
222
|
+
"""Return the team that works the item, or None when unknown."""
|
|
223
|
+
if item.team is None:
|
|
224
|
+
team = self._earliest_team()
|
|
225
|
+
if team is None:
|
|
226
|
+
self._warn(item, 'no team is available')
|
|
227
|
+
return team
|
|
228
|
+
team = self.by_label.get(item.team.lower())
|
|
229
|
+
if team is None:
|
|
230
|
+
self._warn(item, f'team {item.team!r} is not in the workforce')
|
|
231
|
+
return team
|
|
232
|
+
|
|
233
|
+
def own_date(self, item: BacklogItem) -> Optional[date]:
|
|
234
|
+
"""Return the date the team finishes the item's own work.
|
|
235
|
+
|
|
236
|
+
Done and rejected items consume no team time and get no date.
|
|
237
|
+
Other items are worked by their assigned team, or by the team
|
|
238
|
+
that is free earliest, from where that team's cursor stands. When
|
|
239
|
+
the team has no capacity for the item, or no team is available,
|
|
240
|
+
the item gets no date and a warning is reported.
|
|
241
|
+
"""
|
|
242
|
+
if item.status in (Status.DONE, Status.REJECTED):
|
|
243
|
+
return None
|
|
244
|
+
team = self._team_for(item)
|
|
245
|
+
if team is None:
|
|
246
|
+
return None
|
|
247
|
+
position = self.cursor[team.name]
|
|
248
|
+
result = self.workforce.advance(team, item.story_points, position)
|
|
249
|
+
if result is None:
|
|
250
|
+
self._warn(item, f'team {team.name!r} has no capacity for it')
|
|
251
|
+
return None
|
|
252
|
+
ready, moved = result
|
|
253
|
+
self.cursor[team.name] = moved
|
|
254
|
+
return ready
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class _ParentRollup:
|
|
259
|
+
"""Lift each parent's date to be no earlier than its children.
|
|
260
|
+
|
|
261
|
+
A parent cannot be ready before its latest child, even though the
|
|
262
|
+
work on the parent itself may be scheduled earlier. The effective
|
|
263
|
+
date of an item is therefore the latest of its own date and the
|
|
264
|
+
effective dates of its children, found recursively. Done and
|
|
265
|
+
rejected items keep no date and never delay their parent.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
children: dict[str, list[str]]
|
|
269
|
+
own: dict[str, Optional[date]]
|
|
270
|
+
status: dict[str, Status]
|
|
271
|
+
memo: dict[str, Optional[date]]
|
|
272
|
+
active: set[str]
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def create(backlog: Backlog, own: dict[str, Optional[date]],
|
|
276
|
+
status: dict[str, Status]) -> '_ParentRollup':
|
|
277
|
+
"""Create a rollup, grouping the item keys by their parent key."""
|
|
278
|
+
children: dict[str, list[str]] = {}
|
|
279
|
+
for item in backlog:
|
|
280
|
+
if item.parent_key is not None:
|
|
281
|
+
children.setdefault(item.parent_key, []).append(item.key)
|
|
282
|
+
return _ParentRollup(children, own, status, {}, set())
|
|
283
|
+
|
|
284
|
+
def effective(self, key: str) -> Optional[date]:
|
|
285
|
+
"""Return the effective ready date of one item key."""
|
|
286
|
+
if key in self.memo:
|
|
287
|
+
return self.memo[key]
|
|
288
|
+
if self.status.get(key) in (Status.DONE, Status.REJECTED):
|
|
289
|
+
self.memo[key] = None
|
|
290
|
+
return None
|
|
291
|
+
if key in self.active:
|
|
292
|
+
return self.own.get(key)
|
|
293
|
+
self.active.add(key)
|
|
294
|
+
dates = [self.own.get(key)]
|
|
295
|
+
dates += [self.effective(child)
|
|
296
|
+
for child in self.children.get(key, [])]
|
|
297
|
+
self.active.discard(key)
|
|
298
|
+
known = [day for day in dates if day is not None]
|
|
299
|
+
result = max(known) if known else None
|
|
300
|
+
self.memo[key] = result
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def estimate_ready_date(backlog: Backlog, available_teams: AvailableTeams,
|
|
305
|
+
start_date: Optional[date] = None,
|
|
306
|
+
stderr_file: TextIO = sys.stderr) -> Backlog:
|
|
307
|
+
"""Estimate the ready date of backlog items.
|
|
308
|
+
|
|
309
|
+
The teams start working on the start date, which defaults to today
|
|
310
|
+
when None is given. The backlog items are worked in their given
|
|
311
|
+
order. Each item is worked by its assigned team, or, when it names
|
|
312
|
+
no team, by the team that becomes free earliest. Only one team works
|
|
313
|
+
an item, and a team works one item at a time, in backlog order. When
|
|
314
|
+
a team's daily capacity covers more than one item, several items
|
|
315
|
+
finish on the same day, and the next item carries on from the
|
|
316
|
+
leftover capacity of the day the current one finished.
|
|
317
|
+
|
|
318
|
+
The story points an item still needs are turned into calendar time
|
|
319
|
+
from the team's velocity, rescaled by the team's effective capacity
|
|
320
|
+
on each day. That capacity follows every member's full-time
|
|
321
|
+
equivalent and actual work hours, so weekends, company holidays,
|
|
322
|
+
personal vacation, learning periods and ordered over-time all change
|
|
323
|
+
the pace. A standard work day is the longest day in the company
|
|
324
|
+
weekly schedule. The story points of TODO and IN_PROGRESS items are
|
|
325
|
+
all treated as still left to do; DONE and REJECTED items need no work
|
|
326
|
+
and get no estimated date. See also the Status enum.
|
|
327
|
+
|
|
328
|
+
A parent's estimated date is lifted to be no earlier than its latest
|
|
329
|
+
child's, applied through the whole hierarchy, because a parent cannot
|
|
330
|
+
be ready before its children even though work on the parent itself
|
|
331
|
+
may be scheduled earlier. A finished child does not delay its parent.
|
|
332
|
+
|
|
333
|
+
Dependencies between items are not considered; the backlog is assumed
|
|
334
|
+
to be ordered so that the teams can work the items in order. When an
|
|
335
|
+
item names a team that is not in the workforce, when no team is
|
|
336
|
+
available, or when the chosen team has no capacity for the item, the
|
|
337
|
+
item gets no estimated date and a warning is reported.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
backlog: The backlog to estimate the ready date of. The argument
|
|
341
|
+
is not modified. The backlog must be ordered so that the
|
|
342
|
+
teams can work the items in order.
|
|
343
|
+
available_teams: The available teams used to estimate the ready
|
|
344
|
+
date, including absence, velocity and work hours.
|
|
345
|
+
start_date: The day the teams start working, or None for today.
|
|
346
|
+
stderr_file: The file to report warnings to.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
A new backlog whose items carry the estimated ready date. The
|
|
350
|
+
other fields are copied unchanged from the given items.
|
|
351
|
+
"""
|
|
352
|
+
start = date.today() if start_date is None else start_date
|
|
353
|
+
estimator = _Estimator.create(available_teams, start, stderr_file)
|
|
354
|
+
own = {item.key: estimator.own_date(item) for item in backlog}
|
|
355
|
+
status = {item.key: item.status for item in backlog}
|
|
356
|
+
rollup = _ParentRollup.create(backlog, own, status)
|
|
357
|
+
return [replace(item, estimated_ready_date=rollup.effective(item.key))
|
|
358
|
+
for item in backlog]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def set_plan_from_estimate(backlog: Backlog,
|
|
362
|
+
stderr_file: TextIO = sys.stderr) -> Backlog:
|
|
363
|
+
"""Set the planned ready dates from the estimated ready dates.
|
|
364
|
+
|
|
365
|
+
For each backlog item the planned ready date is set to the estimated
|
|
366
|
+
ready date, copying None when the estimated ready date is None.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
backlog: The backlog to set the planned ready dates of. The
|
|
370
|
+
argument is not modified.
|
|
371
|
+
stderr_file: The file to report errors to.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
A new backlog whose items carry the planned ready date taken from
|
|
375
|
+
the estimated ready date. The other fields are copied unchanged.
|
|
376
|
+
"""
|
|
377
|
+
_ = stderr_file
|
|
378
|
+
return [replace(item, planned_ready_date=item.estimated_ready_date)
|
|
379
|
+
for item in backlog]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Rules for formatting table data."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from tableio import Fmt, Color, TableBorderStyle
|
|
9
|
+
from backlogops.backlog import Status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_status_format() -> dict[Status, Fmt]:
|
|
13
|
+
"""Return the default format specification for the status column."""
|
|
14
|
+
return {
|
|
15
|
+
Status.TODO: Fmt(),
|
|
16
|
+
Status.IN_PROGRESS: Fmt(),
|
|
17
|
+
Status.DONE: Fmt(italic=True, highlight=Color.GREEN),
|
|
18
|
+
Status.REJECTED: Fmt(italic=True, highlight=Color.RED)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FormatRules: # pylint: disable=too-many-instance-attributes
|
|
23
|
+
"""Rules for formatting table data."""
|
|
24
|
+
|
|
25
|
+
backlog_first: bool = True
|
|
26
|
+
"""Whether to write the backlog before the releases."""
|
|
27
|
+
|
|
28
|
+
border_style: TableBorderStyle = \
|
|
29
|
+
TableBorderStyle.OUTER_FIRST_ROW_THICK_INNER_THIN
|
|
30
|
+
"""The border style to apply to the written table."""
|
|
31
|
+
|
|
32
|
+
filtered_data_range: bool = True
|
|
33
|
+
"""Whether to mark the written data as a filtered data range."""
|
|
34
|
+
|
|
35
|
+
first_row_format: Fmt = Fmt(bold=True)
|
|
36
|
+
"""The format specification for the column names row."""
|
|
37
|
+
|
|
38
|
+
status_format: dict[Status, Fmt] = \
|
|
39
|
+
field(default_factory=default_status_format)
|
|
40
|
+
"""The format specification for the status column."""
|
|
41
|
+
|
|
42
|
+
estimate_late: Fmt = Fmt(bold=True, highlight=Color.RED)
|
|
43
|
+
"""The format for estimate values if later than planned."""
|
|
44
|
+
|
|
45
|
+
estimate_early: Fmt = Fmt()
|
|
46
|
+
"""The format for estimate values if earlier than planned."""
|
|
47
|
+
|
|
48
|
+
estimate_eq_planned: Fmt = Fmt()
|
|
49
|
+
"""The format for estimate values if equal to planned."""
|
|
50
|
+
|
|
51
|
+
def get_status_format(self, status: Status) -> Fmt:
|
|
52
|
+
"""Return the format for a status."""
|
|
53
|
+
return self.status_format.get(status, Fmt())
|
|
54
|
+
|
|
55
|
+
def turn_off_cell_format(self) -> None:
|
|
56
|
+
"""Turn off all cell formatting.
|
|
57
|
+
|
|
58
|
+
Make all cells plain, without any formatting.
|
|
59
|
+
This does not affect the border style or the filtered data range.
|
|
60
|
+
"""
|
|
61
|
+
self.first_row_format = Fmt()
|
|
62
|
+
self.status_format = {status: Fmt() for status in Status}
|
|
63
|
+
self.estimate_late = Fmt()
|
|
64
|
+
self.estimate_early = Fmt()
|
|
65
|
+
self.estimate_eq_planned = Fmt()
|
|
66
|
+
|
|
67
|
+
def cell_format_used(self) -> bool:
|
|
68
|
+
"""Return True if any cell formatting is used."""
|
|
69
|
+
return any([self.first_row_format != Fmt(),
|
|
70
|
+
any(fmt != Fmt() for fmt in self.status_format.values()),
|
|
71
|
+
self.estimate_late != Fmt(),
|
|
72
|
+
self.estimate_early != Fmt(),
|
|
73
|
+
self.estimate_eq_planned != Fmt()])
|