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.
Files changed (47) hide show
  1. {prodc-0.3.62 → prodc-0.3.63}/PKG-INFO +1 -1
  2. {prodc-0.3.62 → prodc-0.3.63}/pyproject.toml +1 -1
  3. prodc-0.3.63/src/prodc/board.py +218 -0
  4. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/cli.py +21 -93
  5. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/report.py +10 -18
  6. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/report_html.py +11 -64
  7. {prodc-0.3.62 → prodc-0.3.63}/.gitignore +0 -0
  8. {prodc-0.3.62 → prodc-0.3.63}/CHANGELOG.md +0 -0
  9. {prodc-0.3.62 → prodc-0.3.63}/LICENSE +0 -0
  10. {prodc-0.3.62 → prodc-0.3.63}/README.md +0 -0
  11. {prodc-0.3.62 → prodc-0.3.63}/examples/quickstart/features/orders.feature +0 -0
  12. {prodc-0.3.62 → prodc-0.3.63}/examples/quickstart/needs/jordan.yaml +0 -0
  13. {prodc-0.3.62 → prodc-0.3.63}/examples/quickstart/prodc.toml +0 -0
  14. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/__init__.py +0 -0
  15. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/adapters.py +0 -0
  16. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/agent.md +0 -0
  17. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/callbacks.py +0 -0
  18. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/config.py +0 -0
  19. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/dedup.py +0 -0
  20. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/loader.py +0 -0
  21. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/model.py +0 -0
  22. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/py.typed +0 -0
  23. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/status.py +0 -0
  24. {prodc-0.3.62 → prodc-0.3.63}/src/prodc/writer.py +0 -0
  25. {prodc-0.3.62 → prodc-0.3.63}/tests/test_adapters.py +0 -0
  26. {prodc-0.3.62 → prodc-0.3.63}/tests/test_callbacks.py +0 -0
  27. {prodc-0.3.62 → prodc-0.3.63}/tests/test_cli.py +0 -0
  28. {prodc-0.3.62 → prodc-0.3.63}/tests/test_command_hook.py +0 -0
  29. {prodc-0.3.62 → prodc-0.3.63}/tests/test_dedup.py +0 -0
  30. {prodc-0.3.62 → prodc-0.3.63}/tests/test_description.py +0 -0
  31. {prodc-0.3.62 → prodc-0.3.63}/tests/test_drafts.py +0 -0
  32. {prodc-0.3.62 → prodc-0.3.63}/tests/test_external_adapter.py +0 -0
  33. {prodc-0.3.62 → prodc-0.3.63}/tests/test_gitlab_source.py +0 -0
  34. {prodc-0.3.62 → prodc-0.3.63}/tests/test_importance.py +0 -0
  35. {prodc-0.3.62 → prodc-0.3.63}/tests/test_loader.py +0 -0
  36. {prodc-0.3.62 → prodc-0.3.63}/tests/test_models.py +0 -0
  37. {prodc-0.3.62 → prodc-0.3.63}/tests/test_report.py +0 -0
  38. {prodc-0.3.62 → prodc-0.3.63}/tests/test_report_html.py +0 -0
  39. {prodc-0.3.62 → prodc-0.3.63}/tests/test_roles.py +0 -0
  40. {prodc-0.3.62 → prodc-0.3.63}/tests/test_sources.py +0 -0
  41. {prodc-0.3.62 → prodc-0.3.63}/tests/test_staleness.py +0 -0
  42. {prodc-0.3.62 → prodc-0.3.63}/tests/test_status.py +0 -0
  43. {prodc-0.3.62 → prodc-0.3.63}/tests/test_suggest.py +0 -0
  44. {prodc-0.3.62 → prodc-0.3.63}/tests/test_variants.py +0 -0
  45. {prodc-0.3.62 → prodc-0.3.63}/tests/test_who_variants.py +0 -0
  46. {prodc-0.3.62 → prodc-0.3.63}/tests/test_writer.py +0 -0
  47. {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.62
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.62"
3
+ version = "0.3.63"
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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": [_need_payload(r) for r in results],
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
- results = resolve(needs, cfg.adapters, cfg.source_adapters)
632
- _annotate_board_warnings(results, needs)
633
- summary = summarize(results)
634
- orphan_map = orphans(needs, cfg.adapters)
635
- recs = recurrence(needs)
636
- goal_cov, ungoaled = goal_report(results, list(model.goals))
637
- rel_cov = release_report(results, list(model.releases))
638
- role_cov, unroled = role_report(results, list(model.roles))
639
- hos = handoffs(needs)
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 = _need_payload(r) # the shared core — never drifts from the board
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
- needs = list(model.needs)
62
- results = resolve(needs, cfg.adapters, cfg.source_adapters)
63
- summary = summarize(results)
64
- goal_cov, ungoaled = goal_report(results, list(model.goals))
65
- rel_cov = release_report(results, list(model.releases))
66
- role_cov, unroled = role_report(results, list(model.roles))
67
- hos = handoffs(needs)
68
- recs = recurrence(needs)
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, NeedResult
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
- needs = list(model.needs)
78
- results = resolve(needs, cfg.adapters, cfg.source_adapters)
79
- summary = summarize(results)
80
- goal_cov, ungoaled = goal_report(results, list(model.goals))
81
- rel_cov = release_report(results, list(model.releases))
82
- role_cov, unroled = role_report(results, list(model.roles))
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": [_need_payload(r) for r in results if r.need.product],
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(needs)
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(needs)
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