dctracker 1.0.0__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.
- dct/__init__.py +6 -0
- dct/cli.py +1659 -0
- dct/config.py +150 -0
- dct/core/__init__.py +0 -0
- dct/core/analytics.py +382 -0
- dct/core/changelog.py +112 -0
- dct/core/checkpoints.py +205 -0
- dct/core/decisions.py +238 -0
- dct/core/engine.py +32 -0
- dct/core/handoff.py +151 -0
- dct/core/ids.py +25 -0
- dct/core/items.py +382 -0
- dct/core/plans/__init__.py +6 -0
- dct/core/plans/classify.py +94 -0
- dct/core/plans/crud.py +680 -0
- dct/core/plans/ingest.py +408 -0
- dct/core/plans/parser.py +312 -0
- dct/core/projects.py +298 -0
- dct/core/sprint.py +315 -0
- dct/core/sprints.py +601 -0
- dct/core/version.py +116 -0
- dct/db.py +558 -0
- dct/export.py +107 -0
- dct/hooks/check-changelog-on-stop.sh +49 -0
- dct/hooks/check-changelog.sh +143 -0
- dct/hooks/dct-plan-autoscan.sh +54 -0
- dct/hooks/dct-task-mirror.sh +62 -0
- dct/server.py +1812 -0
- dct/setup.py +258 -0
- dct/skills/clog/SKILL.md +73 -0
- dct/skills/commit/SKILL.md +153 -0
- dct/skills/dct-import/SKILL.md +329 -0
- dct/skills/dct-init/SKILL.md +289 -0
- dct/skills/handoff/SKILL.md +136 -0
- dct/skills/pickup/SKILL.md +115 -0
- dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
- dct/skills/plan-status/SKILL.md +79 -0
- dct/skills/release/SKILL.md +281 -0
- dct/skills/track/SKILL.md +121 -0
- dct/skills/track-list/SKILL.md +134 -0
- dct/skills/track-resolve/SKILL.md +53 -0
- dct/skills/track-update/SKILL.md +109 -0
- dct/web/__init__.py +1 -0
- dct/web/app.py +83 -0
- dct/web/blueprints/__init__.py +5 -0
- dct/web/blueprints/analytics.py +34 -0
- dct/web/blueprints/changelog.py +40 -0
- dct/web/blueprints/decisions.py +23 -0
- dct/web/blueprints/events.py +69 -0
- dct/web/blueprints/handoffs.py +26 -0
- dct/web/blueprints/home.py +15 -0
- dct/web/blueprints/items.py +128 -0
- dct/web/blueprints/plans.py +53 -0
- dct/web/blueprints/projects.py +23 -0
- dct/web/blueprints/sprints.py +71 -0
- dct/web/changes.py +77 -0
- dct/web/serializers.py +109 -0
- dct/web/static/app.css +609 -0
- dct/web/static/app.js +62 -0
- dct/web/static/vendor/SOURCES.txt +10 -0
- dct/web/static/vendor/htmx.min.js +1 -0
- dct/web/static/vendor/uPlot.iife.min.js +2 -0
- dct/web/static/vendor/uPlot.min.css +1 -0
- dct/web/templates/analytics.html +63 -0
- dct/web/templates/base.html +106 -0
- dct/web/templates/changelog/list.html +23 -0
- dct/web/templates/decisions/list.html +22 -0
- dct/web/templates/handoffs/list.html +45 -0
- dct/web/templates/home.html +20 -0
- dct/web/templates/item_detail.html +91 -0
- dct/web/templates/items_list.html +44 -0
- dct/web/templates/partials/_bars.html +15 -0
- dct/web/templates/partials/_checkpoint_steps.html +22 -0
- dct/web/templates/partials/_items_table.html +23 -0
- dct/web/templates/partials/_plan_section.html +24 -0
- dct/web/templates/partials/_project_card.html +24 -0
- dct/web/templates/plans/detail.html +16 -0
- dct/web/templates/plans/list.html +15 -0
- dct/web/templates/project_home.html +51 -0
- dct/web/templates/sprints/detail.html +37 -0
- dct/web/templates/sprints/list.html +35 -0
- dctracker-1.0.0.dist-info/METADATA +357 -0
- dctracker-1.0.0.dist-info/RECORD +87 -0
- dctracker-1.0.0.dist-info/WHEEL +5 -0
- dctracker-1.0.0.dist-info/entry_points.txt +2 -0
- dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
- dctracker-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Sprint views — list (both models, labeled) + detail (members + steps)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flask import Blueprint, abort, g, render_template
|
|
6
|
+
|
|
7
|
+
from dct.web.app import current_engine
|
|
8
|
+
from dct.core import sprints as sprints_core
|
|
9
|
+
from dct.core import checkpoints as checkpoints_core
|
|
10
|
+
from dct.core import projects as projects_core
|
|
11
|
+
|
|
12
|
+
bp = Blueprint("sprints", __name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@bp.route("/p/<slug>/sprints")
|
|
16
|
+
def list(slug): # noqa: A001 — Flask endpoint name is intentional (sprints.list)
|
|
17
|
+
engine = current_engine()
|
|
18
|
+
first_class = sprints_core.list_sprints(engine, project_slug=slug)
|
|
19
|
+
# Current sprint = the project's single active sprint; its members come from
|
|
20
|
+
# item_sprints (one source of truth, #216). No more legacy-boolean section.
|
|
21
|
+
current = sprints_core.get_current_sprint(engine, slug)
|
|
22
|
+
current_items = (
|
|
23
|
+
sprints_core.list_items_in_sprint(engine, current["id"]) if current else []
|
|
24
|
+
)
|
|
25
|
+
return render_template(
|
|
26
|
+
"sprints/list.html",
|
|
27
|
+
slug=slug,
|
|
28
|
+
sprints=first_class,
|
|
29
|
+
current=current,
|
|
30
|
+
current_items=current_items,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@bp.route("/sprints/<int:sprint_id>")
|
|
35
|
+
def detail(sprint_id):
|
|
36
|
+
engine = current_engine()
|
|
37
|
+
sprint = sprints_core.get_sprint(engine, sprint_id)
|
|
38
|
+
if sprint is None:
|
|
39
|
+
abort(404)
|
|
40
|
+
# get_sprint returns project_id (int) but NO slug; resolve it so member
|
|
41
|
+
# links can build url_for('items.detail', slug=..., id=...). projects core
|
|
42
|
+
# has no get-by-id, so map id->slug from list_projects.
|
|
43
|
+
slug = next(
|
|
44
|
+
(p["slug"] for p in projects_core.list_projects(engine)
|
|
45
|
+
if p["id"] == sprint["project_id"]),
|
|
46
|
+
None,
|
|
47
|
+
)
|
|
48
|
+
# Scope the sidebar + breadcrumb to the owning project (no slug in the URL).
|
|
49
|
+
g.current_slug = slug
|
|
50
|
+
g.current_section = "sprints"
|
|
51
|
+
members = sprints_core.list_items_in_sprint(engine, sprint_id)
|
|
52
|
+
rows = []
|
|
53
|
+
done = total = 0
|
|
54
|
+
for it in members:
|
|
55
|
+
cps = checkpoints_core.list_checkpoints(
|
|
56
|
+
engine, it["id"], include_rejected=True
|
|
57
|
+
)
|
|
58
|
+
rows.append({"item": it, "checkpoints": cps})
|
|
59
|
+
for cp in cps:
|
|
60
|
+
if cp["status"] != "rejected":
|
|
61
|
+
total += 1
|
|
62
|
+
if cp["status"] == "done":
|
|
63
|
+
done += 1
|
|
64
|
+
return render_template(
|
|
65
|
+
"sprints/detail.html",
|
|
66
|
+
sprint=sprint,
|
|
67
|
+
slug=slug,
|
|
68
|
+
rows=rows,
|
|
69
|
+
cp_done=done,
|
|
70
|
+
cp_total=total,
|
|
71
|
+
)
|
dct/web/changes.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Cheap composite change-token for SSE live refresh.
|
|
2
|
+
|
|
3
|
+
The token is two halves joined by ':':
|
|
4
|
+
1. max(updated_at) across every table that carries an updated_at column
|
|
5
|
+
— advances on any UPDATE (core stamps updated_at with microsecond precision).
|
|
6
|
+
2. per-table live row counts (deleted_at IS NULL; item_sprints uses removed_at
|
|
7
|
+
IS NULL) — advances on any INSERT or SOFT-DELETE, including child rows
|
|
8
|
+
(notes, checkpoints, links) that never touch a parent's updated_at.
|
|
9
|
+
|
|
10
|
+
Stable when the DB is unchanged. Read-only: SELECTs only, never DDL.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
|
|
15
|
+
from dct.db import (
|
|
16
|
+
projects,
|
|
17
|
+
items,
|
|
18
|
+
item_notes,
|
|
19
|
+
item_checkpoints,
|
|
20
|
+
changelog_entries,
|
|
21
|
+
session_handoffs,
|
|
22
|
+
sprints,
|
|
23
|
+
plans,
|
|
24
|
+
plan_sections,
|
|
25
|
+
plan_checkpoints,
|
|
26
|
+
item_sprints,
|
|
27
|
+
decisions,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Tables carrying an updated_at column — feed the freshness half of the token.
|
|
31
|
+
_UPDATED_AT_TABLES = (
|
|
32
|
+
projects,
|
|
33
|
+
items,
|
|
34
|
+
item_checkpoints,
|
|
35
|
+
changelog_entries,
|
|
36
|
+
sprints,
|
|
37
|
+
plans,
|
|
38
|
+
plan_sections,
|
|
39
|
+
plan_checkpoints,
|
|
40
|
+
decisions,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# (table, live_predicate) — count of non-deleted rows feeds the count half.
|
|
44
|
+
# item_notes / session_handoffs / item_sprints have no updated_at, so their
|
|
45
|
+
# mutations are visible only through this row-count half.
|
|
46
|
+
_COUNT_TABLES = (
|
|
47
|
+
(projects, projects.c.deleted_at.is_(None)),
|
|
48
|
+
(items, items.c.deleted_at.is_(None)),
|
|
49
|
+
(item_notes, item_notes.c.deleted_at.is_(None)),
|
|
50
|
+
(item_checkpoints, item_checkpoints.c.deleted_at.is_(None)),
|
|
51
|
+
(changelog_entries, changelog_entries.c.deleted_at.is_(None)),
|
|
52
|
+
(session_handoffs, session_handoffs.c.deleted_at.is_(None)),
|
|
53
|
+
(sprints, sprints.c.deleted_at.is_(None)),
|
|
54
|
+
(plans, plans.c.deleted_at.is_(None)),
|
|
55
|
+
(plan_sections, plan_sections.c.deleted_at.is_(None)),
|
|
56
|
+
(plan_checkpoints, plan_checkpoints.c.deleted_at.is_(None)),
|
|
57
|
+
(item_sprints, item_sprints.c.removed_at.is_(None)),
|
|
58
|
+
(decisions, decisions.c.deleted_at.is_(None)),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def change_token(engine: sa.Engine) -> str:
|
|
63
|
+
"""Return a cheap composite token that changes on any tracked mutation."""
|
|
64
|
+
parts: list[str] = []
|
|
65
|
+
with engine.connect() as conn:
|
|
66
|
+
newest = None
|
|
67
|
+
for table in _UPDATED_AT_TABLES:
|
|
68
|
+
value = conn.execute(sa.select(sa.func.max(table.c.updated_at))).scalar()
|
|
69
|
+
if value is not None and (newest is None or value > newest):
|
|
70
|
+
newest = value
|
|
71
|
+
parts.append(str(newest) if newest is not None else "-")
|
|
72
|
+
for table, predicate in _COUNT_TABLES:
|
|
73
|
+
count = conn.execute(
|
|
74
|
+
sa.select(sa.func.count()).select_from(table).where(predicate)
|
|
75
|
+
).scalar()
|
|
76
|
+
parts.append(str(count or 0))
|
|
77
|
+
return ":".join(parts)
|
dct/web/serializers.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Serialization helpers for the dct web viewer.
|
|
2
|
+
|
|
3
|
+
JSON-native conversions shared by every blueprint:
|
|
4
|
+
- datetimes -> ISO 8601 strings,
|
|
5
|
+
- the JSON-in-TEXT `tags` column -> a real list,
|
|
6
|
+
- markdown prose -> sanitized HTML (raw HTML escaped, never executed).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
import markdown as _markdown
|
|
14
|
+
import markupsafe
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_iso(dt) -> str | None:
|
|
18
|
+
"""datetime -> ISO 8601 string; None passes through; other types stringified."""
|
|
19
|
+
if dt is None:
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(dt, datetime):
|
|
22
|
+
return dt.isoformat()
|
|
23
|
+
return str(dt)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_tags(raw) -> list[str]:
|
|
27
|
+
"""Tolerant parse of the `tags` field.
|
|
28
|
+
|
|
29
|
+
Accepts JSON-encoded TEXT (``'["a","b"]'``), ``None``, an already-decoded
|
|
30
|
+
list, or a bare string. Always returns ``list[str]`` and never raises.
|
|
31
|
+
"""
|
|
32
|
+
if raw is None:
|
|
33
|
+
return []
|
|
34
|
+
if isinstance(raw, list):
|
|
35
|
+
return [str(t) for t in raw]
|
|
36
|
+
if isinstance(raw, str):
|
|
37
|
+
s = raw.strip()
|
|
38
|
+
if not s:
|
|
39
|
+
return []
|
|
40
|
+
try:
|
|
41
|
+
parsed = json.loads(s)
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
return [s]
|
|
44
|
+
if isinstance(parsed, list):
|
|
45
|
+
return [str(t) for t in parsed]
|
|
46
|
+
return [str(parsed)]
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Markdown link syntax can emit dangerous-scheme URIs (e.g. `[x](javascript:alert(1))`)
|
|
51
|
+
# even though raw HTML is escaped upstream. Rewrite such href/src values to "#" so a
|
|
52
|
+
# malicious description/note can't smuggle a javascript:/data: link into the page.
|
|
53
|
+
_UNSAFE_URI = re.compile(
|
|
54
|
+
r"""(href|src)\s*=\s*(["'])\s*(?:javascript|vbscript|data|file)\s*:[^"']*\2""",
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _neutralize_unsafe_links(html: str) -> str:
|
|
60
|
+
return _UNSAFE_URI.sub(lambda m: f"{m.group(1)}={m.group(2)}#{m.group(2)}", html)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- readability: paragraph-split long unstructured prose ------------------
|
|
64
|
+
# Many dct descriptions/notes are stored as ONE continuous block (zero newlines)
|
|
65
|
+
# and render as an unreadable wall. When a block has no paragraph breaks and no
|
|
66
|
+
# markdown block syntax, we split it on sentence boundaries and regroup so the
|
|
67
|
+
# renderer emits real <p> breaks. Conservative: structured/short text is left alone.
|
|
68
|
+
_ABBREV = ("e.g.", "i.e.", "etc.", "vs.", "cf.", "al.", "inc.", "approx.", "no.", "fig.")
|
|
69
|
+
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z(`])")
|
|
70
|
+
_BLOCK_MD = re.compile(r"(?m)^\s*(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _paragraphize(text: str, group: int = 2) -> str:
|
|
74
|
+
if "\n\n" in text or _BLOCK_MD.search(text) or len(text) <= 300:
|
|
75
|
+
return text
|
|
76
|
+
parts = _SENTENCE_SPLIT.split(text.strip())
|
|
77
|
+
sentences: list[str] = []
|
|
78
|
+
for part in parts:
|
|
79
|
+
if sentences and sentences[-1].rstrip().lower().endswith(_ABBREV):
|
|
80
|
+
sentences[-1] = f"{sentences[-1]} {part}"
|
|
81
|
+
else:
|
|
82
|
+
sentences.append(part)
|
|
83
|
+
if len(sentences) < 3:
|
|
84
|
+
return text
|
|
85
|
+
return "\n\n".join(
|
|
86
|
+
" ".join(sentences[i:i + group]) for i in range(0, len(sentences), group)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_markdown(text: str | None) -> markupsafe.Markup:
|
|
91
|
+
"""Render markdown to HTML with raw HTML escaped (defense-in-depth).
|
|
92
|
+
|
|
93
|
+
We escape the source *before* handing it to python-markdown, so any raw
|
|
94
|
+
``<script>`` / ``<img onerror=...>`` becomes inert text while ordinary
|
|
95
|
+
markdown syntax (emphasis, lists, code fences, tables) still renders.
|
|
96
|
+
Long unstructured blocks are paragraph-split first for readability.
|
|
97
|
+
Returns a ``Markup`` so Jinja2 treats it as safe to emit verbatim.
|
|
98
|
+
"""
|
|
99
|
+
if not text:
|
|
100
|
+
return markupsafe.Markup("")
|
|
101
|
+
text = _paragraphize(text)
|
|
102
|
+
escaped = str(markupsafe.escape(text))
|
|
103
|
+
html = _markdown.markdown(
|
|
104
|
+
escaped,
|
|
105
|
+
extensions=["fenced_code", "tables"],
|
|
106
|
+
output_format="html",
|
|
107
|
+
)
|
|
108
|
+
html = _neutralize_unsafe_links(html)
|
|
109
|
+
return markupsafe.Markup(html)
|