feature-flow-cli 0.1.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.
- feature_flow/__init__.py +3 -0
- feature_flow/__main__.py +7 -0
- feature_flow/_bundle/adapters/codex/feature-flow/SKILL.md +62 -0
- feature_flow/_bundle/adapters/codex/feature-flow/agents/openai.yaml +6 -0
- feature_flow/_bundle/agents/ticket-builder.md +24 -0
- feature_flow/_bundle/agents/ticket-reviewer.md +16 -0
- feature_flow/_bundle/guides/build.md +139 -0
- feature_flow/_bundle/guides/plan.md +93 -0
- feature_flow/_bundle/guides/review.md +87 -0
- feature_flow/_bundle/guides/show.md +30 -0
- feature_flow/_bundle/guides/templates/commands.md +15 -0
- feature_flow/_bundle/guides/templates/learnings.md +7 -0
- feature_flow/_bundle/guides/templates/map.md +26 -0
- feature_flow/_bundle/guides/templates/spec.md +62 -0
- feature_flow/_bundle/guides/templates/ticket.md +29 -0
- feature_flow/_bundle/guides/templates/ui-mockup.md +43 -0
- feature_flow/_bundle/scripts/floor-guard.py +14 -0
- feature_flow/_bundle/scripts/flow-status.py +14 -0
- feature_flow/_bundle/scripts/flow-view.html +536 -0
- feature_flow/_bundle/scripts/flow-view.py +14 -0
- feature_flow/_bundle/scripts/flow.py +14 -0
- feature_flow/_bundle/scripts/gate.py +14 -0
- feature_flow/_bundle/skills/architect-review/SKILL.md +143 -0
- feature_flow/_bundle/skills/automation-design/SKILL.md +274 -0
- feature_flow/_bundle/skills/feature-flow/SKILL.md +62 -0
- feature_flow/checks.py +71 -0
- feature_flow/cli.py +47 -0
- feature_flow/command.py +39 -0
- feature_flow/conductor.py +299 -0
- feature_flow/floorguard.py +340 -0
- feature_flow/gate.py +100 -0
- feature_flow/git.py +47 -0
- feature_flow/install.py +365 -0
- feature_flow/prompts.py +52 -0
- feature_flow/state.py +75 -0
- feature_flow/status.py +231 -0
- feature_flow/tickets.py +168 -0
- feature_flow/view.py +312 -0
- feature_flow_cli-0.1.0.dist-info/METADATA +293 -0
- feature_flow_cli-0.1.0.dist-info/RECORD +44 -0
- feature_flow_cli-0.1.0.dist-info/WHEEL +5 -0
- feature_flow_cli-0.1.0.dist-info/entry_points.txt +3 -0
- feature_flow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- feature_flow_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# UI mockups
|
|
2
|
+
|
|
3
|
+
Use only when the feature creates or changes visible screens or components. Paste the result
|
|
4
|
+
into the `## UI` section of spec.md.
|
|
5
|
+
|
|
6
|
+
Rules:
|
|
7
|
+
|
|
8
|
+
- ASCII box drawing only: `┌ ┐ └ ┘ │ ─ ├ ┤`. At most 50 columns wide.
|
|
9
|
+
- One mockup per distinct visual state: open, closed, empty, loaded, error. Label each with a heading.
|
|
10
|
+
- Realistic text, never lorem ipsum. Show buttons as `[ Button ]`, inputs as `[________]`,
|
|
11
|
+
checked items as `✓`.
|
|
12
|
+
- Order components the way the user meets them. Show shared screen space when components share it.
|
|
13
|
+
- This is a reference for the person building it. Accuracy matters more than looks.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
|
|
17
|
+
### ExportMenu — open, CSV selected
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
┌─────────────────────────────────┐
|
|
21
|
+
│ Export ▼ │
|
|
22
|
+
├─────────────────────────────────┤
|
|
23
|
+
│ Format │
|
|
24
|
+
│ ┌─────────────────────────┐ │
|
|
25
|
+
│ │ ✓ CSV (default) │ │
|
|
26
|
+
│ │ JSON │ │
|
|
27
|
+
│ │ Excel │ │
|
|
28
|
+
│ └─────────────────────────┘ │
|
|
29
|
+
│─────────────────────────────────│
|
|
30
|
+
│ Recent exports │
|
|
31
|
+
│ · orders-2025-01.csv │
|
|
32
|
+
│ · customers-2025-01.csv │
|
|
33
|
+
│ [ Export ] │
|
|
34
|
+
└─────────────────────────────────┘
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### ExportMenu — closed
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
┌─────────────────────────────────┐
|
|
41
|
+
│ Export [CSV] │
|
|
42
|
+
└─────────────────────────────────┘
|
|
43
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The floor guard. usage: python3 scripts/floor-guard.py <feature> <NN> [base-commit]"""
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True # never leave __pycache__ in the user's tree
|
|
7
|
+
here = Path(__file__).resolve().parent
|
|
8
|
+
for root in (here.parent, here.parent / ".feature-flow"):
|
|
9
|
+
if (root / "feature_flow" / "__init__.py").is_file():
|
|
10
|
+
sys.path.insert(0, str(root))
|
|
11
|
+
break
|
|
12
|
+
from feature_flow.floorguard import main
|
|
13
|
+
|
|
14
|
+
sys.exit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The ticket graph reader. usage: python3 scripts/flow-status.py <feature> [--next | --counts | --check | --mermaid [plain] | --json]"""
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True # never leave __pycache__ in the user's tree
|
|
7
|
+
here = Path(__file__).resolve().parent
|
|
8
|
+
for root in (here.parent, here.parent / ".feature-flow"):
|
|
9
|
+
if (root / "feature_flow" / "__init__.py").is_file():
|
|
10
|
+
sys.path.insert(0, str(root))
|
|
11
|
+
break
|
|
12
|
+
from feature_flow.status import main
|
|
13
|
+
|
|
14
|
+
sys.exit(main())
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="auto">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<!--__REFRESH__-->
|
|
7
|
+
<title>Ticket flow</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0d1117; --panel: #161b22; --panel2: #1c232c; --line: #30363d; --text: #e6edf3; --muted: #8b949e;
|
|
11
|
+
--resolved: #3fb950; --ready: #f0b429; --claimed: #58a6ff; --open: #8b949e; --waiting: #f85149; --parked: #6e7681;
|
|
12
|
+
--danger: #f85149; --card: #1b222b; --shadow: 0 8px 28px rgba(0, 0, 0, .45);
|
|
13
|
+
}
|
|
14
|
+
:root[data-theme="light"] {
|
|
15
|
+
--bg: #ffffff; --panel: #f6f8fa; --panel2: #eef1f4; --line: #d0d7de; --text: #1f2328; --muted: #656d76;
|
|
16
|
+
--resolved: #1a7f37; --ready: #bf8700; --claimed: #0969da; --open: #6e7781; --waiting: #cf222e; --parked: #8c959f;
|
|
17
|
+
--danger: #cf222e; --card: #ffffff; --shadow: 0 8px 24px rgba(31, 35, 40, .15);
|
|
18
|
+
}
|
|
19
|
+
@media (prefers-color-scheme: light) {
|
|
20
|
+
:root[data-theme="auto"] {
|
|
21
|
+
--bg: #ffffff; --panel: #f6f8fa; --panel2: #eef1f4; --line: #d0d7de; --text: #1f2328; --muted: #656d76;
|
|
22
|
+
--resolved: #1a7f37; --ready: #bf8700; --claimed: #0969da; --open: #6e7781; --waiting: #cf222e; --parked: #8c959f;
|
|
23
|
+
--danger: #cf222e; --card: #ffffff; --shadow: 0 8px 24px rgba(31, 35, 40, .15);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
html, body { margin: 0; height: 100%; }
|
|
28
|
+
body {
|
|
29
|
+
background: var(--bg); color: var(--text);
|
|
30
|
+
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
31
|
+
display: flex; flex-direction: column; min-height: 100vh;
|
|
32
|
+
}
|
|
33
|
+
header { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
34
|
+
header h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: .2px; }
|
|
35
|
+
header .sub { color: var(--muted); font-size: 12px; }
|
|
36
|
+
.stats { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
|
|
37
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 12px; background: var(--panel2); white-space: nowrap; }
|
|
38
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
39
|
+
button, select {
|
|
40
|
+
font: inherit; color: var(--text); background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
|
41
|
+
}
|
|
42
|
+
button:hover, select:hover { border-color: var(--muted); }
|
|
43
|
+
button:focus-visible, select:focus-visible, input:focus-visible, .node:focus-visible { outline: 2px solid var(--claimed); outline-offset: 2px; }
|
|
44
|
+
main { flex: 1; display: grid; grid-template-columns: minmax(0, 1fr) 320px; min-height: 0; }
|
|
45
|
+
#stage { overflow: auto; padding: 16px; position: relative; display: flex; align-items: center; background-image: radial-gradient(var(--line) 1px, transparent 1px); background-size: 22px 22px; background-position: -11px -11px; }
|
|
46
|
+
#graph { display: block; margin: 0 auto; max-width: none; flex: 0 0 auto; }
|
|
47
|
+
#graph.fit { width: 100%; height: auto; }
|
|
48
|
+
aside { border-left: 1px solid var(--line); background: var(--panel); padding: 16px; overflow: auto; }
|
|
49
|
+
aside h2 { margin: 0 0 4px; font-size: 15px; }
|
|
50
|
+
aside .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
|
51
|
+
aside h3 { margin: 14px 0 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); }
|
|
52
|
+
aside ul { margin: 0; padding-left: 18px; }
|
|
53
|
+
aside li { margin: 2px 0; }
|
|
54
|
+
aside pre { white-space: pre-wrap; word-break: break-word; margin: 0; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
|
|
55
|
+
aside .empty { color: var(--muted); }
|
|
56
|
+
footer { border-top: 1px solid var(--line); background: var(--panel); padding: 10px 20px; display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
|
|
57
|
+
#scrub { flex: 1 1 220px; min-width: 160px; accent-color: var(--resolved); }
|
|
58
|
+
#clock { color: var(--muted); font-size: 12px; min-width: 190px; }
|
|
59
|
+
.legend { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--muted); font-size: 12px; width: 100%; }
|
|
60
|
+
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
61
|
+
|
|
62
|
+
/* graph */
|
|
63
|
+
.edge { fill: none; stroke: var(--line); stroke-width: 1.6; }
|
|
64
|
+
.edge.done { stroke: var(--resolved); opacity: .55; }
|
|
65
|
+
.edge.active { stroke: var(--ready); stroke-width: 2.2; stroke-dasharray: 7 9; animation: flow .9s linear infinite; }
|
|
66
|
+
.edge.hot { stroke: var(--text); opacity: 1; stroke-width: 2.4; }
|
|
67
|
+
@keyframes flow { to { stroke-dashoffset: -16; } }
|
|
68
|
+
.card { fill: var(--card); stroke: var(--line); stroke-width: 1.4; transition: stroke .3s, fill .3s; }
|
|
69
|
+
.node { cursor: pointer; }
|
|
70
|
+
.node .t { fill: var(--text); font: 600 13px -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
71
|
+
.node .l { fill: var(--muted); font: 600 11px ui-monospace, Menlo, monospace; }
|
|
72
|
+
.node .sub { fill: var(--muted); font: 11px -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
73
|
+
.node .badge { font: 700 11px -apple-system, "Segoe UI", sans-serif; }
|
|
74
|
+
.node.st-resolved .card { stroke: var(--resolved); }
|
|
75
|
+
.node.st-claimed .card { stroke: var(--claimed); }
|
|
76
|
+
.node.st-waiting .card { stroke: var(--waiting); }
|
|
77
|
+
.node.st-parked .card { stroke: var(--parked); stroke-dasharray: 5 4; }
|
|
78
|
+
.node.ready .card { stroke: var(--ready); }
|
|
79
|
+
.node.pinned .card, .node:hover .card { stroke-width: 2.6; }
|
|
80
|
+
.stripe.st-resolved { fill: var(--resolved); } .stripe.st-ready { fill: var(--ready); } .stripe.st-claimed { fill: var(--claimed); }
|
|
81
|
+
.stripe.st-open { fill: var(--open); } .stripe.st-waiting { fill: var(--waiting); } .stripe.st-parked { fill: var(--parked); }
|
|
82
|
+
.ring { fill: none; stroke: var(--ready); stroke-width: 2; opacity: 0; transform-box: fill-box; transform-origin: center; pointer-events: none; }
|
|
83
|
+
.node.ready .ring { animation: pulse 1.9s ease-out infinite; }
|
|
84
|
+
@keyframes pulse { 0% { opacity: .9; transform: scale(1); } 100% { opacity: 0; transform: scale(1.14, 1.34); } }
|
|
85
|
+
.spin { fill: none; stroke: var(--claimed); stroke-width: 2.2; stroke-dasharray: 5 4; transform-box: fill-box; transform-origin: center; opacity: 0; }
|
|
86
|
+
.node.st-claimed .spin { opacity: 1; animation: spin 1.2s linear infinite; }
|
|
87
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
88
|
+
.burst { fill: none; stroke: var(--resolved); stroke-width: 3; opacity: 0; transform-box: fill-box; transform-origin: center; pointer-events: none; }
|
|
89
|
+
.burst.go { animation: burst .9s ease-out 1; }
|
|
90
|
+
@keyframes burst { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(1.22, 1.7); } }
|
|
91
|
+
.node.sentback .card { animation: flash .9s ease-in-out 2; }
|
|
92
|
+
@keyframes flash { 0%, 100% { stroke: var(--danger); fill: var(--card); } 50% { stroke: var(--danger); fill: rgba(248, 81, 73, .25); } }
|
|
93
|
+
.traveller { fill: var(--resolved); filter: drop-shadow(0 0 4px var(--resolved)); pointer-events: none; }
|
|
94
|
+
.empty-msg { text-align: center; color: var(--muted); padding: 60px 20px; }
|
|
95
|
+
@media (max-width: 820px) { main { grid-template-columns: 1fr; } aside { border-left: 0; border-top: 1px solid var(--line); max-height: 40vh; } .stats { margin-left: 0; } }
|
|
96
|
+
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
|
97
|
+
</style>
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
<header>
|
|
101
|
+
<h1 id="feat">Ticket flow</h1>
|
|
102
|
+
<span class="sub" id="sub"></span>
|
|
103
|
+
<div class="stats" id="stats"></div>
|
|
104
|
+
<button id="fit" title="Switch between fit to window and full size">Fit</button>
|
|
105
|
+
<button id="theme" title="Switch light and dark (t)">Theme</button>
|
|
106
|
+
</header>
|
|
107
|
+
<main>
|
|
108
|
+
<div id="stage"><svg id="graph" class="fit" role="img" aria-label="Ticket dependency graph" xmlns="http://www.w3.org/2000/svg"></svg></div>
|
|
109
|
+
<aside id="detail" aria-live="polite"><p class="empty">Hover or click a ticket to see its details.</p></aside>
|
|
110
|
+
</main>
|
|
111
|
+
<footer>
|
|
112
|
+
<button id="restart" title="Back to the start (Home)">⏮</button>
|
|
113
|
+
<button id="play" title="Play or pause the replay (space)">▶ Replay</button>
|
|
114
|
+
<button id="stepb" title="Previous step (left arrow)">◀</button>
|
|
115
|
+
<button id="stepf" title="Next step (right arrow)">▶</button>
|
|
116
|
+
<input id="scrub" type="range" min="0" max="0" value="0" aria-label="Replay position">
|
|
117
|
+
<select id="speed" aria-label="Replay speed"><option value="2">0.5x</option><option value="1" selected>1x</option><option value="0.5">2x</option><option value="0.25">4x</option></select>
|
|
118
|
+
<button id="now" title="Show the current state (End)">Now</button>
|
|
119
|
+
<span id="clock"></span>
|
|
120
|
+
<div class="legend" id="legend"></div>
|
|
121
|
+
</footer>
|
|
122
|
+
<script id="flow-data" type="application/json">__FLOW_DATA__</script>
|
|
123
|
+
<script>
|
|
124
|
+
"use strict";
|
|
125
|
+
|
|
126
|
+
// <layout>
|
|
127
|
+
function layoutGraph(tickets, o) {
|
|
128
|
+
var byLabel = {};
|
|
129
|
+
tickets.forEach(function (t) { byLabel[t.label] = t; });
|
|
130
|
+
var layer = {}, visiting = {};
|
|
131
|
+
function depth(t) {
|
|
132
|
+
if (layer[t.label] !== undefined) return layer[t.label];
|
|
133
|
+
if (visiting[t.label]) return 0;
|
|
134
|
+
visiting[t.label] = true;
|
|
135
|
+
var d = 0;
|
|
136
|
+
t.blocked_by.forEach(function (b) { if (byLabel[b]) d = Math.max(d, depth(byLabel[b]) + 1); });
|
|
137
|
+
visiting[t.label] = false;
|
|
138
|
+
layer[t.label] = d;
|
|
139
|
+
return d;
|
|
140
|
+
}
|
|
141
|
+
tickets.forEach(depth);
|
|
142
|
+
var maxLayer = 0;
|
|
143
|
+
tickets.forEach(function (t) { maxLayer = Math.max(maxLayer, layer[t.label]); });
|
|
144
|
+
var cols = [], c;
|
|
145
|
+
for (c = 0; c <= maxLayer; c++) cols.push([]);
|
|
146
|
+
tickets.forEach(function (t) { cols[layer[t.label]].push(t.label); });
|
|
147
|
+
var succ = {};
|
|
148
|
+
tickets.forEach(function (t) {
|
|
149
|
+
t.blocked_by.forEach(function (b) { if (byLabel[b]) (succ[b] = succ[b] || []).push(t.label); });
|
|
150
|
+
});
|
|
151
|
+
var pos = {};
|
|
152
|
+
function index() { cols.forEach(function (col) { col.forEach(function (l, i) { pos[l] = i; }); }); }
|
|
153
|
+
function avg(list, fallback) {
|
|
154
|
+
if (!list.length) return fallback;
|
|
155
|
+
return list.reduce(function (a, l) { return a + pos[l]; }, 0) / list.length;
|
|
156
|
+
}
|
|
157
|
+
function byBary(fn) {
|
|
158
|
+
return function (a, b) { return (fn(a) - fn(b)) || (byLabel[a].id - byLabel[b].id); };
|
|
159
|
+
}
|
|
160
|
+
index();
|
|
161
|
+
for (var sweep = 0; sweep < 4; sweep++) {
|
|
162
|
+
for (c = 1; c <= maxLayer; c++) {
|
|
163
|
+
cols[c].sort(byBary(function (l) { return avg(byLabel[l].blocked_by.filter(function (b) { return byLabel[b]; }), pos[l]); }));
|
|
164
|
+
index();
|
|
165
|
+
}
|
|
166
|
+
for (c = maxLayer - 1; c >= 0; c--) {
|
|
167
|
+
cols[c].sort(byBary(function (l) { return avg(succ[l] || [], pos[l]); }));
|
|
168
|
+
index();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
var tallest = 0;
|
|
172
|
+
cols.forEach(function (col) { tallest = Math.max(tallest, col.length); });
|
|
173
|
+
var colH = function (n) { return n * o.nodeH + Math.max(0, n - 1) * o.rowGap; };
|
|
174
|
+
var innerH = colH(tallest);
|
|
175
|
+
var nodes = {};
|
|
176
|
+
cols.forEach(function (col, ci) {
|
|
177
|
+
var y0 = o.pad + (innerH - colH(col.length)) / 2;
|
|
178
|
+
col.forEach(function (l, i) {
|
|
179
|
+
nodes[l] = { x: o.pad + ci * (o.nodeW + o.colGap), y: y0 + i * (o.nodeH + o.rowGap), layer: ci };
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
var edges = [];
|
|
183
|
+
tickets.forEach(function (t) {
|
|
184
|
+
t.blocked_by.forEach(function (b) { if (byLabel[b]) edges.push({ from: b, to: t.label }); });
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
nodes: nodes, edges: edges,
|
|
188
|
+
width: o.pad * 2 + (maxLayer + 1) * o.nodeW + maxLayer * o.colGap,
|
|
189
|
+
height: o.pad * 2 + innerH
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// </layout>
|
|
193
|
+
|
|
194
|
+
// <replay>
|
|
195
|
+
function readyOf(tickets, state) {
|
|
196
|
+
var r = {};
|
|
197
|
+
tickets.forEach(function (t) {
|
|
198
|
+
r[t.label] = state[t.label] === "open" && t.blocked_by.every(function (b) { return state[b] === "resolved"; });
|
|
199
|
+
});
|
|
200
|
+
return r;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildFrames(tickets, details) {
|
|
204
|
+
var events = [];
|
|
205
|
+
tickets.forEach(function (t) {
|
|
206
|
+
var h = (details[t.label] && details[t.label].history) || [];
|
|
207
|
+
h.forEach(function (e, i) { events.push({ t: e.t, label: t.label, status: e.status, first: i === 0 }); });
|
|
208
|
+
});
|
|
209
|
+
events.sort(function (a, b) { return (a.t - b.t) || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0); });
|
|
210
|
+
var state = {};
|
|
211
|
+
tickets.forEach(function (t) { state[t.label] = "open"; });
|
|
212
|
+
events.forEach(function (e) { if (e.first) state[e.label] = e.status; });
|
|
213
|
+
var copy = function (s) { var o = {}; Object.keys(s).forEach(function (k) { o[k] = s[k]; }); return o; };
|
|
214
|
+
var frames = [{ t: events.length ? events[0].t : null, state: copy(state), change: null }];
|
|
215
|
+
events.forEach(function (e) {
|
|
216
|
+
if (e.first || state[e.label] === e.status) return;
|
|
217
|
+
var from = state[e.label];
|
|
218
|
+
state[e.label] = e.status;
|
|
219
|
+
frames.push({ t: e.t, state: copy(state), change: { label: e.label, from: from, to: e.status } });
|
|
220
|
+
});
|
|
221
|
+
return frames;
|
|
222
|
+
}
|
|
223
|
+
// </replay>
|
|
224
|
+
|
|
225
|
+
(function () {
|
|
226
|
+
var NS = "http://www.w3.org/2000/svg";
|
|
227
|
+
var data = JSON.parse(document.getElementById("flow-data").textContent);
|
|
228
|
+
var tickets = data.tickets || [];
|
|
229
|
+
var details = data.details || {};
|
|
230
|
+
var O = { nodeW: 214, nodeH: 66, colGap: 96, rowGap: 24, pad: 36 };
|
|
231
|
+
var STATUS_COLOR = { resolved: "var(--resolved)", ready: "var(--ready)", claimed: "var(--claimed)", open: "var(--open)", waiting: "var(--waiting)", parked: "var(--parked)", unknown: "var(--open)" };
|
|
232
|
+
var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
233
|
+
|
|
234
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
235
|
+
function el(tag, attrs, text) {
|
|
236
|
+
var e = document.createElementNS(NS, tag);
|
|
237
|
+
Object.keys(attrs || {}).forEach(function (k) { e.setAttribute(k, attrs[k]); });
|
|
238
|
+
if (text !== undefined) e.textContent = text;
|
|
239
|
+
return e;
|
|
240
|
+
}
|
|
241
|
+
function h(tag, attrs, text) {
|
|
242
|
+
var e = document.createElement(tag);
|
|
243
|
+
Object.keys(attrs || {}).forEach(function (k) { e.setAttribute(k, attrs[k]); });
|
|
244
|
+
if (text !== undefined) e.textContent = text;
|
|
245
|
+
return e;
|
|
246
|
+
}
|
|
247
|
+
function ellipsize(s, n) { return s.length > n ? s.slice(0, n - 1) + "…" : s; }
|
|
248
|
+
|
|
249
|
+
$("feat").textContent = data.feature ? "Ticket flow: " + data.feature : "Ticket flow";
|
|
250
|
+
$("sub").textContent = data.generated ? "generated " + data.generated : "";
|
|
251
|
+
document.title = $("feat").textContent;
|
|
252
|
+
|
|
253
|
+
var legend = $("legend");
|
|
254
|
+
[["resolved", "resolved"], ["ready", "ready to start"], ["claimed", "being worked"], ["open", "waiting on a blocker"], ["waiting", "needs a human"], ["parked", "parked"]].forEach(function (p) {
|
|
255
|
+
var s = h("span");
|
|
256
|
+
var d = h("i", { "class": "dot" }); d.style.background = STATUS_COLOR[p[0]];
|
|
257
|
+
s.appendChild(d); s.appendChild(document.createTextNode(p[1]));
|
|
258
|
+
legend.appendChild(s);
|
|
259
|
+
});
|
|
260
|
+
legend.appendChild(h("span", {}, "dashes flow into tickets that are ready to start"));
|
|
261
|
+
|
|
262
|
+
var svg = $("graph");
|
|
263
|
+
if (!tickets.length) {
|
|
264
|
+
$("stage").replaceChildren(h("p", { "class": "empty-msg" }, "No tickets found."));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
var L = layoutGraph(tickets, O);
|
|
268
|
+
svg.setAttribute("viewBox", "0 0 " + L.width + " " + L.height);
|
|
269
|
+
svg.setAttribute("width", L.width);
|
|
270
|
+
svg.setAttribute("height", L.height);
|
|
271
|
+
var defs = el("defs");
|
|
272
|
+
svg.appendChild(defs);
|
|
273
|
+
var edgeLayer = el("g"), nodeLayer = el("g"), fxLayer = el("g");
|
|
274
|
+
svg.appendChild(edgeLayer); svg.appendChild(nodeLayer); svg.appendChild(fxLayer);
|
|
275
|
+
|
|
276
|
+
var edgeEls = {};
|
|
277
|
+
L.edges.forEach(function (e) {
|
|
278
|
+
var a = L.nodes[e.from], b = L.nodes[e.to];
|
|
279
|
+
var x1 = a.x + O.nodeW, y1 = a.y + O.nodeH / 2, x2 = b.x, y2 = b.y + O.nodeH / 2, dx = Math.max(40, (x2 - x1) / 2);
|
|
280
|
+
var path = el("path", { "class": "edge", d: "M" + x1 + "," + y1 + " C" + (x1 + dx) + "," + y1 + " " + (x2 - dx) + "," + y2 + " " + x2 + "," + y2 });
|
|
281
|
+
edgeLayer.appendChild(path);
|
|
282
|
+
edgeEls[e.from + ">" + e.to] = path;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
var byLabel = {}, nodeEls = {};
|
|
286
|
+
tickets.forEach(function (t) { byLabel[t.label] = t; });
|
|
287
|
+
tickets.forEach(function (t) {
|
|
288
|
+
var p = L.nodes[t.label];
|
|
289
|
+
var g = el("g", { "class": "node", transform: "translate(" + p.x + "," + p.y + ")", tabindex: "0", role: "button" });
|
|
290
|
+
g.appendChild(el("title", {}, t.label + " " + t.title));
|
|
291
|
+
g.appendChild(el("rect", { "class": "ring", width: O.nodeW, height: O.nodeH, rx: 12 }));
|
|
292
|
+
g.appendChild(el("rect", { "class": "card", width: O.nodeW, height: O.nodeH, rx: 12 }));
|
|
293
|
+
var stripe = el("rect", { "class": "stripe", x: 0, y: 10, width: 4, height: O.nodeH - 20, rx: 2 });
|
|
294
|
+
g.appendChild(stripe);
|
|
295
|
+
g.appendChild(el("rect", { "class": "spin", x: O.nodeW - 24, y: 8, width: 16, height: 16, rx: 8 }));
|
|
296
|
+
g.appendChild(el("rect", { "class": "burst", width: O.nodeW, height: O.nodeH, rx: 12 }));
|
|
297
|
+
g.appendChild(el("text", { "class": "l", x: 16, y: 22 }, t.label + (t.type && t.type !== "task" ? " · " + t.type : "")));
|
|
298
|
+
g.appendChild(el("text", { "class": "t", x: 16, y: 43 }, ellipsize(t.title, 27)));
|
|
299
|
+
var d = details[t.label] || {};
|
|
300
|
+
var sub = el("text", { "class": "sub", x: 16, y: 58 }, "");
|
|
301
|
+
g.appendChild(sub);
|
|
302
|
+
var rounds = el("text", { "class": "badge", x: O.nodeW - 14, y: 22, "text-anchor": "end" }, "");
|
|
303
|
+
g.appendChild(rounds);
|
|
304
|
+
if (d.rounds && d.rounds.length) { rounds.textContent = "⟳ " + d.rounds.length; rounds.style.fill = "var(--ready)"; }
|
|
305
|
+
nodeLayer.appendChild(g);
|
|
306
|
+
nodeEls[t.label] = { g: g, stripe: stripe, sub: sub, burst: g.querySelector(".burst") };
|
|
307
|
+
g.addEventListener("mouseenter", function () { if (!pinned) showDetail(t.label); });
|
|
308
|
+
g.addEventListener("mouseleave", function () { if (!pinned) showDetail(null); });
|
|
309
|
+
g.addEventListener("focus", function () { if (!pinned) showDetail(t.label); });
|
|
310
|
+
g.addEventListener("click", function (ev) { ev.stopPropagation(); pinned = pinned === t.label ? null : t.label; showDetail(pinned || t.label); markPinned(); });
|
|
311
|
+
g.addEventListener("keydown", function (ev) { if (ev.key === "Enter") { ev.preventDefault(); g.dispatchEvent(new MouseEvent("click", { bubbles: true })); } });
|
|
312
|
+
});
|
|
313
|
+
var pinned = null;
|
|
314
|
+
function markPinned() { Object.keys(nodeEls).forEach(function (k) { nodeEls[k].g.classList.toggle("pinned", k === pinned); }); }
|
|
315
|
+
$("stage").addEventListener("click", function () { pinned = null; showDetail(null); markPinned(); });
|
|
316
|
+
|
|
317
|
+
var SUBTEXT = { resolved: "resolved", ready: "ready to start", claimed: "being worked on", open: "waiting on a blocker", waiting: "needs a human", parked: "parked", unknown: "unknown status" };
|
|
318
|
+
|
|
319
|
+
// The state being shown: { label: status } plus the ready flags for it.
|
|
320
|
+
var shown = { state: {}, ready: {} };
|
|
321
|
+
function nowState() {
|
|
322
|
+
var s = {}, r = {};
|
|
323
|
+
tickets.forEach(function (t) { s[t.label] = t.status; r[t.label] = !!t.ready; });
|
|
324
|
+
return { state: s, ready: r };
|
|
325
|
+
}
|
|
326
|
+
function apply(view, label) {
|
|
327
|
+
shown = view;
|
|
328
|
+
tickets.forEach(function (t) {
|
|
329
|
+
var st = view.state[t.label], rd = view.ready[t.label];
|
|
330
|
+
var n = nodeEls[t.label];
|
|
331
|
+
n.g.setAttribute("class", "node st-" + st + (rd ? " ready" : "") + (pinned === t.label ? " pinned" : ""));
|
|
332
|
+
var stripeKey = rd ? "ready" : st;
|
|
333
|
+
n.stripe.setAttribute("class", "stripe st-" + stripeKey);
|
|
334
|
+
n.sub.textContent = rd ? SUBTEXT.ready : (SUBTEXT[st] || st);
|
|
335
|
+
n.g.setAttribute("aria-label", t.label + " " + t.title + ", " + (rd ? SUBTEXT.ready : (SUBTEXT[st] || st)));
|
|
336
|
+
});
|
|
337
|
+
L.edges.forEach(function (e) {
|
|
338
|
+
var from = view.state[e.from], to = view.state[e.to];
|
|
339
|
+
var cls = "edge";
|
|
340
|
+
if (from === "resolved" && to === "resolved") cls += " done";
|
|
341
|
+
else if (from === "resolved" && (view.ready[e.to] || to === "claimed")) cls += " active";
|
|
342
|
+
edgeEls[e.from + ">" + e.to].setAttribute("class", cls);
|
|
343
|
+
});
|
|
344
|
+
renderStats(view);
|
|
345
|
+
if (currentDetail) showDetail(currentDetail);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function renderStats(view) {
|
|
349
|
+
var count = { resolved: 0, claimed: 0, ready: 0 };
|
|
350
|
+
tickets.forEach(function (t) {
|
|
351
|
+
if (view.state[t.label] === "resolved") count.resolved++;
|
|
352
|
+
if (view.state[t.label] === "claimed") count.claimed++;
|
|
353
|
+
if (view.ready[t.label]) count.ready++;
|
|
354
|
+
});
|
|
355
|
+
var stats = $("stats");
|
|
356
|
+
stats.replaceChildren();
|
|
357
|
+
function chip(color, text) {
|
|
358
|
+
var c = h("span", { "class": "chip" });
|
|
359
|
+
if (color) { var d = h("i", { "class": "dot" }); d.style.background = color; c.appendChild(d); }
|
|
360
|
+
c.appendChild(document.createTextNode(text));
|
|
361
|
+
stats.appendChild(c);
|
|
362
|
+
}
|
|
363
|
+
chip(STATUS_COLOR.resolved, count.resolved + " of " + tickets.length + " resolved");
|
|
364
|
+
chip(STATUS_COLOR.ready, count.ready + " ready");
|
|
365
|
+
if (count.claimed) chip(STATUS_COLOR.claimed, count.claimed + " in progress");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
var currentDetail = null;
|
|
369
|
+
function showDetail(label) {
|
|
370
|
+
currentDetail = label;
|
|
371
|
+
var box = $("detail");
|
|
372
|
+
box.replaceChildren();
|
|
373
|
+
if (!label) { box.appendChild(h("p", { "class": "empty" }, "Hover or click a ticket to see its details.")); return; }
|
|
374
|
+
var t = byLabel[label], d = details[label] || {};
|
|
375
|
+
var st = shown.state[label], rd = shown.ready[label];
|
|
376
|
+
box.appendChild(h("h2", {}, t.label + " " + t.title));
|
|
377
|
+
box.appendChild(h("div", { "class": "meta" }, (t.type || "task") + " · " + (rd ? "ready to start" : (SUBTEXT[st] || st)) + (t.test_first ? " · test first: " + t.test_first : "")));
|
|
378
|
+
if (t.blocked_by.length) {
|
|
379
|
+
box.appendChild(h("h3", {}, "Blocked by"));
|
|
380
|
+
var ul = h("ul");
|
|
381
|
+
t.blocked_by.forEach(function (b) {
|
|
382
|
+
var bt = byLabel[b];
|
|
383
|
+
ul.appendChild(h("li", {}, b + (bt ? " " + bt.title + " (" + (shown.state[b] || bt.status) + ")" : " (missing)")));
|
|
384
|
+
});
|
|
385
|
+
box.appendChild(ul);
|
|
386
|
+
}
|
|
387
|
+
if (d.done_when && d.done_when.length) {
|
|
388
|
+
box.appendChild(h("h3", {}, "Done when"));
|
|
389
|
+
var ul2 = h("ul");
|
|
390
|
+
d.done_when.forEach(function (x) { ul2.appendChild(h("li", {}, x)); });
|
|
391
|
+
box.appendChild(ul2);
|
|
392
|
+
}
|
|
393
|
+
if (d.rounds && d.rounds.length) {
|
|
394
|
+
box.appendChild(h("h3", {}, "Sent back"));
|
|
395
|
+
var ul3 = h("ul");
|
|
396
|
+
d.rounds.forEach(function (r) { ul3.appendChild(h("li", {}, "round " + r.n + ": " + r.source)); });
|
|
397
|
+
box.appendChild(ul3);
|
|
398
|
+
}
|
|
399
|
+
if (d.answer) {
|
|
400
|
+
box.appendChild(h("h3", {}, "Answer"));
|
|
401
|
+
box.appendChild(h("pre", {}, d.answer));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Replay.
|
|
406
|
+
var frames = buildFrames(tickets, details);
|
|
407
|
+
var hasHistory = frames.length > 1;
|
|
408
|
+
var idx = frames.length - 1;
|
|
409
|
+
var timer = null;
|
|
410
|
+
var scrub = $("scrub");
|
|
411
|
+
scrub.max = String(Math.max(0, frames.length - 1));
|
|
412
|
+
|
|
413
|
+
function frameView(i) {
|
|
414
|
+
var f = frames[i];
|
|
415
|
+
return { state: f.state, ready: readyOf(tickets, f.state) };
|
|
416
|
+
}
|
|
417
|
+
function clockText(i) {
|
|
418
|
+
if (!hasHistory) return "no git history for these tickets yet";
|
|
419
|
+
var f = frames[i];
|
|
420
|
+
var when = f.t ? new Date(f.t * 1000).toLocaleString() : "";
|
|
421
|
+
return "step " + i + " of " + (frames.length - 1) + (when ? " · " + when : "");
|
|
422
|
+
}
|
|
423
|
+
function seek(i, animate) {
|
|
424
|
+
idx = Math.max(0, Math.min(frames.length - 1, i));
|
|
425
|
+
scrub.value = String(idx);
|
|
426
|
+
apply(frameView(idx));
|
|
427
|
+
$("clock").textContent = clockText(idx);
|
|
428
|
+
if (animate && frames[idx].change) transition(frames[idx].change);
|
|
429
|
+
mode = "replay";
|
|
430
|
+
}
|
|
431
|
+
var mode = "now";
|
|
432
|
+
function showNow() {
|
|
433
|
+
pause();
|
|
434
|
+
apply(nowState());
|
|
435
|
+
scrub.value = scrub.max;
|
|
436
|
+
$("clock").textContent = hasHistory ? "current state" : clockText(0);
|
|
437
|
+
mode = "now";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function transition(ch) {
|
|
441
|
+
if (reduce) return;
|
|
442
|
+
var n = nodeEls[ch.label];
|
|
443
|
+
if (!n) return;
|
|
444
|
+
if (ch.to === "resolved") {
|
|
445
|
+
n.burst.classList.remove("go"); void n.burst.getBoundingClientRect(); n.burst.classList.add("go");
|
|
446
|
+
byLabel[ch.label].blocked_by.forEach(function (b) { var p = edgeEls[b + ">" + ch.label]; if (p) travel(p); });
|
|
447
|
+
} else if (ch.from === "resolved") {
|
|
448
|
+
n.g.classList.remove("sentback"); void n.g.getBoundingClientRect(); n.g.classList.add("sentback");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function travel(path) {
|
|
452
|
+
var len = path.getTotalLength(), dot = el("circle", { "class": "traveller", r: 5 });
|
|
453
|
+
fxLayer.appendChild(dot);
|
|
454
|
+
var start = null, dur = 650;
|
|
455
|
+
function stepFn(ts) {
|
|
456
|
+
if (start === null) start = ts;
|
|
457
|
+
var k = Math.min(1, (ts - start) / dur), pt = path.getPointAtLength(len * k);
|
|
458
|
+
dot.setAttribute("cx", pt.x); dot.setAttribute("cy", pt.y);
|
|
459
|
+
if (k < 1) requestAnimationFrame(stepFn); else fxLayer.removeChild(dot);
|
|
460
|
+
}
|
|
461
|
+
requestAnimationFrame(stepFn);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function play() {
|
|
465
|
+
if (!hasHistory) return;
|
|
466
|
+
if (idx >= frames.length - 1) seek(0, false);
|
|
467
|
+
$("play").textContent = "⏸ Pause";
|
|
468
|
+
var speed = parseFloat($("speed").value);
|
|
469
|
+
(function tick() {
|
|
470
|
+
timer = setTimeout(function () {
|
|
471
|
+
if (idx >= frames.length - 1) { pause(); return; }
|
|
472
|
+
seek(idx + 1, true);
|
|
473
|
+
tick();
|
|
474
|
+
}, 1100 * speed);
|
|
475
|
+
})();
|
|
476
|
+
}
|
|
477
|
+
function pause() { if (timer) clearTimeout(timer); timer = null; $("play").textContent = "▶ Replay"; }
|
|
478
|
+
function toggle() { if (timer) pause(); else play(); }
|
|
479
|
+
|
|
480
|
+
$("play").addEventListener("click", toggle);
|
|
481
|
+
$("restart").addEventListener("click", function () { pause(); seek(0, false); });
|
|
482
|
+
$("stepf").addEventListener("click", function () { pause(); seek(idx + 1, true); });
|
|
483
|
+
$("stepb").addEventListener("click", function () { pause(); seek(idx - 1, false); });
|
|
484
|
+
$("now").addEventListener("click", showNow);
|
|
485
|
+
scrub.addEventListener("input", function () { pause(); seek(parseInt(scrub.value, 10), false); });
|
|
486
|
+
$("speed").addEventListener("change", function () { if (timer) { pause(); play(); } });
|
|
487
|
+
if (!hasHistory) { ["restart", "play", "stepb", "stepf", "scrub", "speed"].forEach(function (id) { $(id).disabled = true; }); }
|
|
488
|
+
|
|
489
|
+
$("fit").addEventListener("click", function () {
|
|
490
|
+
var fit = svg.classList.toggle("fit");
|
|
491
|
+
$("fit").textContent = fit ? "Fit" : "Full size";
|
|
492
|
+
});
|
|
493
|
+
function setTheme(v) { document.documentElement.setAttribute("data-theme", v); try { localStorage.setItem("flow-theme", v); } catch (e) { } }
|
|
494
|
+
try { var saved = localStorage.getItem("flow-theme"); if (saved) document.documentElement.setAttribute("data-theme", saved); } catch (e) { }
|
|
495
|
+
$("theme").addEventListener("click", function () {
|
|
496
|
+
var cur = document.documentElement.getAttribute("data-theme");
|
|
497
|
+
var dark = cur === "dark" || (cur === "auto" && !(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches));
|
|
498
|
+
setTheme(dark ? "light" : "dark");
|
|
499
|
+
});
|
|
500
|
+
document.addEventListener("keydown", function (ev) {
|
|
501
|
+
if (ev.target && (ev.target.tagName === "INPUT" || ev.target.tagName === "SELECT")) return;
|
|
502
|
+
if (ev.key === " " || ev.key === "k") { ev.preventDefault(); toggle(); }
|
|
503
|
+
else if (ev.key === "ArrowRight") { pause(); seek(idx + 1, true); }
|
|
504
|
+
else if (ev.key === "ArrowLeft") { pause(); seek(idx - 1, false); }
|
|
505
|
+
else if (ev.key === "Home") { pause(); seek(0, false); }
|
|
506
|
+
else if (ev.key === "End") { showNow(); }
|
|
507
|
+
else if (ev.key === "t") { $("theme").click(); }
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
function frontierX() {
|
|
511
|
+
var best = null;
|
|
512
|
+
tickets.forEach(function (t) {
|
|
513
|
+
var interesting = shown.ready[t.label] || shown.state[t.label] === "claimed";
|
|
514
|
+
if (interesting && (best === null || L.nodes[t.label].x < best)) best = L.nodes[t.label].x;
|
|
515
|
+
});
|
|
516
|
+
return best;
|
|
517
|
+
}
|
|
518
|
+
function chooseFit() {
|
|
519
|
+
var stage = $("stage");
|
|
520
|
+
var scale = (stage.clientWidth - 32) / L.width;
|
|
521
|
+
var fit = scale >= 0.7;
|
|
522
|
+
svg.classList.toggle("fit", fit);
|
|
523
|
+
$("fit").textContent = fit ? "Fit" : "Full size";
|
|
524
|
+
if (!fit) {
|
|
525
|
+
var x = frontierX();
|
|
526
|
+
stage.scrollLeft = x === null ? 0 : Math.max(0, x - stage.clientWidth / 3);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
window.__flow = { seek: seek, play: play, pause: pause, now: showNow, frames: frames, layout: L, count: tickets.length };
|
|
531
|
+
showNow();
|
|
532
|
+
chooseFit();
|
|
533
|
+
})();
|
|
534
|
+
</script>
|
|
535
|
+
</body>
|
|
536
|
+
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The graph page writer. usage: python3 scripts/flow-view.py <feature> [--watch] [--no-open] [--out FILE]"""
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True # never leave __pycache__ in the user's tree
|
|
7
|
+
here = Path(__file__).resolve().parent
|
|
8
|
+
for root in (here.parent, here.parent / ".feature-flow"):
|
|
9
|
+
if (root / "feature_flow" / "__init__.py").is_file():
|
|
10
|
+
sys.path.insert(0, str(root))
|
|
11
|
+
break
|
|
12
|
+
from feature_flow.view import main
|
|
13
|
+
|
|
14
|
+
sys.exit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The feature-flow conductor. usage: python3 scripts/flow.py <feature> <command>"""
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True # never leave __pycache__ in the user's tree
|
|
7
|
+
here = Path(__file__).resolve().parent
|
|
8
|
+
for root in (here.parent, here.parent / ".feature-flow"):
|
|
9
|
+
if (root / "feature_flow" / "__init__.py").is_file():
|
|
10
|
+
sys.path.insert(0, str(root))
|
|
11
|
+
break
|
|
12
|
+
from feature_flow.cli import main
|
|
13
|
+
|
|
14
|
+
sys.exit(main(scripts=here))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The feature-flow gate. usage: python3 scripts/gate.py <feature>"""
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.dont_write_bytecode = True # never leave __pycache__ in the user's tree
|
|
7
|
+
here = Path(__file__).resolve().parent
|
|
8
|
+
for root in (here.parent, here.parent / ".feature-flow"):
|
|
9
|
+
if (root / "feature_flow" / "__init__.py").is_file():
|
|
10
|
+
sys.path.insert(0, str(root))
|
|
11
|
+
break
|
|
12
|
+
from feature_flow.gate import main
|
|
13
|
+
|
|
14
|
+
sys.exit(main())
|