-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathparse_release_notes.py
More file actions
170 lines (144 loc) · 5.99 KB
/
Copy pathparse_release_notes.py
File metadata and controls
170 lines (144 loc) · 5.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import re
import argparse
import re
import argparse
from docx import Document
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from typing import List, Tuple
# Accept versions like 13.06.16, 13.06.16(2025/04/30), or with a space before parentheses
VERSION_RE = re.compile(r"^\d{2}\.\d{2}\.\d{2}(?:\s*\([^)]*\))?")
import re
import argparse
from docx import Document
from typing import List, Tuple
VERSION_RE = re.compile(r"^\d{2}\.\d{2}\.\d{2}(?:\([^)]*\))?")
def _strip_leading_index_and_type(text: str) -> str:
"""Remove leading index and optional type columns from a row text.
Examples:
- '1\tB\t[defect] foo' -> '[defect] foo'
- '1. B [defect] foo' -> '[defect] foo'
"""
# remove leading numbering like '1', '1.', '1)'
t = re.sub(r"^\s*\d+\s*[\.)]?\s*", "", text)
# if a single-letter type column (B/F) appears next, remove it
t = re.sub(r"^([BF])\s*[\t ]+", "", t)
# if the text has tab-separated columns, prefer the 3rd column if present
parts = re.split(r"\t+", text)
if len(parts) >= 3:
return parts[2].strip()
return t.strip()
def extract_notes_from_line(line: str) -> str:
"""Extract human-readable note from a numbered issue row.
Prioritize content inside square brackets (e.g. '[defect] ...').
If not available, remove index/type columns and return the rest.
"""
if not line:
return ""
# If line is a table-like row, handle tabs first
if '\t' in line:
parts = [p.strip() for p in line.split('\t') if p.strip()]
if len(parts) >= 3:
candidate = parts[2]
elif len(parts) >= 2:
candidate = parts[-1]
else:
candidate = parts[0]
else:
candidate = _strip_leading_index_and_type(line)
# if bracketed tag exists, return from there
m = re.search(r"\[[^\]]+\].*", candidate)
if m:
return m.group(0).strip()
return candidate.strip()
def parse_docx(path: str) -> List[Tuple[str, str]]:
"""Parse a .docx file and return list of (version, note) tuples.
Rules:
- A version header line matches VERSION_RE and sets the current version.
- Issue rows start with a number (e.g. '1\tB\t...') and are associated with the most recent version.
"""
doc = Document(path)
entries: List[Tuple[str, str]] = []
current_version = None
def handle_line(text: str, collect_next_paragraphs: bool = True):
nonlocal current_version
if not text:
return
text = text.strip()
if VERSION_RE.match(text):
current_version = text.split()[0]
return
if current_version is None:
return
# only accept numbered issue rows (allow trailing dot or parenthesis)
if re.match(r"^\s*\d+[\.)]?\b", text):
# extract base note from the current line
note = extract_notes_from_line(text)
# optionally collect subsequent paragraphs that belong to this item
if collect_next_paragraphs:
# We will return the raw note and the caller (paragraph iteration)
# may append following paragraphs that don't look like a new item.
entries.append((current_version, note))
else:
entries.append((current_version, note))
# paragraphs first: iterate with index so we can aggregate following paragraphs
paras = [p.text for p in doc.paragraphs]
i = 0
while i < len(paras):
text = paras[i]
if not text or current_version is None and not VERSION_RE.match(text):
# might still be a version header
if VERSION_RE.match(text or ""):
handle_line(text, collect_next_paragraphs=False)
i += 1
continue
# if this paragraph starts a numbered item, collect following non-numbered paras
if re.match(r"^\s*\d+[\.)]?\b", text):
# extract base note
note = extract_notes_from_line(text)
j = i + 1
extra_parts = []
while j < len(paras) and not re.match(r"^\s*\d+[\.)]?\b", paras[j]) and not VERSION_RE.match(paras[j] or ""):
if paras[j].strip():
extra_parts.append(paras[j].strip())
j += 1
if extra_parts:
note = note + " " + " ".join(extra_parts)
if current_version is None and VERSION_RE.match(text):
# handled above
pass
else:
entries.append((current_version, note))
i = j
continue
# otherwise process normally (could be version header)
handle_line(text)
i += 1
# then tables: treat each table row as a potential item line
for tbl in doc.tables:
for row in tbl.rows:
row_text = '\t'.join(cell.text.strip() for cell in row.cells)
# if row_text is a version header, update current_version
if VERSION_RE.match(row_text):
current_version = row_text.split()[0]
continue
if re.match(r"^\s*\d+[\.)]?\b", row_text):
note = extract_notes_from_line(row_text)
entries.append((current_version, note))
return entries
def save_to_tsv(entries: List[Tuple[str, str]], out_path: str):
with open(out_path, 'w', encoding='utf-8') as f:
f.write('Version\tNote\n')
for v, n in entries:
safe_note = n.replace('\t', ' ').replace('\n', ' ').strip()
f.write(f"{v}\t{safe_note}\n")
def main():
parser = argparse.ArgumentParser(description='Parse release notes Word (.docx) and output a two-column TSV (Version, Note)')
parser.add_argument('input', help='Input .docx file')
parser.add_argument('output', help='Output .tsv file (two columns: Version and Note)')
args = parser.parse_args()
entries = parse_docx(args.input)
save_to_tsv(entries, args.output)
print(f'Wrote {len(entries)} entries to {args.output}')
if __name__ == '__main__':
main()