feat(jira): 增强提交检查和文档路径规范

- 添加已推送提交检查,避免不安全的 amend 操作
- 规范文档保存路径为 `doc/{日期}` 格式
- 扩展 markdown 解析支持表格和更多格式
- 优化 Jira 搜索 API 调用方式
This commit is contained in:
2026-04-02 17:38:39 +08:00
parent 062e9a652b
commit 44b13c96f6
5 changed files with 244 additions and 26 deletions
+127 -12
View File
@@ -57,14 +57,18 @@ def _text_nodes(s: str) -> List[Dict[str, Any]]:
def parse_inline_adf(s: str) -> List[Dict[str, Any]]:
"""
Parse inline **bold**, `code`, and [text](url) into ADF text nodes with marks.
Parse inline **bold**, ~~strikethrough~~, `code`, *italic*, [text](url), ***bold italic***
into ADF text nodes with marks.
"""
if not s:
return []
# Order: links, bold, code (non-greedy). Avoid ``` by not matching double backtick start.
# Order: links, bold+italic, bold, strikethrough, italic, code (non-greedy).
pattern = re.compile(
r"\[([^\]]+)\]\(([^)]+)\)" # [label](url)
r"|\*\*\*(.+?)\*\*\*" # ***bold italic***
r"|\*\*(.+?)\*\*" # **bold**
r"|~~(.+?)~~" # ~~strikethrough~~
r"|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)" # *italic* (avoid matching **)
r"|(?<!`)`([^`\n]+)`(?!`)" # `code`
)
nodes: List[Dict[str, Any]] = []
@@ -84,11 +88,27 @@ def parse_inline_adf(s: str) -> List[Dict[str, Any]]:
)
elif m.group(3) is not None:
nodes.append(
{"type": "text", "text": m.group(3), "marks": [{"type": "strong"}]}
{
"type": "text",
"text": m.group(3),
"marks": [{"type": "strong"}, {"type": "em"}],
}
)
elif m.group(4) is not None:
nodes.append(
{"type": "text", "text": m.group(4), "marks": [{"type": "code"}]}
{"type": "text", "text": m.group(4), "marks": [{"type": "strong"}]}
)
elif m.group(5) is not None:
nodes.append(
{"type": "text", "text": m.group(5), "marks": [{"type": "strike"}]}
)
elif m.group(6) is not None:
nodes.append(
{"type": "text", "text": m.group(6), "marks": [{"type": "em"}]}
)
elif m.group(7) is not None:
nodes.append(
{"type": "text", "text": m.group(7), "marks": [{"type": "code"}]}
)
last = m.end()
if last < len(s):
@@ -98,6 +118,78 @@ def parse_inline_adf(s: str) -> List[Dict[str, Any]]:
return nodes
def _split_table_row(line: str) -> List[str]:
"""Split a pipe-delimited table row into cells, stripping whitespace."""
parts = line.split("|")
cells = []
for p in parts:
c = p.strip()
if c:
cells.append(c)
return cells
def _is_separator_row(cells: List[str]) -> bool:
"""Check if a row is a GFM table separator (e.g., |---|---|)."""
return bool(cells) and all(re.fullmatch(r"-{3,}", c.strip()) for c in cells)
def _is_table_row(line: str) -> bool:
"""Detect if a line looks like a GFM table row."""
stripped = line.strip()
if not stripped.startswith("|"):
return False
cells = _split_table_row(stripped)
return len(cells) >= 1
def _parse_table(lines: List[str], start: int, n: int):
"""
Parse a GFM table starting at index start.
Returns (adf_table_dict, next_index).
"""
header_cells = _split_table_row(lines[start].strip())
num_cols = len(header_cells)
i = start + 1
# Skip separator row if present
if i < n and _is_separator_row(_split_table_row(lines[i].strip())):
i += 1
data_rows: List[List[str]] = []
while i < n:
s = lines[i].strip()
if not s or not _is_table_row(s):
break
row_cells = _split_table_row(s)
# Pad or trim to match header column count
while len(row_cells) < num_cols:
row_cells.append("")
data_rows.append(row_cells[:num_cols])
i += 1
# Build ADF table
def _make_cell(text: str, is_header: bool = False) -> Dict[str, Any]:
cell_type = "tableHeader" if is_header else "tableCell"
inline_nodes = parse_inline_adf(text) or _text_nodes(text or " ")
return {
"type": cell_type,
"content": [{"type": "paragraph", "content": inline_nodes}],
}
def _make_row(cell_texts: List[str], is_header: bool = False) -> Dict[str, Any]:
return {
"type": "tableRow",
"content": [_make_cell(c, is_header=is_header) for c in cell_texts],
}
table_content: List[Dict[str, Any]] = [_make_row(header_cells, is_header=True)]
for dr in data_rows:
table_content.append(_make_row(dr))
return {"type": "table", "content": table_content}, i
def _paragraph_from_buffer(lines: List[str]) -> Optional[Dict[str, Any]]:
if not lines:
return None
@@ -164,6 +256,13 @@ def markdown_to_adf(text: str) -> Dict[str, Any]:
i += 1
continue
# GFM Table: | col1 | col2 |
if _is_table_row(stripped):
flush_paragraph()
table_adf, i = _parse_table(lines, i, n)
content.append(table_adf)
continue
# Fenced code block
if stripped.startswith("```"):
flush_paragraph()
@@ -293,6 +392,22 @@ def plain_to_adf(text: str) -> Dict[str, Any]:
return markdown_to_adf(text)
def unescape_text(text: str) -> str:
"""
Unescape common escape sequences that may appear as literal strings
in CLI arguments or generated content.
Converts \\n -> newline, \\t -> tab, \\\\ -> backslash, etc.
"""
if not text:
return text
result = text.replace("\\\\", "\x00ESCAPED_BACKSLASH\x00")
result = result.replace("\\n", "\n")
result = result.replace("\\t", "\t")
result = result.replace("\\r", "\r")
result = result.replace("\x00ESCAPED_BACKSLASH\x00", "\\")
return result
def _out(data: Any, fmt: str) -> None:
if fmt == "json":
print(json.dumps(data, ensure_ascii=False, indent=2))
@@ -318,16 +433,16 @@ def cmd_myself(base: str, email: str, token: str, fmt: str) -> int:
def cmd_search(base: str, email: str, token: str, jql: str, limit: int, fmt: str) -> int:
url = f"{base}/rest/api/3/search"
params = {
url = f"{base}/rest/api/3/search/jql"
payload = {
"jql": jql,
"maxResults": limit,
"fields": "key,summary,status,assignee,updated",
"fields": ["key", "summary", "status", "assignee", "updated"],
}
r = requests.get(
r = requests.post(
url,
headers=session_headers(email, token, json_body=False),
params=params,
headers=session_headers(email, token),
json=payload,
timeout=60,
)
if r.status_code != 200:
@@ -455,8 +570,8 @@ def read_body(description: Optional[str], path: Optional[str]) -> str:
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"File not found: {p}")
return p.read_text(encoding="utf-8")
return description or ""
return unescape_text(p.read_text(encoding="utf-8"))
return unescape_text(description or "")
def main() -> int: