feat(jira): 增强提交检查和文档路径规范
- 添加已推送提交检查,避免不安全的 amend 操作
- 规范文档保存路径为 `doc/{日期}` 格式
- 扩展 markdown 解析支持表格和更多格式
- 优化 Jira 搜索 API 调用方式
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user