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
feature_flow/status.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""The ticket graph reader, ported from the bash script of the same name.
|
|
2
|
+
|
|
3
|
+
usage: python3 scripts/flow-status.py <feature> [--next | --counts | --check | --mermaid [plain] | --json]
|
|
4
|
+
Exit codes for --next: 0 a ready ticket path is printed, 10 all done, 11 unfinished but nothing ready.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from feature_flow import tickets
|
|
12
|
+
|
|
13
|
+
USAGE = "usage: python3 scripts/flow-status.py <feature> [--next | --counts | --check | --mermaid [plain] | --json]"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _env(name, default):
|
|
17
|
+
return os.environ.get(name) or default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def jstr(s):
|
|
21
|
+
out = ['"']
|
|
22
|
+
for c in s:
|
|
23
|
+
if c == "\\":
|
|
24
|
+
out.append("\\\\")
|
|
25
|
+
elif c == '"':
|
|
26
|
+
out.append('\\"')
|
|
27
|
+
elif c == "\t":
|
|
28
|
+
out.append("\\t")
|
|
29
|
+
elif c == "\001":
|
|
30
|
+
out.append("\\n")
|
|
31
|
+
elif c < " ":
|
|
32
|
+
pass
|
|
33
|
+
else:
|
|
34
|
+
out.append(c)
|
|
35
|
+
out.append('"')
|
|
36
|
+
return "".join(out)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(argv, out, err):
|
|
40
|
+
"""Write what the bash script would to out and err, and return its exit code."""
|
|
41
|
+
feature = argv[0] if len(argv) > 0 else ""
|
|
42
|
+
mode = argv[1] if len(argv) > 1 and argv[1] != "" else "table"
|
|
43
|
+
opt = argv[2] if len(argv) > 2 else ""
|
|
44
|
+
if feature == "":
|
|
45
|
+
err.write(USAGE + "\n")
|
|
46
|
+
return 2
|
|
47
|
+
root = _env("FLOW_DIR", "plans")
|
|
48
|
+
folder = "%s/%s/%s" % (root, feature, _env("FLOW_TICKETS", "tasks"))
|
|
49
|
+
if not os.path.isdir(folder):
|
|
50
|
+
err.write("no ticket folder: %s\n" % folder)
|
|
51
|
+
return 2
|
|
52
|
+
paths = tickets.ticket_files(folder)
|
|
53
|
+
if not paths:
|
|
54
|
+
err.write("no tickets in %s\n" % folder)
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
if mode == "--check":
|
|
58
|
+
learnings = "%s/%s/learnings.md" % (root, feature)
|
|
59
|
+
if os.path.isfile(learnings):
|
|
60
|
+
lines = sum(1 for line in tickets.records(learnings) if line.strip(" \t\n\v\f\r") != "")
|
|
61
|
+
if lines > 40:
|
|
62
|
+
out.write("warning: learnings.md has %d lines. every session reads it, so a human should prune it\n"
|
|
63
|
+
% lines)
|
|
64
|
+
|
|
65
|
+
g = tickets.Graph(paths)
|
|
66
|
+
return _report(g, mode, opt, feature, out, err)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _report(g, mode, opt, feature, out, err):
|
|
70
|
+
n = len(g.order)
|
|
71
|
+
count = {"resolved": 0, "open": 0, "claimed": 0, "waiting": 0, "parked": 0, "unknown": 0}
|
|
72
|
+
ready = {}
|
|
73
|
+
first = None
|
|
74
|
+
unfinished = 0
|
|
75
|
+
for t in g.order:
|
|
76
|
+
ready[t] = False
|
|
77
|
+
count[g.stat[t]] += 1
|
|
78
|
+
if g.stat[t] == "open":
|
|
79
|
+
if all(d in g.seen and g.stat[d] == "resolved" for d in g.blk[t]):
|
|
80
|
+
ready[t] = True
|
|
81
|
+
if first is None:
|
|
82
|
+
first = t
|
|
83
|
+
if g.stat[t] not in ("resolved", "parked"):
|
|
84
|
+
unfinished += 1
|
|
85
|
+
nready = sum(1 for t in g.order if ready[t])
|
|
86
|
+
|
|
87
|
+
if mode == "--next":
|
|
88
|
+
if first is not None:
|
|
89
|
+
out.write(g.path[first] + "\n")
|
|
90
|
+
return 0
|
|
91
|
+
if unfinished == 0:
|
|
92
|
+
err.write("COMPLETE\n")
|
|
93
|
+
return 10
|
|
94
|
+
err.write("stuck: %d claimed, %d waiting, %d open but blocked, %d unknown status\n"
|
|
95
|
+
% (count["claimed"], count["waiting"], count["open"], count["unknown"]))
|
|
96
|
+
return 11
|
|
97
|
+
|
|
98
|
+
counts = (n, count["resolved"], count["open"], count["claimed"], count["waiting"], count["parked"],
|
|
99
|
+
count["unknown"], nready)
|
|
100
|
+
|
|
101
|
+
if mode == "--counts":
|
|
102
|
+
out.write("total=%d resolved=%d open=%d claimed=%d waiting=%d parked=%d unknown=%d ready=%d\n" % counts)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
if mode == "--json":
|
|
106
|
+
parts = []
|
|
107
|
+
for t in g.order:
|
|
108
|
+
bl = ",".join(jstr(g.lab[d] if d in g.seen else "%02d" % d) for d in g.blk[t])
|
|
109
|
+
parts.append('{"id":%d,"label":%s,"title":%s,"status":%s,"type":%s,"test_first":%s,"blocked_by":[%s],'
|
|
110
|
+
'"ready":%s,"file":%s}'
|
|
111
|
+
% (t, jstr(g.lab[t]), jstr(g.title[t]), jstr(g.stat[t]), jstr(g.typ.get(t) or "task"),
|
|
112
|
+
jstr(g.tf.get(t, "")), bl, "true" if ready[t] else "false", jstr(g.path[t])))
|
|
113
|
+
out.write('{"feature":%s,"tickets":[%s],"counts":{"total":%d,"resolved":%d,"open":%d,"claimed":%d,'
|
|
114
|
+
'"waiting":%d,"parked":%d,"unknown":%d,"ready":%d}}\n' % ((jstr(feature), ",".join(parts)) + counts))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
if mode == "--mermaid":
|
|
118
|
+
out.write("flowchart TD\n")
|
|
119
|
+
for t in g.order:
|
|
120
|
+
cls = g.stat[t]
|
|
121
|
+
if cls == "open":
|
|
122
|
+
cls = "ready" if ready[t] else "open"
|
|
123
|
+
if cls == "unknown":
|
|
124
|
+
cls = "open"
|
|
125
|
+
suffix = "" if opt == "plain" else ":::" + cls
|
|
126
|
+
out.write(' T%s["%s %s"]%s\n' % (g.lab[t], g.lab[t], g.title[t].replace('"', "'"), suffix))
|
|
127
|
+
for t in g.order:
|
|
128
|
+
for d in g.blk[t]:
|
|
129
|
+
if d in g.seen:
|
|
130
|
+
out.write(" T%s --> T%s\n" % (g.lab[d], g.lab[t]))
|
|
131
|
+
if opt != "plain":
|
|
132
|
+
out.write(" classDef resolved fill:#d4edda,stroke:#2e7d32\n"
|
|
133
|
+
" classDef ready fill:#fff3cd,stroke:#b8860b\n"
|
|
134
|
+
" classDef open fill:#f5f5f5,stroke:#999999\n"
|
|
135
|
+
" classDef claimed fill:#cfe2ff,stroke:#1d4ed8\n"
|
|
136
|
+
" classDef waiting fill:#fde2e2,stroke:#b91c1c\n"
|
|
137
|
+
" classDef parked fill:#eeeeee,stroke:#bbbbbb,stroke-dasharray:4 3\n")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
if mode == "--check":
|
|
141
|
+
return _check(g, ready, nready, unfinished, out)
|
|
142
|
+
|
|
143
|
+
for t in g.order:
|
|
144
|
+
bl = ",".join("%02d" % d for d in g.blk[t])
|
|
145
|
+
out.write("%s %-9s %-6s %-12s %s\n" % (g.lab[t], g.stat[t], "READY" if ready[t] else "-",
|
|
146
|
+
"after " + bl if bl else "", g.title[t]))
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check(g, ready, nready, unfinished, out):
|
|
151
|
+
errors = []
|
|
152
|
+
for t in g.order:
|
|
153
|
+
lab = g.lab[t]
|
|
154
|
+
if t in g.dup:
|
|
155
|
+
errors.append(lab + ": two tickets share this number")
|
|
156
|
+
if not g.hasstatus[t]:
|
|
157
|
+
errors.append(lab + ": no Status line in the first 20 lines")
|
|
158
|
+
elif g.stat[t] == "unknown":
|
|
159
|
+
errors.append(lab + ": unrecognised Status value")
|
|
160
|
+
if not g.hasdone[t]:
|
|
161
|
+
errors.append(lab + ": no ## Done when section")
|
|
162
|
+
if t in g.hastf and g.tf[t] not in ("yes", "no"):
|
|
163
|
+
errors.append(lab + ": Test first must be yes or no")
|
|
164
|
+
for d in g.blk[t]:
|
|
165
|
+
if d == t:
|
|
166
|
+
errors.append(lab + ": blocked by itself")
|
|
167
|
+
elif d not in g.seen:
|
|
168
|
+
errors.append("%s: blocked by %d, which does not exist" % (lab, d))
|
|
169
|
+
|
|
170
|
+
placed = set()
|
|
171
|
+
progress = True
|
|
172
|
+
left = len(g.order)
|
|
173
|
+
while progress and left > 0:
|
|
174
|
+
progress = False
|
|
175
|
+
for t in g.order:
|
|
176
|
+
if t in placed:
|
|
177
|
+
continue
|
|
178
|
+
if all(not (d in g.seen and d != t and d not in placed) for d in g.blk[t]):
|
|
179
|
+
placed.add(t)
|
|
180
|
+
progress = True
|
|
181
|
+
left -= 1
|
|
182
|
+
if left > 0:
|
|
183
|
+
errors.extend(g.lab[t] + ": part of a dependency cycle" for t in g.order if t not in placed)
|
|
184
|
+
if nready == 0 and unfinished > 0:
|
|
185
|
+
errors.append("no ticket is ready to start")
|
|
186
|
+
|
|
187
|
+
reach = set()
|
|
188
|
+
for _ in range(len(g.order)):
|
|
189
|
+
changed = False
|
|
190
|
+
for t in g.order:
|
|
191
|
+
for d in g.blk[t]:
|
|
192
|
+
if d not in g.seen:
|
|
193
|
+
continue
|
|
194
|
+
if (t, d) not in reach:
|
|
195
|
+
reach.add((t, d))
|
|
196
|
+
changed = True
|
|
197
|
+
for x in g.order:
|
|
198
|
+
if (d, x) in reach and (t, x) not in reach:
|
|
199
|
+
reach.add((t, x))
|
|
200
|
+
changed = True
|
|
201
|
+
if not changed:
|
|
202
|
+
break
|
|
203
|
+
for t in g.order:
|
|
204
|
+
for m in g.mentions.get(t, []):
|
|
205
|
+
if m == t or m not in g.seen or (t, m) in reach or (m, t) in reach:
|
|
206
|
+
continue
|
|
207
|
+
out.write("warning: %s: mentions ticket %s but is not ordered against it. add it to Blocked by, or reword\n"
|
|
208
|
+
% (g.lab[t], g.lab[m]))
|
|
209
|
+
if errors:
|
|
210
|
+
for e in errors:
|
|
211
|
+
out.write(e + "\n")
|
|
212
|
+
return 1
|
|
213
|
+
out.write("OK: %d tickets, %d ready now\n" % (len(g.order), nready))
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _Bytes:
|
|
218
|
+
"""Writes text to a byte stream, keeping the bytes awk would have passed through untouched."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, stream):
|
|
221
|
+
self.stream = stream
|
|
222
|
+
|
|
223
|
+
def write(self, text):
|
|
224
|
+
self.stream.write(text.encode("utf-8", "surrogateescape"))
|
|
225
|
+
self.stream.flush()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv=None):
|
|
229
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
230
|
+
sys.stdout.flush()
|
|
231
|
+
return run(argv, _Bytes(sys.stdout.buffer), _Bytes(sys.stderr.buffer))
|
feature_flow/tickets.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Reading and changing ticket files."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DONE = ("resolved", "done", "closed")
|
|
8
|
+
RESET = ("claimed", "in-progress")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def name(path):
|
|
12
|
+
return Path(path).stem
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def number(path):
|
|
16
|
+
return name(path).split("-", 1)[0]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _lines(path):
|
|
20
|
+
return Path(path).read_text(encoding="utf-8").splitlines(True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def status(path):
|
|
24
|
+
"""The first word of the Status line in the first 20 lines, lower case."""
|
|
25
|
+
for line in _lines(path)[:20]:
|
|
26
|
+
if line.startswith("Status:"):
|
|
27
|
+
value = line[len("Status:"):].strip().lower()
|
|
28
|
+
return value.split()[0] if value else ""
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_open(path):
|
|
33
|
+
lines = _lines(path)
|
|
34
|
+
for i, line in enumerate(lines[:20]):
|
|
35
|
+
if line.startswith("Status:"):
|
|
36
|
+
lines[i] = "Status: open\n"
|
|
37
|
+
break
|
|
38
|
+
Path(path).write_text("".join(lines), encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def append_findings(path, round_no, source, findings):
|
|
42
|
+
text = "\n## Review findings (round %d, %s)\n\n%s\n" % (round_no, source, findings.rstrip("\n"))
|
|
43
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
44
|
+
handle.write(text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def commands_value(path, key):
|
|
48
|
+
"""A command from commands.md, or "" when it is missing or a placeholder in angle brackets."""
|
|
49
|
+
path = Path(path)
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return ""
|
|
52
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
53
|
+
if re.match(r"^%s:" % re.escape(key), line):
|
|
54
|
+
value = re.sub(r"^%s:[ \t]*" % re.escape(key), "", line).replace("`", "")
|
|
55
|
+
return "" if value.startswith("<") else value
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# The ticket graph, read the way the bash flow-status script's awk program reads it.
|
|
60
|
+
|
|
61
|
+
STATUS_WORDS = {
|
|
62
|
+
"resolved": "resolved", "done": "resolved", "closed": "resolved",
|
|
63
|
+
"claimed": "claimed", "in-progress": "claimed",
|
|
64
|
+
"parked": "parked", "wontfix": "parked",
|
|
65
|
+
"needs-triage": "waiting", "needs-info": "waiting", "ready-for-human": "waiting", "waiting": "waiting",
|
|
66
|
+
"open": "open", "ready-for-agent": "open",
|
|
67
|
+
}
|
|
68
|
+
_FILE_RE = re.compile(r"^[0-9][0-9].*-.*\.md$")
|
|
69
|
+
_MENTION_RE = re.compile(r"[Tt]ickets? +[0-9]+( *(,|and|&) *[0-9]+)*")
|
|
70
|
+
_DIGITS_RE = re.compile(r"[0-9]+")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def records(path):
|
|
74
|
+
"""The file's lines: split on \\n only, no final empty record, bytes kept. A trailing \\r is dropped,
|
|
75
|
+
so a ticket saved with Windows line endings reads the same as one saved with \\n."""
|
|
76
|
+
text = Path(path).read_bytes().decode("utf-8", "surrogateescape")
|
|
77
|
+
if text == "":
|
|
78
|
+
return []
|
|
79
|
+
lines = text.split("\n")
|
|
80
|
+
if text.endswith("\n"):
|
|
81
|
+
lines.pop()
|
|
82
|
+
return [line[:-1] if line.endswith("\r") else line for line in lines]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ticket_files(folder):
|
|
86
|
+
"""What the glob [0-9][0-9]*-*.md finds in the folder, as folder/name, in name order."""
|
|
87
|
+
folder = str(folder)
|
|
88
|
+
try:
|
|
89
|
+
names = sorted(n for n in os.listdir(folder) if _FILE_RE.match(n))
|
|
90
|
+
except OSError:
|
|
91
|
+
return []
|
|
92
|
+
return ["%s/%s" % (folder, n) for n in names]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def norm_status(value):
|
|
96
|
+
s = value.lower()
|
|
97
|
+
s = re.sub(r"^[ \t]+", "", s)
|
|
98
|
+
s = re.sub(r"[ \t(].*$", "", s, flags=re.S)
|
|
99
|
+
return STATUS_WORDS.get(s, "unknown")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Graph:
|
|
103
|
+
"""Every ticket in a folder, keyed by number. The fields follow the awk arrays one to one."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, paths):
|
|
106
|
+
self.seen = set()
|
|
107
|
+
self.dup = set()
|
|
108
|
+
self.path, self.lab, self.stat, self.title = {}, {}, {}, {}
|
|
109
|
+
self.hasstatus, self.hasdone, self.blk = {}, {}, {}
|
|
110
|
+
self.hasblk, self.hastype, self.hastf, self.hastitle = set(), set(), set(), set()
|
|
111
|
+
self.typ, self.tf = {}, {}
|
|
112
|
+
self.mentions = {}
|
|
113
|
+
for p in paths:
|
|
114
|
+
self._read(p)
|
|
115
|
+
self.order = sorted(self.seen)
|
|
116
|
+
|
|
117
|
+
def _read(self, filename):
|
|
118
|
+
lines = records(filename)
|
|
119
|
+
if not lines:
|
|
120
|
+
return # awk never starts an empty file
|
|
121
|
+
base = filename.rsplit("/", 1)[-1]
|
|
122
|
+
label = re.match(r"^[0-9]+", base).group(0)
|
|
123
|
+
cur = int(label)
|
|
124
|
+
if cur in self.path:
|
|
125
|
+
self.dup.add(cur)
|
|
126
|
+
self.path[cur] = filename
|
|
127
|
+
self.lab[cur] = label
|
|
128
|
+
self.stat[cur] = "unknown"
|
|
129
|
+
self.hasstatus[cur] = False
|
|
130
|
+
self.hasdone[cur] = False
|
|
131
|
+
self.blk[cur] = []
|
|
132
|
+
self.title[cur] = base
|
|
133
|
+
self.seen.add(cur)
|
|
134
|
+
skipm = False
|
|
135
|
+
ment = self.mentions.setdefault(cur, [])
|
|
136
|
+
for fnr, line in enumerate(lines, 1):
|
|
137
|
+
head = fnr <= 20
|
|
138
|
+
if head and line.startswith("Status:") and not self.hasstatus[cur]:
|
|
139
|
+
self.stat[cur] = norm_status(line[len("Status:"):])
|
|
140
|
+
self.hasstatus[cur] = True
|
|
141
|
+
if head and line.startswith("Blocked by:") and cur not in self.hasblk:
|
|
142
|
+
self.hasblk.add(cur)
|
|
143
|
+
self.blk[cur].extend(int(m) for m in _DIGITS_RE.findall(line[len("Blocked by:"):]))
|
|
144
|
+
if head and line.startswith("Type:") and cur not in self.hastype:
|
|
145
|
+
self.hastype.add(cur)
|
|
146
|
+
self.typ[cur] = _first_word(line.lower(), "type:")
|
|
147
|
+
if head and line.startswith("Test first:") and cur not in self.hastf:
|
|
148
|
+
self.hastf.add(cur)
|
|
149
|
+
self.tf[cur] = _first_word(line.lower(), "test first:")
|
|
150
|
+
if line.startswith("# ") and cur not in self.hastitle:
|
|
151
|
+
self.hastitle.add(cur)
|
|
152
|
+
self.title[cur] = line[2:]
|
|
153
|
+
if line.startswith("## Done when"):
|
|
154
|
+
self.hasdone[cur] = True
|
|
155
|
+
if line.startswith("## "):
|
|
156
|
+
skipm = re.match(r"^(not in this ticket|answer|review findings)", line[3:].lower()) is not None
|
|
157
|
+
continue
|
|
158
|
+
if not skipm and fnr > 1:
|
|
159
|
+
for seg in _MENTION_RE.finditer(line):
|
|
160
|
+
for num in _DIGITS_RE.findall(seg.group(0)):
|
|
161
|
+
m = int(num)
|
|
162
|
+
if m < 1000 and m not in ment:
|
|
163
|
+
ment.append(m)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _first_word(lowered, key):
|
|
167
|
+
v = re.sub(r"^%s[ \t]*" % re.escape(key), "", lowered)
|
|
168
|
+
return re.sub(r"[ \t].*$", "", v, flags=re.S)
|