feat(g3fo-commit-jira): 新增无 MCP 的 Jira CLI 并增强 AI 执行流程约束
- 新增 `jira_cli.py` 脚本,支持在不依赖 Atlassian MCP 的环境下通过同一 Jira Token 进行搜索、创建 Issue 和评论 - 新增共享配置模块 `jira_env.py`,统一加载 `jira_upload.env` 并支持在技能根目录查找配置文件 - 更新 `SKILL.md` 和参考文档,增加「AI 执行契约」和详细的前置条件说明,强制 Step 1→5 线性执行顺序 - 新增 `agent_execution_checklist.md` 检查清单,防止 AI 跳步、误用工具或未等用户确认就绑定 Issue - 改进 `jira_cli.py` 的评论和描述生成,将常见 Markdown 语法转换为 Jira ADF 格式以获得更好的渲染效果
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Jira REST API CLI — same credentials as upload_attachment.py (jira_upload.env).
|
||||
Use when Atlassian MCP is unavailable (other IDEs).
|
||||
|
||||
Commands:
|
||||
myself Current user (accountId for assignee)
|
||||
search JQL search
|
||||
issue Get one issue by key
|
||||
create Create Task (G3SF by default), assign to self
|
||||
comment Add comment (ADF) to issue
|
||||
|
||||
Examples:
|
||||
python jira_cli.py --config ../jira_upload.env search --limit 5
|
||||
python jira_cli.py --config ../jira_upload.env issue G3SF-123
|
||||
python jira_cli.py --config ../jira_upload.env create --summary "[G3SF] fix foo" --description-file report.md
|
||||
python jira_cli.py --config ../jira_upload.env comment --issue G3SF-123 --body-file impact.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("ERROR: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from jira_env import CONFIG_FILENAME, load_config, require_credentials, session_headers
|
||||
|
||||
DEFAULT_JQL = (
|
||||
"project = G3SF AND assignee = currentUser() AND statusCategory != Done "
|
||||
"ORDER BY updated DESC"
|
||||
)
|
||||
|
||||
|
||||
def _empty_doc() -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [{"type": "paragraph", "content": [{"type": "text", "text": " "}]}],
|
||||
}
|
||||
|
||||
|
||||
def _text_nodes(s: str) -> List[Dict[str, Any]]:
|
||||
"""Single plain text node (non-empty)."""
|
||||
if not s:
|
||||
return []
|
||||
return [{"type": "text", "text": s}]
|
||||
|
||||
|
||||
def parse_inline_adf(s: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse inline **bold**, `code`, and [text](url) into ADF text nodes with marks.
|
||||
"""
|
||||
if not s:
|
||||
return []
|
||||
# Order: links, bold, code (non-greedy). Avoid ``` by not matching double backtick start.
|
||||
pattern = re.compile(
|
||||
r"\[([^\]]+)\]\(([^)]+)\)" # [label](url)
|
||||
r"|\*\*(.+?)\*\*" # **bold**
|
||||
r"|(?<!`)`([^`\n]+)`(?!`)" # `code`
|
||||
)
|
||||
nodes: List[Dict[str, Any]] = []
|
||||
last = 0
|
||||
for m in pattern.finditer(s):
|
||||
if m.start() > last:
|
||||
chunk = s[last : m.start()]
|
||||
if chunk:
|
||||
nodes.append({"type": "text", "text": chunk})
|
||||
if m.group(1) is not None and m.group(2) is not None:
|
||||
nodes.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": m.group(1),
|
||||
"marks": [{"type": "link", "attrs": {"href": m.group(2).strip()}}],
|
||||
}
|
||||
)
|
||||
elif m.group(3) is not None:
|
||||
nodes.append(
|
||||
{"type": "text", "text": m.group(3), "marks": [{"type": "strong"}]}
|
||||
)
|
||||
elif m.group(4) is not None:
|
||||
nodes.append(
|
||||
{"type": "text", "text": m.group(4), "marks": [{"type": "code"}]}
|
||||
)
|
||||
last = m.end()
|
||||
if last < len(s):
|
||||
tail = s[last:]
|
||||
if tail:
|
||||
nodes.append({"type": "text", "text": tail})
|
||||
return nodes
|
||||
|
||||
|
||||
def _paragraph_from_buffer(lines: List[str]) -> Optional[Dict[str, Any]]:
|
||||
if not lines:
|
||||
return None
|
||||
inner: List[Dict[str, Any]] = []
|
||||
for i, line in enumerate(lines):
|
||||
if i > 0:
|
||||
inner.append({"type": "hardBreak"})
|
||||
inner.extend(parse_inline_adf(line) or _text_nodes(line))
|
||||
if not inner:
|
||||
return None
|
||||
return {"type": "paragraph", "content": inner}
|
||||
|
||||
|
||||
def markdown_to_adf(text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert common Markdown to Atlassian Document Format (headings, lists, bold, code, links).
|
||||
Jira REST API stores comments/descriptions as ADF; plain paragraphs showed ### literally.
|
||||
"""
|
||||
text = text or ""
|
||||
if not text.strip():
|
||||
return _empty_doc()
|
||||
|
||||
lines = text.split("\n")
|
||||
content: List[Dict[str, Any]] = []
|
||||
n = len(lines)
|
||||
i = 0
|
||||
para_buf: List[str] = []
|
||||
|
||||
def flush_paragraph() -> None:
|
||||
nonlocal para_buf
|
||||
if not para_buf:
|
||||
return
|
||||
p = _paragraph_from_buffer(para_buf)
|
||||
para_buf = []
|
||||
if p:
|
||||
content.append(p)
|
||||
|
||||
while i < n:
|
||||
raw = lines[i]
|
||||
stripped = raw.strip()
|
||||
|
||||
if not stripped:
|
||||
flush_paragraph()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ATX heading # .. ######
|
||||
hm = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
||||
if hm and len(hm.group(1)) <= 6:
|
||||
flush_paragraph()
|
||||
level = len(hm.group(1))
|
||||
title = hm.group(2).strip()
|
||||
title_nodes = parse_inline_adf(title) or _text_nodes(title)
|
||||
content.append(
|
||||
{"type": "heading", "attrs": {"level": level}, "content": title_nodes}
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Horizontal rule
|
||||
if re.fullmatch(r"[-*_]{3,}", stripped):
|
||||
flush_paragraph()
|
||||
content.append({"type": "rule"})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Fenced code block
|
||||
if stripped.startswith("```"):
|
||||
flush_paragraph()
|
||||
lang = stripped[3:].strip() or "plaintext"
|
||||
code_lines: List[str] = []
|
||||
i += 1
|
||||
while i < n:
|
||||
if lines[i].strip().startswith("```"):
|
||||
i += 1
|
||||
break
|
||||
code_lines.append(lines[i])
|
||||
i += 1
|
||||
code_text = "\n".join(code_lines)
|
||||
content.append(
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {"language": lang},
|
||||
"content": [{"type": "text", "text": code_text or " "}],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Bullet list (consecutive - or *)
|
||||
if re.match(r"^[-*]\s+", stripped):
|
||||
flush_paragraph()
|
||||
items: List[str] = []
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
if not s:
|
||||
break
|
||||
bm = re.match(r"^[-*]\s+(.*)$", s)
|
||||
if not bm:
|
||||
break
|
||||
items.append(bm.group(1))
|
||||
i += 1
|
||||
if items:
|
||||
content.append(
|
||||
{
|
||||
"type": "bulletList",
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": parse_inline_adf(it)
|
||||
or _text_nodes(it),
|
||||
}
|
||||
],
|
||||
}
|
||||
for it in items
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Ordered list
|
||||
om = re.match(r"^(\d+)\.\s+(.*)$", stripped)
|
||||
if om:
|
||||
flush_paragraph()
|
||||
start_order = int(om.group(1))
|
||||
items = [om.group(2)]
|
||||
i += 1
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
if not s:
|
||||
break
|
||||
m = re.match(r"^\d+\.\s+(.*)$", s)
|
||||
if not m:
|
||||
break
|
||||
items.append(m.group(1))
|
||||
i += 1
|
||||
content.append(
|
||||
{
|
||||
"type": "orderedList",
|
||||
"attrs": {"order": start_order},
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": parse_inline_adf(it)
|
||||
or _text_nodes(it),
|
||||
}
|
||||
],
|
||||
}
|
||||
for it in items
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Blockquote: single line > text
|
||||
if stripped.startswith("> "):
|
||||
flush_paragraph()
|
||||
quote_lines: List[str] = [stripped[2:].strip()]
|
||||
i += 1
|
||||
while i < n and lines[i].strip().startswith("> "):
|
||||
quote_lines.append(lines[i].strip()[2:].strip())
|
||||
i += 1
|
||||
q_inner: List[Dict[str, Any]] = []
|
||||
for j, ql in enumerate(quote_lines):
|
||||
if j > 0:
|
||||
q_inner.append({"type": "hardBreak"})
|
||||
q_inner.extend(parse_inline_adf(ql) or _text_nodes(ql))
|
||||
content.append(
|
||||
{
|
||||
"type": "blockquote",
|
||||
"content": [{"type": "paragraph", "content": q_inner}],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
para_buf.append(raw)
|
||||
i += 1
|
||||
|
||||
flush_paragraph()
|
||||
|
||||
if not content:
|
||||
return _empty_doc()
|
||||
return {"type": "doc", "version": 1, "content": content}
|
||||
|
||||
|
||||
def plain_to_adf(text: str) -> Dict[str, Any]:
|
||||
"""Backward-compatible name: Markdown-aware conversion for Jira ADF."""
|
||||
return markdown_to_adf(text)
|
||||
|
||||
|
||||
def _out(data: Any, fmt: str) -> None:
|
||||
if fmt == "json":
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(data)
|
||||
|
||||
|
||||
def cmd_myself(base: str, email: str, token: str, fmt: str) -> int:
|
||||
url = f"{base}/rest/api/3/myself"
|
||||
r = requests.get(url, headers=session_headers(email, token, json_body=False), timeout=60)
|
||||
if r.status_code != 200:
|
||||
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
|
||||
print(r.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
j = r.json()
|
||||
if fmt == "json":
|
||||
_out(j, "json")
|
||||
else:
|
||||
print(f"accountId: {j.get('accountId')}")
|
||||
print(f"displayName: {j.get('displayName')}")
|
||||
print(f"email: {j.get('emailAddress')}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_search(base: str, email: str, token: str, jql: str, limit: int, fmt: str) -> int:
|
||||
url = f"{base}/rest/api/3/search"
|
||||
params = {
|
||||
"jql": jql,
|
||||
"maxResults": limit,
|
||||
"fields": "key,summary,status,assignee,updated",
|
||||
}
|
||||
r = requests.get(
|
||||
url,
|
||||
headers=session_headers(email, token, json_body=False),
|
||||
params=params,
|
||||
timeout=60,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
|
||||
print(r.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
data = r.json()
|
||||
issues = data.get("issues") or []
|
||||
if fmt == "json":
|
||||
_out(data, "json")
|
||||
return 0
|
||||
if not issues:
|
||||
print("(no issues)")
|
||||
return 0
|
||||
for i, iss in enumerate(issues, 1):
|
||||
f = iss.get("fields") or {}
|
||||
st = (f.get("status") or {}).get("name") or "?"
|
||||
summ = (f.get("summary") or "")[:80]
|
||||
print(f"{i}. {iss.get('key')} [{summ}] status: {st}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_issue(base: str, email: str, token: str, key: str, fmt: str) -> int:
|
||||
url = f"{base}/rest/api/3/issue/{key}"
|
||||
params = {"fields": "summary,status,assignee,project,description,created,updated"}
|
||||
r = requests.get(
|
||||
url,
|
||||
headers=session_headers(email, token, json_body=False),
|
||||
params=params,
|
||||
timeout=60,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
|
||||
print(r.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
j = r.json()
|
||||
if fmt == "json":
|
||||
_out(j, "json")
|
||||
return 0
|
||||
f = j.get("fields") or {}
|
||||
print(f"key: {j.get('key')}")
|
||||
print(f"summary: {f.get('summary')}")
|
||||
print(f"status: {(f.get('status') or {}).get('name')}")
|
||||
proj = f.get("project") or {}
|
||||
print(f"project: {proj.get('key')} {proj.get('name')}")
|
||||
asn = f.get("assignee")
|
||||
print(f"assignee: {(asn or {}).get('displayName') or asn}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create(
|
||||
base: str,
|
||||
email: str,
|
||||
token: str,
|
||||
project: str,
|
||||
summary: str,
|
||||
description: str,
|
||||
issuetype: str,
|
||||
assign_self: bool,
|
||||
fmt: str,
|
||||
) -> int:
|
||||
fields: Dict[str, Any] = {
|
||||
"project": {"key": project},
|
||||
"summary": summary.strip(),
|
||||
"description": plain_to_adf(description),
|
||||
"issuetype": {"name": issuetype},
|
||||
}
|
||||
if assign_self:
|
||||
mr = requests.get(
|
||||
f"{base}/rest/api/3/myself",
|
||||
headers=session_headers(email, token, json_body=False),
|
||||
timeout=30,
|
||||
)
|
||||
if mr.status_code == 200:
|
||||
aid = mr.json().get("accountId")
|
||||
if aid:
|
||||
fields["assignee"] = {"accountId": aid}
|
||||
payload = {"fields": fields}
|
||||
url = f"{base}/rest/api/3/issue"
|
||||
r = requests.post(url, headers=session_headers(email, token), json=payload, timeout=60)
|
||||
if r.status_code not in (200, 201):
|
||||
# Retry without assignee if permission error
|
||||
if r.status_code == 400 and assign_self and "assignee" in fields:
|
||||
del fields["assignee"]
|
||||
r2 = requests.post(
|
||||
url, headers=session_headers(email, token), json={"fields": fields}, timeout=60
|
||||
)
|
||||
if r2.status_code in (200, 201):
|
||||
key = r2.json().get("key")
|
||||
print(f"WARN: created without assignee (API rejected assignee). key={key}", file=sys.stderr)
|
||||
if fmt == "json":
|
||||
_out(r2.json(), "json")
|
||||
else:
|
||||
print(key)
|
||||
return 0
|
||||
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
|
||||
print(r.text[:1200], file=sys.stderr)
|
||||
return 1
|
||||
j = r.json()
|
||||
if fmt == "json":
|
||||
_out(j, "json")
|
||||
else:
|
||||
print(j.get("key", j))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_comment(base: str, email: str, token: str, issue: str, body: str, fmt: str) -> int:
|
||||
url = f"{base}/rest/api/3/issue/{issue.strip()}/comment"
|
||||
payload = {"body": plain_to_adf(body)}
|
||||
r = requests.post(url, headers=session_headers(email, token), json=payload, timeout=60)
|
||||
if r.status_code not in (200, 201):
|
||||
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
|
||||
print(r.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
j = r.json()
|
||||
if fmt == "json":
|
||||
_out(j, "json")
|
||||
else:
|
||||
print(f"OK comment id={j.get('id')}")
|
||||
return 0
|
||||
|
||||
|
||||
def read_body(description: Optional[str], path: Optional[str]) -> str:
|
||||
if path:
|
||||
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 ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Jira REST CLI (same token as upload_attachment)")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=None,
|
||||
help=f"path to {CONFIG_FILENAME} (default: cwd / script dir / skill dir)",
|
||||
)
|
||||
parser.add_argument("--format", choices=("text", "json"), default="text", dest="fmt")
|
||||
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_my = sub.add_parser("myself", help="GET /myself (accountId, email)")
|
||||
p_my.set_defaults(func="myself")
|
||||
|
||||
p_se = sub.add_parser("search", help="JQL search")
|
||||
p_se.add_argument("--jql", default=DEFAULT_JQL, help="JQL (default: G3SF in-progress for current user)")
|
||||
p_se.add_argument("--limit", type=int, default=5)
|
||||
p_se.set_defaults(func="search")
|
||||
|
||||
p_is = sub.add_parser("issue", help="Get issue by key")
|
||||
p_is.add_argument("key", help="e.g. G3SF-123")
|
||||
p_is.set_defaults(func="issue")
|
||||
|
||||
p_cr = sub.add_parser("create", help="Create issue (Task)")
|
||||
p_cr.add_argument("--project", default="G3SF")
|
||||
p_cr.add_argument("--summary", required=True)
|
||||
p_cr.add_argument(
|
||||
"--description",
|
||||
default="",
|
||||
help="Markdown → ADF (headings, lists, bold, code, links)",
|
||||
)
|
||||
p_cr.add_argument("--description-file", dest="description_file", default=None)
|
||||
p_cr.add_argument("--issuetype", default="Task", dest="issuetype")
|
||||
p_cr.add_argument(
|
||||
"--no-assign-self",
|
||||
action="store_true",
|
||||
help="do not set assignee to API token user",
|
||||
)
|
||||
p_cr.set_defaults(func="create")
|
||||
|
||||
p_co = sub.add_parser("comment", help="Add comment to issue")
|
||||
p_co.add_argument("--issue", required=True)
|
||||
p_co.add_argument("--body", default="")
|
||||
p_co.add_argument("--body-file", dest="body_file", default=None)
|
||||
p_co.set_defaults(func="comment")
|
||||
|
||||
args = parser.parse_args()
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
try:
|
||||
cfg = load_config(args.config, script_dir)
|
||||
base, email, token = require_credentials(cfg)
|
||||
except ValueError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
fmt = args.fmt
|
||||
if args.func == "myself":
|
||||
return cmd_myself(base, email, token, fmt)
|
||||
if args.func == "search":
|
||||
return cmd_search(base, email, token, args.jql, args.limit, fmt)
|
||||
if args.func == "issue":
|
||||
return cmd_issue(base, email, token, args.key.strip(), fmt)
|
||||
if args.func == "create":
|
||||
try:
|
||||
desc = read_body(args.description, args.description_file)
|
||||
except FileNotFoundError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
return cmd_create(
|
||||
base,
|
||||
email,
|
||||
token,
|
||||
args.project,
|
||||
args.summary,
|
||||
desc,
|
||||
args.issuetype,
|
||||
assign_self=not args.no_assign_self,
|
||||
fmt=fmt,
|
||||
)
|
||||
if args.func == "comment":
|
||||
try:
|
||||
body = read_body(args.body, args.body_file)
|
||||
except FileNotFoundError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not body.strip():
|
||||
print("ERROR: --body or --body-file required", file=sys.stderr)
|
||||
return 1
|
||||
return cmd_comment(base, email, token, args.issue, body, fmt)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user