prodc 0.3.62__tar.gz → 0.3.63__tar.gz
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.
- {prodc-0.3.62 → prodc-0.3.63}/PKG-INFO +1 -1
- {prodc-0.3.62 → prodc-0.3.63}/pyproject.toml +1 -1
- prodc-0.3.63/src/prodc/board.py +218 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/cli.py +21 -93
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/report.py +10 -18
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/report_html.py +11 -64
- {prodc-0.3.62 → prodc-0.3.63}/.gitignore +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/CHANGELOG.md +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/LICENSE +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/README.md +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/__init__.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/adapters.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/agent.md +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/config.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/dedup.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/loader.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/model.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/py.typed +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/status.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/src/prodc/writer.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_adapters.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_callbacks.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_cli.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_command_hook.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_dedup.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_description.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_drafts.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_importance.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_loader.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_models.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_report.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_report_html.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_roles.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_sources.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_staleness.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_status.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_suggest.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_variants.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_who_variants.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_writer.py +0 -0
- {prodc-0.3.62 → prodc-0.3.63}/tests/test_writer_comments.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.63
|
|
4
4
|
Summary: Product management as code: roles, flows, and features traced to evidence.
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Gather the resolved board once, for every renderer to consume.
|
|
2
|
+
|
|
3
|
+
`prodc status`, `prodc report` (markdown), and `prodc report --format html` each need the
|
|
4
|
+
same thing: resolve every need, roll it up, and compute the per-goal / per-release / per-role
|
|
5
|
+
coverage, the handoffs, the recurrence, and the orphan proofs. They used to do that gather
|
|
6
|
+
three separate times, which is how the three outputs could silently drift. :func:`gather` does
|
|
7
|
+
it once and returns a :class:`ResolvedBoard`; the renderers format that struct. :func:`warrant`
|
|
8
|
+
and :func:`need_payload` are the one user-signal string and the one Need-to-dict serialiser the
|
|
9
|
+
board and the HTML viewer share, so a need looks the same wherever it is rendered.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import date
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from .config import Config
|
|
19
|
+
from .model import (
|
|
20
|
+
Goal,
|
|
21
|
+
Model,
|
|
22
|
+
Need,
|
|
23
|
+
NeedResult,
|
|
24
|
+
Release,
|
|
25
|
+
Role,
|
|
26
|
+
SourceKind,
|
|
27
|
+
ears_shape,
|
|
28
|
+
)
|
|
29
|
+
from .status import (
|
|
30
|
+
Handoff,
|
|
31
|
+
Recurrence,
|
|
32
|
+
Summary,
|
|
33
|
+
evidence_age_days,
|
|
34
|
+
flow_orphan_warnings,
|
|
35
|
+
goal_report,
|
|
36
|
+
handoffs,
|
|
37
|
+
last_observed,
|
|
38
|
+
orphans,
|
|
39
|
+
recurrence,
|
|
40
|
+
release_report,
|
|
41
|
+
resolve,
|
|
42
|
+
role_report,
|
|
43
|
+
shared_proof_warnings,
|
|
44
|
+
summarize,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warrant(need: Need) -> str:
|
|
49
|
+
"""The warrant column: the strongest source kind, or 'opinion' when there is no source at all.
|
|
50
|
+
|
|
51
|
+
A `primary` source names its person (`primary:who`); `secondary`/`assumption` show the kind. A
|
|
52
|
+
need with any source is shown by that kind even if it is an opinion (only an assumption) — the
|
|
53
|
+
opinion roll-up lives in the gap counts, this column shows the literal evidence tier.
|
|
54
|
+
"""
|
|
55
|
+
strongest = need.strongest_kind # the one source-strength ranking lives on the model
|
|
56
|
+
if strongest is None:
|
|
57
|
+
return "opinion"
|
|
58
|
+
if strongest is SourceKind.PRIMARY:
|
|
59
|
+
who = next((s.who for s in need.sources if s.kind is SourceKind.PRIMARY and s.who), None)
|
|
60
|
+
return f"primary:{who}" if who else "primary"
|
|
61
|
+
return strongest.value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def annotate_board_warnings(results: list[NeedResult], needs: list[Need]) -> None:
|
|
65
|
+
"""Append the cross-need hygiene warnings (shared proofs #45, seam coverage #44)."""
|
|
66
|
+
shared = shared_proof_warnings(results)
|
|
67
|
+
flow_orphans = flow_orphan_warnings(needs)
|
|
68
|
+
for r in results:
|
|
69
|
+
if r.need.id in shared:
|
|
70
|
+
r.warnings.append(shared[r.need.id])
|
|
71
|
+
if r.need.id in flow_orphans:
|
|
72
|
+
r.warnings.append("belongs to no flow — seam not covered (#44)")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -> dict[str, object]:
|
|
76
|
+
"""One need serialised to a plain dict — the single Need-to-JSON used by the board and viewer.
|
|
77
|
+
|
|
78
|
+
``flavor="board"`` is the status/`show` payload (the full record); ``flavor="html"`` is the
|
|
79
|
+
report viewer's flatter row. They carry different fields in a different order, so the two
|
|
80
|
+
shapes live here together, byte-for-byte as they were, rather than as copies in two modules.
|
|
81
|
+
"""
|
|
82
|
+
need = r.need
|
|
83
|
+
if flavor == "html":
|
|
84
|
+
strongest = need.strongest_kind
|
|
85
|
+
return {
|
|
86
|
+
"id": need.id,
|
|
87
|
+
"text": need.text,
|
|
88
|
+
"description": need.description,
|
|
89
|
+
"state": r.state.value,
|
|
90
|
+
"must": need.must,
|
|
91
|
+
"who": need.who or "(unassigned)",
|
|
92
|
+
"label": need.label,
|
|
93
|
+
"client": need.client,
|
|
94
|
+
"opinion": need.is_opinion,
|
|
95
|
+
"warrant": strongest.value if strongest is not None else "opinion",
|
|
96
|
+
"importance": need.importance,
|
|
97
|
+
"goal": need.goal,
|
|
98
|
+
"release": need.release,
|
|
99
|
+
"claim": need.claim,
|
|
100
|
+
"proofs": list(need.proofs),
|
|
101
|
+
"detail": list(r.detail),
|
|
102
|
+
"warnings": list(r.warnings),
|
|
103
|
+
"attestation": (
|
|
104
|
+
{
|
|
105
|
+
"who": need.attestation.who,
|
|
106
|
+
"date": need.attestation.date,
|
|
107
|
+
"kind": need.attestation.kind.value,
|
|
108
|
+
"verdict": need.attestation.verdict,
|
|
109
|
+
}
|
|
110
|
+
if need.attestation
|
|
111
|
+
else None
|
|
112
|
+
),
|
|
113
|
+
"sources": [
|
|
114
|
+
{
|
|
115
|
+
"kind": s.kind.value,
|
|
116
|
+
"who": s.who,
|
|
117
|
+
"date": s.date,
|
|
118
|
+
"quote": s.quote,
|
|
119
|
+
"locator": s.locator,
|
|
120
|
+
}
|
|
121
|
+
for s in need.sources
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
"id": need.id,
|
|
126
|
+
"who": need.who,
|
|
127
|
+
"role": need.role,
|
|
128
|
+
"text": need.text,
|
|
129
|
+
"description": need.description,
|
|
130
|
+
"must": need.must,
|
|
131
|
+
"state": r.state.value,
|
|
132
|
+
"grounds": r.state.value,
|
|
133
|
+
"warrant": warrant(need),
|
|
134
|
+
"shape": ears_shape(need.text).value,
|
|
135
|
+
"client": need.client,
|
|
136
|
+
"variant_of": need.variant_of,
|
|
137
|
+
"goal": need.goal,
|
|
138
|
+
"release": need.release,
|
|
139
|
+
"product": need.product,
|
|
140
|
+
"draft": need.draft,
|
|
141
|
+
"importance": need.importance,
|
|
142
|
+
"importance_set_at": need.importance_set_at,
|
|
143
|
+
"claim": need.claim,
|
|
144
|
+
"attestation": (
|
|
145
|
+
{
|
|
146
|
+
"who": need.attestation.who,
|
|
147
|
+
"date": need.attestation.date,
|
|
148
|
+
"kind": need.attestation.kind.value,
|
|
149
|
+
"verdict": need.attestation.verdict,
|
|
150
|
+
"comment": need.attestation.comment,
|
|
151
|
+
}
|
|
152
|
+
if need.attestation
|
|
153
|
+
else None
|
|
154
|
+
),
|
|
155
|
+
"delivered": r.delivered,
|
|
156
|
+
"total": r.total,
|
|
157
|
+
"declared": r.declared,
|
|
158
|
+
"opinion": need.is_opinion,
|
|
159
|
+
"only_assumption": need.only_assumption,
|
|
160
|
+
"evidence_age": evidence_age_days(need, date.today()),
|
|
161
|
+
"last_observed_at": last_observed(need),
|
|
162
|
+
"detail": r.detail,
|
|
163
|
+
"warnings": r.warnings,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class ResolvedBoard:
|
|
169
|
+
"""The board resolved once: the results and every roll-up the renderers read off it."""
|
|
170
|
+
|
|
171
|
+
results: list[NeedResult]
|
|
172
|
+
summary: Summary
|
|
173
|
+
goal_cov: list[tuple[Goal, Summary]]
|
|
174
|
+
ungoaled: list[NeedResult]
|
|
175
|
+
rel_cov: list[tuple[Release, Summary]]
|
|
176
|
+
role_cov: list[tuple[Role, Summary]]
|
|
177
|
+
unroled: list[NeedResult]
|
|
178
|
+
handoffs: list[Handoff]
|
|
179
|
+
recurrence: list[Recurrence]
|
|
180
|
+
orphans: dict[str, list[str]]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def gather(
|
|
184
|
+
cfg: Config,
|
|
185
|
+
model: Model,
|
|
186
|
+
*,
|
|
187
|
+
needs: list[Need] | None = None,
|
|
188
|
+
annotate_warnings: bool = False,
|
|
189
|
+
) -> ResolvedBoard:
|
|
190
|
+
"""Resolve the board once and compute every roll-up a renderer needs.
|
|
191
|
+
|
|
192
|
+
``needs`` overrides which needs are resolved (the caller filters drafts/client/goal/stale
|
|
193
|
+
before calling and passes the result); it defaults to the whole model. ``annotate_warnings``
|
|
194
|
+
appends the cross-need board warnings (#44/#45) to the results, as ``prodc status`` does —
|
|
195
|
+
the markdown/HTML reports leave it off, exactly as they did before this was factored out.
|
|
196
|
+
The declared goals/releases/roles come from ``model``; the recurrence/handoffs/orphans views
|
|
197
|
+
are computed over the resolved ``needs``.
|
|
198
|
+
"""
|
|
199
|
+
use_needs = list(model.needs) if needs is None else needs
|
|
200
|
+
results = resolve(use_needs, cfg.adapters, cfg.source_adapters)
|
|
201
|
+
if annotate_warnings:
|
|
202
|
+
annotate_board_warnings(results, use_needs)
|
|
203
|
+
summary = summarize(results)
|
|
204
|
+
goal_cov, ungoaled = goal_report(results, list(model.goals))
|
|
205
|
+
rel_cov = release_report(results, list(model.releases))
|
|
206
|
+
role_cov, unroled = role_report(results, list(model.roles))
|
|
207
|
+
return ResolvedBoard(
|
|
208
|
+
results=results,
|
|
209
|
+
summary=summary,
|
|
210
|
+
goal_cov=goal_cov,
|
|
211
|
+
ungoaled=ungoaled,
|
|
212
|
+
rel_cov=rel_cov,
|
|
213
|
+
role_cov=role_cov,
|
|
214
|
+
unroled=unroled,
|
|
215
|
+
handoffs=handoffs(use_needs),
|
|
216
|
+
recurrence=recurrence(use_needs),
|
|
217
|
+
orphans=orphans(use_needs, cfg.adapters),
|
|
218
|
+
)
|
|
@@ -20,6 +20,16 @@ from pathlib import Path
|
|
|
20
20
|
from typing import cast
|
|
21
21
|
|
|
22
22
|
from . import __version__
|
|
23
|
+
from .board import (
|
|
24
|
+
annotate_board_warnings as _annotate_board_warnings,
|
|
25
|
+
)
|
|
26
|
+
from .board import (
|
|
27
|
+
gather,
|
|
28
|
+
need_payload,
|
|
29
|
+
)
|
|
30
|
+
from .board import (
|
|
31
|
+
warrant as _warrant,
|
|
32
|
+
)
|
|
23
33
|
from .callbacks import advance_ledger, fire, read_ledger, transitions, write_ledger
|
|
24
34
|
from .config import Config, ConfigError
|
|
25
35
|
from .dedup import (
|
|
@@ -39,7 +49,6 @@ from .model import (
|
|
|
39
49
|
NeedState,
|
|
40
50
|
Release,
|
|
41
51
|
Role,
|
|
42
|
-
SourceKind,
|
|
43
52
|
ears_shape,
|
|
44
53
|
)
|
|
45
54
|
from .report import render_markdown
|
|
@@ -52,18 +61,10 @@ from .status import (
|
|
|
52
61
|
applicable_needs,
|
|
53
62
|
argument_defects,
|
|
54
63
|
evidence_age_days,
|
|
55
|
-
flow_orphan_warnings,
|
|
56
|
-
goal_report,
|
|
57
|
-
handoffs,
|
|
58
|
-
last_observed,
|
|
59
|
-
orphans,
|
|
60
64
|
proof_suggestions,
|
|
61
65
|
rank,
|
|
62
66
|
recurrence,
|
|
63
|
-
release_report,
|
|
64
67
|
resolve,
|
|
65
|
-
role_report,
|
|
66
|
-
shared_proof_warnings,
|
|
67
68
|
state_priority,
|
|
68
69
|
summarize,
|
|
69
70
|
)
|
|
@@ -90,22 +91,6 @@ _LABEL = {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
|
|
93
|
-
def _warrant(need: Need) -> str:
|
|
94
|
-
"""The warrant column: the strongest source kind, or 'opinion' when there is no source at all.
|
|
95
|
-
|
|
96
|
-
A `primary` source names its person (`primary:who`); `secondary`/`assumption` show the kind. A
|
|
97
|
-
need with any source is shown by that kind even if it is an opinion (only an assumption) — the
|
|
98
|
-
opinion roll-up lives in the gap counts, this column shows the literal evidence tier.
|
|
99
|
-
"""
|
|
100
|
-
strongest = need.strongest_kind # the one source-strength ranking lives on the model
|
|
101
|
-
if strongest is None:
|
|
102
|
-
return "opinion"
|
|
103
|
-
if strongest is SourceKind.PRIMARY:
|
|
104
|
-
who = next((s.who for s in need.sources if s.kind is SourceKind.PRIMARY and s.who), None)
|
|
105
|
-
return f"primary:{who}" if who else "primary"
|
|
106
|
-
return strongest.value
|
|
107
|
-
|
|
108
|
-
|
|
109
94
|
def _provenance_counts(results: list[NeedResult]) -> dict[str, int]:
|
|
110
95
|
"""How many needs carry each evidence tier (primary/secondary/assumption/opinion)."""
|
|
111
96
|
counts = {"primary": 0, "secondary": 0, "assumption": 0, "opinion": 0}
|
|
@@ -349,52 +334,6 @@ def _summary_payload(summary: Summary, orphan_map: dict[str, list[str]]) -> dict
|
|
|
349
334
|
}
|
|
350
335
|
|
|
351
336
|
|
|
352
|
-
def _need_payload(r: NeedResult) -> dict[str, object]:
|
|
353
|
-
"""One need's common fields — shared by the board and `show` so the two never drift."""
|
|
354
|
-
need = r.need
|
|
355
|
-
return {
|
|
356
|
-
"id": need.id,
|
|
357
|
-
"who": need.who,
|
|
358
|
-
"role": need.role,
|
|
359
|
-
"text": need.text,
|
|
360
|
-
"description": need.description,
|
|
361
|
-
"must": need.must,
|
|
362
|
-
"state": r.state.value,
|
|
363
|
-
"grounds": r.state.value,
|
|
364
|
-
"warrant": _warrant(need),
|
|
365
|
-
"shape": ears_shape(need.text).value,
|
|
366
|
-
"client": need.client,
|
|
367
|
-
"variant_of": need.variant_of,
|
|
368
|
-
"goal": need.goal,
|
|
369
|
-
"release": need.release,
|
|
370
|
-
"product": need.product,
|
|
371
|
-
"draft": need.draft,
|
|
372
|
-
"importance": need.importance,
|
|
373
|
-
"importance_set_at": need.importance_set_at,
|
|
374
|
-
"claim": need.claim,
|
|
375
|
-
"attestation": (
|
|
376
|
-
{
|
|
377
|
-
"who": need.attestation.who,
|
|
378
|
-
"date": need.attestation.date,
|
|
379
|
-
"kind": need.attestation.kind.value,
|
|
380
|
-
"verdict": need.attestation.verdict,
|
|
381
|
-
"comment": need.attestation.comment,
|
|
382
|
-
}
|
|
383
|
-
if need.attestation
|
|
384
|
-
else None
|
|
385
|
-
),
|
|
386
|
-
"delivered": r.delivered,
|
|
387
|
-
"total": r.total,
|
|
388
|
-
"declared": r.declared,
|
|
389
|
-
"opinion": need.is_opinion,
|
|
390
|
-
"only_assumption": need.only_assumption,
|
|
391
|
-
"evidence_age": evidence_age_days(need, date.today()),
|
|
392
|
-
"last_observed_at": last_observed(need),
|
|
393
|
-
"detail": r.detail,
|
|
394
|
-
"warnings": r.warnings,
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
337
|
def _to_json(
|
|
399
338
|
results: list[NeedResult],
|
|
400
339
|
summary: Summary,
|
|
@@ -463,7 +402,7 @@ def _to_json(
|
|
|
463
402
|
for goal, gsum in goal_cov
|
|
464
403
|
],
|
|
465
404
|
"ungoaled": [r.need.id for r in ungoaled],
|
|
466
|
-
"needs": [
|
|
405
|
+
"needs": [need_payload(r) for r in results],
|
|
467
406
|
}
|
|
468
407
|
return json.dumps(payload, indent=2)
|
|
469
408
|
|
|
@@ -482,17 +421,6 @@ def _load_or_report(args: argparse.Namespace) -> tuple[Config, Model] | None:
|
|
|
482
421
|
return None
|
|
483
422
|
|
|
484
423
|
|
|
485
|
-
def _annotate_board_warnings(results: list[NeedResult], needs: list[Need]) -> None:
|
|
486
|
-
"""Append the cross-need hygiene warnings (shared proofs #45, seam coverage #44)."""
|
|
487
|
-
shared = shared_proof_warnings(results)
|
|
488
|
-
flow_orphans = flow_orphan_warnings(needs)
|
|
489
|
-
for r in results:
|
|
490
|
-
if r.need.id in shared:
|
|
491
|
-
r.warnings.append(shared[r.need.id])
|
|
492
|
-
if r.need.id in flow_orphans:
|
|
493
|
-
r.warnings.append("belongs to no flow — seam not covered (#44)")
|
|
494
|
-
|
|
495
|
-
|
|
496
424
|
def _annotate_duplicate_warnings(
|
|
497
425
|
results: list[NeedResult], needs: list[Need], threshold: float
|
|
498
426
|
) -> None:
|
|
@@ -628,15 +556,15 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
628
556
|
for n in needs
|
|
629
557
|
if (age := evidence_age_days(n, today)) is not None and age > args.stale
|
|
630
558
|
]
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
summary =
|
|
634
|
-
orphan_map = orphans
|
|
635
|
-
recs = recurrence
|
|
636
|
-
goal_cov, ungoaled =
|
|
637
|
-
rel_cov =
|
|
638
|
-
role_cov, unroled =
|
|
639
|
-
hos = handoffs
|
|
559
|
+
board = gather(cfg, model, needs=needs, annotate_warnings=True)
|
|
560
|
+
results = board.results
|
|
561
|
+
summary = board.summary
|
|
562
|
+
orphan_map = board.orphans
|
|
563
|
+
recs = board.recurrence
|
|
564
|
+
goal_cov, ungoaled = board.goal_cov, board.ungoaled
|
|
565
|
+
rel_cov = board.rel_cov
|
|
566
|
+
role_cov, unroled = board.role_cov, board.unroled
|
|
567
|
+
hos = board.handoffs
|
|
640
568
|
|
|
641
569
|
if args.columns:
|
|
642
570
|
bad = [c for c in args.columns if c not in _COLUMNS]
|
|
@@ -776,7 +704,7 @@ def _render_show(r: NeedResult, importance_label: str | None = None) -> str:
|
|
|
776
704
|
|
|
777
705
|
def _show_json(r: NeedResult) -> str:
|
|
778
706
|
need = r.need
|
|
779
|
-
payload =
|
|
707
|
+
payload = need_payload(r) # the shared core — never drifts from the board
|
|
780
708
|
payload.update(
|
|
781
709
|
{
|
|
782
710
|
"label": need.label,
|
|
@@ -11,18 +11,10 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from datetime import date
|
|
13
13
|
|
|
14
|
+
from .board import gather
|
|
14
15
|
from .config import Config
|
|
15
16
|
from .model import Model, NeedResult, NeedState
|
|
16
|
-
from .status import
|
|
17
|
-
goal_report,
|
|
18
|
-
handoffs,
|
|
19
|
-
recurrence,
|
|
20
|
-
release_report,
|
|
21
|
-
resolve,
|
|
22
|
-
role_report,
|
|
23
|
-
state_priority,
|
|
24
|
-
summarize,
|
|
25
|
-
)
|
|
17
|
+
from .status import state_priority
|
|
26
18
|
|
|
27
19
|
_LABEL = {
|
|
28
20
|
NeedState.DELIVERED: "DELIVERED",
|
|
@@ -58,14 +50,14 @@ def render_markdown(
|
|
|
58
50
|
model (so `--client` reuses the same filter the board uses). ``rev`` is an optional short git
|
|
59
51
|
revision to stamp the report, so a printed copy says which state it reflects.
|
|
60
52
|
"""
|
|
61
|
-
|
|
62
|
-
results =
|
|
63
|
-
summary =
|
|
64
|
-
goal_cov, ungoaled =
|
|
65
|
-
rel_cov =
|
|
66
|
-
role_cov, unroled =
|
|
67
|
-
hos = handoffs
|
|
68
|
-
recs = recurrence
|
|
53
|
+
board = gather(cfg, model)
|
|
54
|
+
results = board.results
|
|
55
|
+
summary = board.summary
|
|
56
|
+
goal_cov, ungoaled = board.goal_cov, board.ungoaled
|
|
57
|
+
rel_cov = board.rel_cov
|
|
58
|
+
role_cov, unroled = board.role_cov, board.unroled
|
|
59
|
+
hos = board.handoffs
|
|
60
|
+
recs = board.recurrence
|
|
69
61
|
|
|
70
62
|
scope = f" — {client}" if client else ""
|
|
71
63
|
stamp = date.today().isoformat() + (f" · {rev}" if rev else "")
|
|
@@ -12,74 +12,21 @@ from __future__ import annotations
|
|
|
12
12
|
import json
|
|
13
13
|
from datetime import date
|
|
14
14
|
|
|
15
|
+
from .board import gather, need_payload
|
|
15
16
|
from .config import Config
|
|
16
|
-
from .model import Model
|
|
17
|
-
from .status import (
|
|
18
|
-
goal_report,
|
|
19
|
-
handoffs,
|
|
20
|
-
recurrence,
|
|
21
|
-
release_report,
|
|
22
|
-
resolve,
|
|
23
|
-
role_report,
|
|
24
|
-
summarize,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _need_payload(r: NeedResult) -> dict[str, object]:
|
|
29
|
-
"""One need, flattened to the plain JSON the viewer renders a row and its detail from."""
|
|
30
|
-
need = r.need
|
|
31
|
-
strongest = need.strongest_kind
|
|
32
|
-
return {
|
|
33
|
-
"id": need.id,
|
|
34
|
-
"text": need.text,
|
|
35
|
-
"description": need.description,
|
|
36
|
-
"state": r.state.value,
|
|
37
|
-
"must": need.must,
|
|
38
|
-
"who": need.who or "(unassigned)",
|
|
39
|
-
"label": need.label,
|
|
40
|
-
"client": need.client,
|
|
41
|
-
"opinion": need.is_opinion,
|
|
42
|
-
"warrant": strongest.value if strongest is not None else "opinion",
|
|
43
|
-
"importance": need.importance,
|
|
44
|
-
"goal": need.goal,
|
|
45
|
-
"release": need.release,
|
|
46
|
-
"claim": need.claim,
|
|
47
|
-
"proofs": list(need.proofs),
|
|
48
|
-
"detail": list(r.detail),
|
|
49
|
-
"warnings": list(r.warnings),
|
|
50
|
-
"attestation": (
|
|
51
|
-
{
|
|
52
|
-
"who": need.attestation.who,
|
|
53
|
-
"date": need.attestation.date,
|
|
54
|
-
"kind": need.attestation.kind.value,
|
|
55
|
-
"verdict": need.attestation.verdict,
|
|
56
|
-
}
|
|
57
|
-
if need.attestation
|
|
58
|
-
else None
|
|
59
|
-
),
|
|
60
|
-
"sources": [
|
|
61
|
-
{
|
|
62
|
-
"kind": s.kind.value,
|
|
63
|
-
"who": s.who,
|
|
64
|
-
"date": s.date,
|
|
65
|
-
"quote": s.quote,
|
|
66
|
-
"locator": s.locator,
|
|
67
|
-
}
|
|
68
|
-
for s in need.sources
|
|
69
|
-
],
|
|
70
|
-
}
|
|
17
|
+
from .model import Model
|
|
71
18
|
|
|
72
19
|
|
|
73
20
|
def board_payload(
|
|
74
21
|
cfg: Config, model: Model, *, client: str | None = None, rev: str | None = None
|
|
75
22
|
) -> dict[str, object]:
|
|
76
23
|
"""Gather the resolved board into a plain-dict snapshot (the data the viewer renders)."""
|
|
77
|
-
|
|
78
|
-
results =
|
|
79
|
-
summary =
|
|
80
|
-
goal_cov, ungoaled =
|
|
81
|
-
rel_cov =
|
|
82
|
-
role_cov, unroled =
|
|
24
|
+
board = gather(cfg, model)
|
|
25
|
+
results = board.results
|
|
26
|
+
summary = board.summary
|
|
27
|
+
goal_cov, ungoaled = board.goal_cov, board.ungoaled
|
|
28
|
+
rel_cov = board.rel_cov
|
|
29
|
+
role_cov, unroled = board.role_cov, board.unroled
|
|
83
30
|
|
|
84
31
|
def _cov(delivered: int, total: int) -> dict[str, object]:
|
|
85
32
|
pct = round(delivered / total * 100) if total else None
|
|
@@ -102,7 +49,7 @@ def board_payload(
|
|
|
102
49
|
"opinion": summary.opinion,
|
|
103
50
|
"coverage_pct": round(summary.coverage * 100),
|
|
104
51
|
},
|
|
105
|
-
"needs": [
|
|
52
|
+
"needs": [need_payload(r, flavor="html") for r in results if r.need.product],
|
|
106
53
|
"goals": [{"id": g.id, "text": g.text, **_cov(s.delivered, s.total)} for g, s in goal_cov],
|
|
107
54
|
"ungoaled": [r.need.id for r in ungoaled],
|
|
108
55
|
"releases": [
|
|
@@ -119,7 +66,7 @@ def board_payload(
|
|
|
119
66
|
"count": rec.count,
|
|
120
67
|
"clients": list(rec.clients),
|
|
121
68
|
}
|
|
122
|
-
for rec in recurrence
|
|
69
|
+
for rec in board.recurrence
|
|
123
70
|
],
|
|
124
71
|
"handoffs": [
|
|
125
72
|
{
|
|
@@ -129,7 +76,7 @@ def board_payload(
|
|
|
129
76
|
"from_need": h.from_need.id,
|
|
130
77
|
"to_need": h.to_need.id,
|
|
131
78
|
}
|
|
132
|
-
for h in handoffs
|
|
79
|
+
for h in board.handoffs
|
|
133
80
|
],
|
|
134
81
|
}
|
|
135
82
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|