Compare commits
5
Commits
81727978f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5426b573c1 | ||
|
|
44b13c96f6 | ||
|
|
062e9a652b | ||
|
|
57fca4e468 | ||
|
|
cd6c8a0450 |
@@ -0,0 +1,108 @@
|
||||
# 影响分析报告 — g3fo-commit-jira 无 MCP 支持
|
||||
|
||||
## 1. 改动概览
|
||||
|
||||
- **背景**:技能原依赖 Cursor Atlassian MCP 完成 Jira 搜索/创建/评论;其它 IDE 无 MCP 时无法完成流程。
|
||||
- **目标**:在仅配置 `jira_upload.env`(与上传附件相同 Token)时,通过脚本完成查询、创建 Issue、添加评论。
|
||||
- **涉及模块**:`skills/g3fo-commit-jira/scripts/`、`SKILL.md`、`references/workflow.md`、`references/env_config.md`。
|
||||
- **改动类型**:功能新增(脚本)+ 文档更新。
|
||||
|
||||
## 2. 方法级改动分析
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 新增 `jira_env.py` | 统一加载 `jira_upload.env`,支持在技能根目录查找配置文件。 |
|
||||
| 新增 `jira_cli.py` | `myself` / `search` / `issue` / `create` / `comment` REST 调用。 |
|
||||
| 修改 `upload_attachment.py` | 改为引用 `jira_env`,行为与原先一致(多一层技能目录 config 查找)。 |
|
||||
| SKILL / workflow / env_config | 描述无 MCP 路径与命令示例。 |
|
||||
|
||||
**与原有逻辑差异**:上传附件 URL、认证方式未变;配置查找增加 `skills/g3fo-commit-jira/jira_upload.env`。
|
||||
|
||||
## 3. 调用方与影响范围
|
||||
|
||||
- **调用方**:Agent 按 SKILL 执行;用户手动运行脚本。
|
||||
- **破坏性变更**:否。未配置 env 时 `upload_attachment` 仍报错;原 MCP 流程仍可用。
|
||||
- **边界**:Jira Server/Data Center 与 Cloud API 差异未专门适配(与现有上传脚本一致,面向 Cloud)。
|
||||
|
||||
## 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低。新脚本失败时退回 MCP 或仅本地 amend。
|
||||
- **回滚**:删除 `jira_cli.py`/`jira_env.py` 并恢复 `upload_attachment.py` 内联配置逻辑;回滚文档。
|
||||
- **回滚方式是否简单**:是。
|
||||
|
||||
## 5. 验证与测试
|
||||
|
||||
- `python -m py_compile` 通过;`jira_cli.py --help` 正常。
|
||||
- 真实 Jira 调用需用户环境凭证,未在 CI 中执行。
|
||||
|
||||
---
|
||||
|
||||
# 影响分析报告 — jira_cli Markdown → ADF 渲染(评论/描述)
|
||||
|
||||
## 1. 改动概览
|
||||
|
||||
- **背景**:REST 脚本原先把正文整段当作 ADF 段落,`###` 等在 Jira 界面显示为原文,与 Atlassian 插件/MCP 的 Markdown 体验不一致。
|
||||
- **目标**:在 `jira_cli.py` 内将常见 Markdown 转为 ADF(heading、list、strong、code、link、codeBlock、rule、blockquote),使 `comment` / `create` 的展示接近网页富文本。
|
||||
- **涉及模块**:`scripts/jira_cli.py`、`SKILL.md`。
|
||||
- **改动类型**:功能增强(无 API 变更)。
|
||||
|
||||
## 2. 方法级改动分析
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| `markdown_to_adf`(新) | 按行解析 Markdown,输出 ADF `doc`。 |
|
||||
| `plain_to_adf` | 改为委托 `markdown_to_adf`,保持调用方不变。 |
|
||||
| `parse_inline_adf`(新) | 行内 `**`、`` ` ``、`[text](url)`。 |
|
||||
|
||||
**与原有逻辑差异**:非 Markdown 行仍按段落(含换行 hardBreak)输出;纯文本行为与旧版「多段段落」接近,但连续单行不再强制 `\\n\\n` 才分段。
|
||||
|
||||
## 3. 调用方与影响范围
|
||||
|
||||
- **调用方**:`cmd_comment`、`cmd_create` 经 `plain_to_adf` → `markdown_to_adf`。
|
||||
- **破坏性变更**:否。极端表格/复杂 MD 语法未实现,可能仍以普通文本行展示。
|
||||
- **边界**:与 Jira Cloud ADF 一致;Server/DC 若 API 不同需单独验证。
|
||||
|
||||
## 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低。若某 ADF 节点被实例拒绝,可回退 `plain_to_adf` 为旧实现。
|
||||
- **回滚方式是否简单**:是(恢复旧 `plain_to_adf` 单函数)。
|
||||
|
||||
## 5. 验证与测试
|
||||
|
||||
- 本地 `markdown_to_adf` 样例 JSON 结构校验;`py_compile` 通过。
|
||||
- 完整评论 POST 需对接真实 Jira。
|
||||
|
||||
---
|
||||
|
||||
# 影响分析报告 — AI 执行流程防走偏(SKILL / workflow / checklist)
|
||||
|
||||
## 1. 改动概览
|
||||
|
||||
- **背景**:不同 AI 执行本技能时偶发跳步、先写 Jira 再看 diff、占位 Key 上传、误用 MCP 冒充附件等。
|
||||
- **目标**:在文档层强制 **Step 1→5 线性顺序**、门禁表、红线与 Step 3 交互规则;新增逐步检查清单供 Agent 对照。
|
||||
- **涉及模块**:`SKILL.md`、`references/workflow.md`、新增 `references/agent_execution_checklist.md`。
|
||||
- **改动类型**:文档 / 流程约束增强(无脚本行为变更)。
|
||||
|
||||
## 2. 方法级改动分析
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| `SKILL.md` | 前置「AI 执行契约」、各 Step「前置」说明、frontmatter description 强调线性流程。 |
|
||||
| `workflow.md` | 目录增加「AI 执行顺序提醒」,链回 SKILL 与 checklist。 |
|
||||
| `agent_execution_checklist.md`(新) | Step 0~5 打勾表 + 走偏速查。 |
|
||||
|
||||
**与原有逻辑差异**:仅约束 Agent 阅读与执行顺序;不改变 Python/API 行为。
|
||||
|
||||
## 3. 调用方与影响范围
|
||||
|
||||
- **调用方**:所有读取本技能的 Agent。
|
||||
- **破坏性变更**:否。用户手动跑脚本不受影响。
|
||||
|
||||
## 4. 风险与回滚
|
||||
|
||||
- **风险级别**:低。文档过长可能略增 token;可精简 checklist。
|
||||
- **回滚方式是否简单**:是(还原 SKILL/workflow、删除 checklist)。
|
||||
|
||||
## 5. 验证与测试
|
||||
|
||||
- 文档结构与人读一致性自检;无自动化测试。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: g3fo-commit-jira
|
||||
description: G3FO 项目 Git 提交规范自动化工具。强制要求提供 git 修订号(hash),支持用户直接提供 Jira 编号或自动查找/创建 Jira 任务。按任务类型(漏洞修复/新功能/重构等)判定需附加的文档(开发计划、影响分析、任务摘要),生成对应 MD 并按公司规范命名(<JIRA-ID>_Dev_Plan.md / _Impact_Analysis.md / _Task_Summary.md)上传到 Jira 附件,将影响分析写入评论,最后将 Jira 编号追加到 commit message 末尾后提交(不 push)。适用于 G3FO/G3SF 项目的所有 git 提交场景。
|
||||
description: G3FO 项目 Git 提交规范自动化工具。**AI 必须严格按 Step 1→5 线性执行**(见 SKILL 内「AI 执行契约」),每步完成后再进入下一步;强制 git 修订号校验与 HEAD 校验(amend 场景);Jira 附件仅用 upload_attachment.py。按任务类型生成文档并上传,评论影响分析,最后 amend 追加 Jira、禁止 push。详见 references/agent_execution_checklist.md。
|
||||
---
|
||||
|
||||
# G3FO Commit Jira 技能
|
||||
@@ -12,56 +12,101 @@ description: G3FO 项目 Git 提交规范自动化工具。强制要求提供 gi
|
||||
|
||||
---
|
||||
|
||||
## AI 执行契约(防走偏,**必读且优先于即兴发挥**)
|
||||
|
||||
### 1. 线性流程,禁止跳步
|
||||
|
||||
必须按 **Step 1 → Step 2 → Step 3 → Step 4 → Step 5** 顺序执行。**每完成一步**,在回复中用一句话标明 **`Step N 已完成`**,再进入下一步。禁止在未完成前置步骤时执行后续操作。
|
||||
|
||||
| 在未完成… | 禁止执行… |
|
||||
|-----------|-----------|
|
||||
| **Step 1**:`git rev-parse <hash>` 成功;若需 amend,已确认该 hash **就是当前 HEAD** | 调用 Jira、生成报告正文、落盘 `<KEY>_*.md`、`git commit --amend` |
|
||||
| **Step 2**:已对照政策表写出**任务类型** + 三文档各是否需要(✅/❌) | 批量生成无关文档,或该写报告却跳过 |
|
||||
| **Step 3**:已持有**真实** Jira Issue Key(用户给出 / 用户从列表选定 / 新建命令返回的 key) | 以真实路径上传附件、写绑定该 Issue 的评论(禁止用 `JIRA-XXX` 等占位 Key 落盘上传) |
|
||||
| **Step 4**:本任务在 Jira 侧应做的上传/评论/建单已按政策做完 | `git commit --amend` |
|
||||
|
||||
### 2. 红线(违反即视为流程错误)
|
||||
|
||||
1. **禁止 `git push`**。本技能只做到 amend 为止。
|
||||
2. **禁止跳过** `git rev-parse <hash>`;amend 场景下禁止在 **hash ≠ HEAD** 时仍执行 amend。
|
||||
3. **禁止 amend 已推送的提交**(除非用户明确要求并确认风险)。使用 `git branch -r --contains <hash>` 检测。
|
||||
4. **禁止用 Atlassian MCP 或其它方式冒充「已上传 MD 附件」**;上传文件 **必须** 使用 `scripts/upload_attachment.py`(MCP 无可靠上传能力时不得虚构成功)。
|
||||
5. **禁止**在用户未选定 Issue、也未完成新建并取得 Key 的情况下,把某 Key 写进 commit message。
|
||||
6. **禁止**未读 `references/jira_commit_docs_policy.md`(或本 SKILL 中的条件表)就默认「三份全要」或「一律不要文档」。
|
||||
7. **禁止**在用户已提供 Jira 编号时仍执行搜索、展示列表或创建新 Issue。
|
||||
|
||||
### 3. Step 3 交互规则(易走偏)
|
||||
|
||||
- 用户**已给** Jira 编号 → **直接进入 Step 4**,Key = 用户给的编号,**禁止执行搜索、展示列表或创建新 Issue**。
|
||||
- 用户**未给** → 搜索展示列表后,**必须等待用户输入 1~N 或 0**(或明确同意新建),**禁止**擅自替用户选一个 Issue 绑定。
|
||||
- 搜索为空 → 可进入新建流程;新建成功后 Key 以 API 返回为准。
|
||||
|
||||
### 4. 自检
|
||||
|
||||
逐步执行时可对照 **`references/agent_execution_checklist.md`** 逐项确认。
|
||||
|
||||
---
|
||||
|
||||
## 前置要求
|
||||
|
||||
**1. 强制提供 Git 修订号 (hash)**
|
||||
|
||||
用户必须提供一个有效的 git 修订号(如 `HEAD` 或具体的 `hash`)。
|
||||
- **校验逻辑**:使用 `git rev-parse <hash>` 检查修订号是否存在。
|
||||
- **错误处理**:如果修订号无效或找不到,**必须** 停止操作并提示用户:“找不到修订号 `<hash>`,请提供正确的 git 修订号(例如 HEAD 或 7 位以上的 commit hash)。”
|
||||
- **错误处理**:如果修订号无效或找不到,**必须** 停止操作并提示用户:"找不到修订号 `<hash>`,请提供正确的 git 修订号(例如 HEAD 或 7 位以上的 commit hash)。"
|
||||
- **路径要求**:必须在相关的项目根目录下执行 git 命令。如果当前目录不是 git 仓库或不是目标项目,请提示用户切换到正确的项目路径。
|
||||
- **已 push 提交检测**:使用 `git branch -r --contains <hash>` 检查提交是否已推送到远程。如果已推送,**默认跳过 Step 5(amend)**,仅完成 Jira 绑定和文档上传,并告知用户:
|
||||
> "提交 `<hash>` 已推送到远程,无法 amend。已完成 Jira 绑定和文档上传。如需修改 commit message,请手动处理或提供特别说明。"
|
||||
- **例外**:如果用户明确要求修改已 push 的提交(如通过 `--force` 或其他方式),需用户确认风险后再执行。
|
||||
|
||||
**2. Jira 编号 (可选)**
|
||||
|
||||
用户可以主动提供 Jira 编号(如 `G3SF-123`)。
|
||||
- 如果提供了 Jira 编号,直接引用该编号,跳过“查找/选择 Jira 任务”的步骤。
|
||||
- 如果提供了 Jira 编号,**直接使用该编号,跳过「查找/选择/新建 Jira 任务」的步骤**,进入 Step 4 上传文档和评论。
|
||||
- 如果未提供,则按流程自动查找或提示用户新建。
|
||||
|
||||
**3. Atlassian MCP 授权**
|
||||
**3. Jira 访问方式(二选一)**
|
||||
|
||||
- 查看 `mcps/plugin-atlassian-atlassian/STATUS.md` 确认已在 Cursor 中授权 MCP 插件。
|
||||
- 如果未授权,调用 `mcp_auth`,server: `plugin-atlassian-atlassian`,参数 `{}`。
|
||||
- 若 MCP 插件未安装,引导用户按以下步骤操作:
|
||||
1. 打开 Cursor Settings -> MCP
|
||||
2. 添加名为 `Atlassian` 的插件
|
||||
3. 按照提示完成授权并启用 skill
|
||||
- 确保可用工具在 `mcps/plugin-atlassian-atlassian/tools/` 目录下已列出。
|
||||
| 方式 | 适用场景 |
|
||||
|------|----------|
|
||||
| **REST 脚本(推荐通用)** | 任意 IDE;仅需 `jira_upload.env`(与上传附件**同一套** Token)。查 Issue、新建 Task、写评论、上传附件**全部**可走脚本。 |
|
||||
| **Atlassian MCP** | 仅 Cursor 等已安装并授权 Atlassian 插件的环境;可与脚本混用(附件仍必须用脚本)。 |
|
||||
|
||||
**4. 上传附件配置(用于将影响分析报告 MD 上传到 Jira)**
|
||||
**无 MCP 时**:必须配置 `jira_upload.env`,并用 `scripts/jira_cli.py` 完成 Step 3/4 中的查询、创建与评论(见下文「无 MCP 执行要点」)。
|
||||
|
||||
- Atlassian MCP 插件不支持上传文件,本技能使用自带脚本 `scripts/upload_attachment.py` 将报告 MD 上传为 Issue 附件。
|
||||
- 在技能目录或脚本目录放置 `jira_upload.env`(或使用 `--config` 指定路径),包含:`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`。格式见 `jira_upload.env.example`,详细说明见 `references/env_config.md`。
|
||||
- 若未配置,上传附件步骤跳过,仅通过 `jira_add_comment` 写入报告;已配置则先上传 MD 再写评论。
|
||||
**有 MCP 时**:可用 MCP 搜索/创建/评论;或仍用 `jira_cli.py`(行为一致,便于脚本化)。
|
||||
|
||||
**4. `jira_upload.env`(上传附件 + 无 MCP 时全部 Jira API)**
|
||||
|
||||
- 在技能目录 `skills/g3fo-commit-jira/` 放置 `jira_upload.env`(或 `--config` 指定),包含:`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`。示例见 `jira_upload.env.example`,说明见 `references/env_config.md`。
|
||||
- **上传附件**:`scripts/upload_attachment.py`(MCP 不支持上传文件)。
|
||||
- **无 MCP 时查 Jira / 建单 / 评论**:`scripts/jira_cli.py`,与上传使用**相同** Token,详见 `references/env_config.md` 与 `references/workflow.md`「无 MCP / jira_cli」。
|
||||
- 若未配置:无法调用 Jira API;须提示用户创建 `jira_upload.env` 或在本机 Cursor 使用 MCP。
|
||||
|
||||
---
|
||||
|
||||
## 执行步骤
|
||||
## 执行步骤(严格顺序)
|
||||
|
||||
> **提醒**:仅当上一节「执行契约」中本步的前置条件已满足时,才执行本节对应步骤。
|
||||
|
||||
### Step 1 - 获取提交内容
|
||||
|
||||
```powershell
|
||||
git show <hash> # 获取 diff + 元数据
|
||||
git log -1 --format="%B" <hash> # 获取原始 commit message
|
||||
git branch -r --contains <hash> # 检查是否已推送到远程(有输出则已 push)
|
||||
```
|
||||
|
||||
分析要点:
|
||||
- 涉及的服务模块/包名
|
||||
- 新增/修改/删除的方法与接口
|
||||
- 逻辑改动的核心目的
|
||||
- **是否已推送**:如果 `git branch -r --contains <hash>` 有输出,说明已推送,Step 5 将跳过 amend
|
||||
|
||||
### Step 2 - 判定任务类型并确定需生成的文档
|
||||
|
||||
根据改动内容与 `references/jira_commit_docs_policy.md` 中的**详细条件表**,先判定任务类型,再决定需要生成并上传的文档:
|
||||
**前置**:Step 1 已完成。
|
||||
根据改动内容与 `references/jira_commit_docs_policy.md` 中的**详细条件表**,先判定任务类型,再决定需要生成并上传的文档(须在回复中写明类型与三文档要/不要):
|
||||
|
||||
| 任务类型 | 开发计划 | 影响分析 | 任务摘要 |
|
||||
|----------|:--------:|:--------:|:--------:|
|
||||
@@ -78,29 +123,45 @@ git log -1 --format="%B" <hash> # 获取原始 commit message
|
||||
|
||||
### Step 2(续)- 生成所需报告内容
|
||||
|
||||
对上述判定为“需要”的文档,按 `references/workflow.md` 中的模板生成内容(可先存于内存或临时文件):
|
||||
对上述判定为"需要"的文档,按 `references/workflow.md` 中的模板生成内容(可先存于内存或临时文件):
|
||||
|
||||
- **开发计划**:步骤、设计方法、技术决策。
|
||||
- **影响分析**:改动概览、方法级分析、影响范围、风险与回滚、验证与测试(模板见 workflow.md)。
|
||||
- **任务摘要**:所完成工作的简要回顾,以及任何偏离计划之处。
|
||||
|
||||
生成后先不按“公司附件名”落盘,等取得 Jira 编号后再以 `<JIRA-ID>_*.md` 命名保存并上传。
|
||||
生成后先不按"公司附件名"落盘,等取得 Jira 编号后再以 `<JIRA-ID>_*.md` 命名保存并上传。
|
||||
|
||||
**文档保存路径规则**:
|
||||
- 所有生成的 MD 文档统一保存到项目根目录下的 `doc/{日期}` 文件夹
|
||||
- 日期格式为 `YYYY-MM-DD`(如 `doc/2026-03-26`)
|
||||
- 若该文件夹不存在,**必须先创建**再保存文件
|
||||
- 示例路径:`doc/2026-03-26/G3SF-123_Impact_Analysis.md`
|
||||
|
||||
### Step 3 - 确定 Jira 任务
|
||||
|
||||
**前置**:Step 2 已完成(至少已判定文档需求;若需文档,可先草稿内容,**真实 Key 确定后再按 `<KEY>_*.md` 保存**)。
|
||||
|
||||
**1. 如果用户已提供 Jira 编号:**
|
||||
- 直接使用该编号(如 `G3SF-123`),进入 Step 4。
|
||||
- **直接使用该编号**(如 `G3SF-123`),**跳过查找、选择和新建流程**,直接进入 Step 4。
|
||||
- 不执行 JQL 搜索,不展示列表,不创建新 Issue。
|
||||
|
||||
**2. 如果用户未提供 Jira 编号:**
|
||||
- 使用 JQL 查找当前用户在 G3SF 项目下的进行中任务(limit 5):
|
||||
```
|
||||
project = G3SF AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC
|
||||
```
|
||||
- 展示列表供用户选择,或允许用户输入 `0` 新建。
|
||||
- **有 MCP**:用 MCP 的 JQL 搜索,展示列表。
|
||||
- **无 MCP**:执行(PowerShell 用 `;` 分隔):
|
||||
```powershell
|
||||
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" search --limit 5
|
||||
```
|
||||
可加 `--format json` 供解析。展示列表供用户选择,或输入 `0` 新建。
|
||||
- 如果搜索结果为空,自动进入新建流程。
|
||||
|
||||
### Step 4 - 写入 Jira(附件 + 评论)
|
||||
|
||||
**前置**:已持有本任务最终 **Issue Key**(Step 3)。
|
||||
|
||||
**附件命名规范(公司要求)**:上传到 Jira 的 MD 文件名必须为
|
||||
`<JIRA-ID>_Dev_Plan.md`、`<JIRA-ID>_Impact_Analysis.md`、`<JIRA-ID>_Task_Summary.md`。
|
||||
根据 Step 2 判定结果,仅上传“需要”的文档;每个文件先按该命名写入本地再上传(脚本以本地文件名为 Jira 附件名)。
|
||||
@@ -108,29 +169,59 @@ project = G3SF AND assignee = currentUser() AND statusCategory != Done ORDER BY
|
||||
**如果选择/新建的任务 ID > 0:**
|
||||
|
||||
1. **保存并上传附件**(已配置 `jira_upload.env` 且本任务需要文档时):
|
||||
将 Step 2 生成的各文档内容,按公司规范命名写入当前项目目录(或 `docs/` 等),再调用上传脚本。PowerShell 中不要用 `&&`,改用 `;` 或换行。
|
||||
将 Step 2 生成的各文档内容,按公司规范命名写入项目 `doc/{日期}` 目录(日期格式 `YYYY-MM-DD`,若不存在则先创建),再调用上传脚本。PowerShell 中不要用 `&&`,改用 `;` 或换行。
|
||||
|
||||
```powershell
|
||||
# 创建日期目录(若不存在)
|
||||
$dateFolder = "doc/$(Get-Date -Format 'yyyy-MM-dd')"
|
||||
if (-not (Test-Path $dateFolder)) { New-Item -ItemType Directory -Path $dateFolder -Force }
|
||||
|
||||
# 示例:需要三份文档时,先写入 G3SF-123_Dev_Plan.md、G3SF-123_Impact_Analysis.md、G3SF-123_Task_Summary.md,再:
|
||||
python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3fo-commit-jira/jira_upload.env" --issue "G3SF-123" --file "G3SF-123_Impact_Analysis.md" --file "G3SF-123_Dev_Plan.md" --file "G3SF-123_Task_Summary.md"
|
||||
python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3fo-commit-jira/jira_upload.env" --issue "G3SF-123" --file "$dateFolder/G3SF-123_Impact_Analysis.md" --file "$dateFolder/G3SF-123_Dev_Plan.md" --file "$dateFolder/G3SF-123_Task_Summary.md"
|
||||
```
|
||||
|
||||
若本任务仅需影响分析,则只写入并上传 `G3SF-123_Impact_Analysis.md`。未配置 `jira_upload.env` 时跳过上传,仅写评论。
|
||||
2. **写评论**:若有影响分析内容,调用 `jira_add_comment` 将影响分析报告正文作为评论写入 issue。
|
||||
若本任务仅需影响分析,则只写入并上传 `G3SF-123_Impact_Analysis.md`。未配置 `jira_upload.env` 时跳过上传;有 MCP 时可仅写评论,无 MCP 则必须配置 env 才能完成评论。
|
||||
2. **写评论**(有影响分析正文时):
|
||||
- **MCP**:`jira_add_comment`,正文为影响分析报告(插件侧多为 Markdown 渲染)。
|
||||
- **无 MCP**:`jira_cli.py comment/create` 会将常见 **Markdown**(`#`~`######` 标题、`-`/`1.` 列表、`**粗体**`、`` `代码` ``、代码块、`[链](url)` 等)转为 **Jira ADF**,在网页上与富文本一致;仍建议长文用 `--body-file`。将报告写入临时文件后:
|
||||
```powershell
|
||||
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" comment --issue "G3SF-123" --body-file "path\to\impact_body.md"
|
||||
```
|
||||
或使用 `--body "..."`(长文建议 `--body-file`)。
|
||||
3. 记录该任务的 issue key。
|
||||
|
||||
**如果用户选择 0 或搜索无结果:**
|
||||
|
||||
- 调用 `jira_create_issue` 创建新任务,详情见 `references/workflow.md`
|
||||
- 设置 project key: `G3SF`, issue type: `Task`
|
||||
- **Summary**:必须在标题前添加 `[G3SF]` 前缀,格式为 `[G3SF] 提炼后的改动摘要`
|
||||
- **Description**:完整影响分析报告(Markdown 格式);若本任务需开发计划/任务摘要,可在创建后通过附件上传
|
||||
- **Assignee**:必须设置为当前登录账号(由 MCP 传入当前用户或等效参数)
|
||||
- 获取返回的新 issue key(如 `G3SF-123`)
|
||||
- 若已配置 `jira_upload.env` 且本任务需要文档,创建成功后按公司命名写入并上传对应 MD 文件到该新 Issue
|
||||
- **MCP**:调用 `jira_create_issue`(见 `references/workflow.md`)。
|
||||
- **无 MCP**:使用 `jira_cli.py create`(经办人默认为 Token 对应用户):
|
||||
```powershell
|
||||
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" create --summary "[G3SF] 提炼后的改动摘要" --description-file "path\to\impact.md"
|
||||
```
|
||||
脚本会打印新 issue key(如 `G3SF-123`);可加 `--format json` 解析 `key` 字段。
|
||||
|
||||
**新建 Issue 字段约定**(两种途径均需遵守):
|
||||
|
||||
- project: `G3SF`,issuetype: `Task`
|
||||
- **Summary**:`[G3SF]` 前缀 + 一句话摘要
|
||||
- **Description**:完整影响分析(Markdown 可先写入文件再用 `--description-file`)
|
||||
- **Assignee**:当前用户(MCP 传 accountId;`jira_cli` 默认 `assignee` = API Token 对应账号)
|
||||
|
||||
- 若需要文档,创建成功后按公司命名上传对应 MD 到该 Issue
|
||||
|
||||
### Step 5 - 更新 Git 提交信息
|
||||
|
||||
**前置**:Step 4 已按政策完成(无需文档的绑定类任务可跳过上传/评论,但须已有 Key)。
|
||||
|
||||
**检查提交是否已推送**:
|
||||
```powershell
|
||||
git branch -r --contains <hash>
|
||||
```
|
||||
- 如果有输出,说明提交已推送到远程分支,**跳过 amend**,告知用户:
|
||||
> "提交 `<hash>` 已推送到远程,无法 amend。已完成 Jira 绑定和文档上传。如需修改 commit message,请手动处理或提供特别说明。"
|
||||
- 如果无输出,说明提交未推送,继续执行 amend。
|
||||
|
||||
**执行 amend(仅当未推送时)**:
|
||||
|
||||
使用获取到的 Jira 编号,通过 `git commit --amend` 更新 commit message。**严禁 push**。
|
||||
|
||||
**注意**:在 Windows PowerShell 环境下,需确保 UTF8 编码以防止中文乱码。
|
||||
@@ -149,16 +240,27 @@ git commit --amend -m "$origMsg`n`n**** Jira $jiraKey" --cleanup=verbatim
|
||||
|
||||
完成后告知用户:
|
||||
|
||||
**未推送时**:
|
||||
```
|
||||
绑定成功:
|
||||
Jira: G3SF-123 (https://your-jira/browse/G3SF-123)
|
||||
Commit: <git log --oneline -1 的结果>
|
||||
已追加 Jira 编号到 commit message
|
||||
```
|
||||
|
||||
**已推送时**:
|
||||
```
|
||||
绑定完成(提交已推送,未修改 commit message):
|
||||
Jira: G3SF-123 (https://your-jira/browse/G3SF-123)
|
||||
Commit: <git log --oneline -1 的结果>
|
||||
提示:提交已推送到远程,如需修改 commit message 请手动处理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关参考
|
||||
|
||||
- **AI 逐步检查清单(防走偏)**:`references/agent_execution_checklist.md`
|
||||
- **公司 Jira 附件文档政策**(何时附加哪些文件):`references/jira_commit_docs_policy.md`
|
||||
- **报告模板、三文档说明与错误处理**:`references/workflow.md`
|
||||
- **上传附件配置**:`references/env_config.md`;脚本 `scripts/upload_attachment.py`,依赖见 `scripts/requirements.txt`
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 任务总结 — g3fo-commit-jira 增强
|
||||
|
||||
## 任务信息
|
||||
|
||||
- **任务**:无 Atlassian MCP 时仍可通过同一 Jira Token 查 Jira、建单、评论。
|
||||
- **范围**:`skills/g3fo-commit-jira`。
|
||||
|
||||
## 改动说明
|
||||
|
||||
- 新增 `scripts/jira_cli.py`(search / issue / create / comment / myself)。
|
||||
- 新增 `scripts/jira_env.py`,`upload_attachment.py` 复用配置加载;支持在技能目录放置 `jira_upload.env`。
|
||||
- 更新 `SKILL.md`、`references/workflow.md`、`references/env_config.md`。
|
||||
|
||||
## 影响与风险
|
||||
|
||||
- 无破坏性变更;风险低。详见 `IMPACT_ANALYSIS.md`。
|
||||
|
||||
## 测试
|
||||
|
||||
- 本地语法与 CLI `--help` 已验证;连通性依赖用户 `jira_upload.env`。
|
||||
|
||||
## 后续
|
||||
|
||||
- 若需同步到全局技能目录,可使用项目内 `skill-sync` 流程。
|
||||
|
||||
---
|
||||
|
||||
## 任务总结 — AI 流程防走偏(文档增强)
|
||||
|
||||
- **目标**:减少 Agent 跳步、误上传、未等用户选 Issue 等偏离。
|
||||
- **改动**:`SKILL.md` 增加「AI 执行契约」与各 Step 前置条件;新增 `references/agent_execution_checklist.md`;`workflow.md` 增加执行顺序提醒。
|
||||
- **测试**:文档审阅;逻辑无代码变更。
|
||||
@@ -0,0 +1,80 @@
|
||||
# AI 执行检查清单(g3fo-commit-jira)
|
||||
|
||||
执行本技能时建议 **边做边勾**( mentally 或写在回复里),避免跳步、顺序颠倒或误用工具。
|
||||
|
||||
---
|
||||
|
||||
## 开始前(Step 0)
|
||||
|
||||
- [ ] 用户已提供 **git 修订号**(如 `HEAD` 或完整/短 hash)
|
||||
- [ ] 在 **目标业务仓库根目录** 执行 git(`git rev-parse --is-inside-work-tree` 为真)
|
||||
- [ ] 已执行 `git rev-parse <hash>`,修订号存在
|
||||
- [ ] 若要对提交做 amend:已确认 `git rev-parse HEAD` **等于** `git rev-parse <hash>`(否则停止,提示用户)
|
||||
- [ ] 已检查 `git branch -r --contains <hash>`,确认提交是否已推送(已推送则跳过 amend)
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — 获取提交内容
|
||||
|
||||
- [ ] 已运行 `git show <hash>`(或等效)并理解改动范围
|
||||
- [ ] 已记录原始 commit message(供 Step 5)
|
||||
|
||||
**未完成 Step 0~1 前:禁止** 写 Jira、生成报告、amend。
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — 任务类型与文档
|
||||
|
||||
- [ ] 已阅读并对照 `references/jira_commit_docs_policy.md`(或 SKILL 中的条件表)
|
||||
- [ ] 已明确写出:**任务类型** + **开发计划 / 影响分析 / 任务摘要** 各是否需要(✅/❌)
|
||||
- [ ] 若需要文档:已按 `workflow.md` 模板准备内容(可先草稿,**真实 Jira Key 出来后再按名落盘**)
|
||||
|
||||
**禁止**:未判定类型就上传三份或一份都不写却写长评论(应与政策一致)。
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Jira Key
|
||||
|
||||
- [ ] 已有 **真实 Issue Key**(如 `G3SF-123`):来自用户直给、用户从列表选择、或 `create` 成功返回
|
||||
- [ ] 若用户已提供 Jira 编号:**跳过搜索、展示列表和新建流程**,直接使用该编号
|
||||
- [ ] 若走列表:已 **等用户选 1~N 或 0**,未擅自替用户绑定
|
||||
|
||||
**未取得真实 Key 前:禁止** 使用 `<JIRA-ID>_*.md` 落盘上传(禁止占位符 Key)。
|
||||
**用户已提供 Jira 时:禁止** 执行搜索、展示列表或创建新 Issue。
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — 写入 Jira
|
||||
|
||||
- [ ] 需要附件时:文件名为 `<KEY>_Dev_Plan.md` / `_Impact_Analysis.md` / `_Task_Summary.md`(仅实际上传需要的)
|
||||
- [ ] 文档保存路径:`doc/{日期}`(日期格式 `YYYY-MM-DD`),若目录不存在则先创建
|
||||
- [ ] 附件 **仅** 通过 `scripts/upload_attachment.py` + `jira_upload.env`(不用 MCP 冒充上传)
|
||||
- [ ] 需要影响分析进评论时:MCP `jira_add_comment` 或 `jira_cli.py comment`(与政策一致)
|
||||
- [ ] 新建 Issue 时:Summary 带 `[G3SF]`,类型 Task,描述/评论与内容一致
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Git amend
|
||||
|
||||
- [ ] 已检查 `git branch -r --contains <hash>`:若已推送,**跳过 amend**,告知用户
|
||||
- [ ] 若未推送:仅 `git commit --amend`,**未** `git push`
|
||||
- [ ] Windows 已按 SKILL 处理 UTF-8 / `--cleanup=verbatim`(如适用)
|
||||
- [ ] 若 message 已含 `**** Jira`,未重复追加
|
||||
|
||||
---
|
||||
|
||||
## 走偏速查
|
||||
|
||||
| 现象 | 纠正 |
|
||||
|------|------|
|
||||
| 先写 Jira 再去看 diff | 回到 Step 1 |
|
||||
| 没有 Key 就上传 | 先完成 Step 3 |
|
||||
| 用 MCP 上传 md | 改用 `upload_attachment.py` |
|
||||
| 简单拼写改却写三份文档 | 重读政策表,按类型裁剪 |
|
||||
| 执行了 push | 违反技能范围,后续勿再 push |
|
||||
| amend 已推送的提交 | 先检查 `git branch -r --contains`,已推送则跳过 amend |
|
||||
| 用户已给 Jira 却还搜索/新建 | 直接使用用户提供的编号,跳过 Step 3 的搜索流程 |
|
||||
|
||||
---
|
||||
|
||||
**原则**:顺序 = 1 → 2 → 3 → 4 → 5;每步通过后再进入下一步;不确定时重读 `SKILL.md` 本节与 `workflow.md` 错误处理表。
|
||||
@@ -49,6 +49,35 @@ python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3f
|
||||
|
||||
---
|
||||
|
||||
## 无 MCP:`jira_cli.py`(查 Jira / 建单 / 评论)
|
||||
|
||||
在 **未安装 Atlassian MCP** 的环境(其它 IDE、终端)中,使用与上传附件**相同**的 `jira_upload.env`,通过 `scripts/jira_cli.py` 调用 Jira REST API v3:
|
||||
|
||||
| 子命令 | 作用 |
|
||||
|--------|------|
|
||||
| `myself` | 当前用户 accountId / 邮箱 |
|
||||
| `search` | JQL 搜索(默认:G3SF 进行中且指派给当前用户) |
|
||||
| `issue <KEY>` | 查看单条 Issue |
|
||||
| `create` | 创建 Issue(默认 G3SF + Task,经办人为 Token 用户) |
|
||||
| `comment` | 添加评论 |
|
||||
|
||||
依赖:`pip install -r scripts/requirements.txt`(仅需 `requests`)。
|
||||
|
||||
```powershell
|
||||
# 搜索(与技能 Step 3 默认 JQL 一致)
|
||||
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" search --limit 5
|
||||
|
||||
# 新建(描述来自文件)
|
||||
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" create --summary "[G3SF] 简述" --description-file "D:\tmp\impact.md"
|
||||
|
||||
# 评论
|
||||
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" comment --issue G3SF-123 --body-file "D:\tmp\comment.md"
|
||||
```
|
||||
|
||||
配置文件查找顺序:当前工作目录、`scripts/` 下、`skills/g3fo-commit-jira/` 下的 `jira_upload.env`。详细参数见 `references/workflow.md`「无 MCP:jira_cli 速查」。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量覆盖
|
||||
|
||||
若同时存在配置文件和环境变量,**环境变量优先**。
|
||||
|
||||
@@ -46,3 +46,9 @@
|
||||
|
||||
示例:工单 `G3SF-123` 的三个附件命名为
|
||||
`G3SF-123_Dev_Plan.md`、`G3SF-123_Impact_Analysis.md`、`G3SF-123_Task_Summary.md`。
|
||||
|
||||
**本地保存路径**:
|
||||
- 所有文档统一保存到项目根目录下的 `doc/{日期}` 文件夹
|
||||
- 日期格式为 `YYYY-MM-DD`(如 `doc/2026-03-26`)
|
||||
- 若该文件夹不存在,**必须先创建**再保存文件
|
||||
- 完整路径示例:`doc/2026-03-26/G3SF-123_Impact_Analysis.md`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# G3FO Commit Jira — 详细工作流参考
|
||||
|
||||
## 目录
|
||||
0. [AI 执行顺序提醒](#ai-执行顺序提醒)
|
||||
1. [公司 Jira 附件文档政策](#公司-jira-附件文档政策)
|
||||
2. [Step 1: 获取并解析提交内容](#step-1-获取并解析提交内容)
|
||||
3. [Step 2: 任务类型判定与三文档模板](#step-2-任务类型判定与三文档模板)
|
||||
@@ -12,6 +13,13 @@
|
||||
|
||||
---
|
||||
|
||||
## AI 执行顺序提醒
|
||||
|
||||
与 **`SKILL.md` 中「AI 执行契约」** 一致:必须 **Step 1 → 2 → 3 → 4 → 5**,不可跳步。附件仅通过 **`upload_attachment.py`**;**禁止 push**。
|
||||
逐步打勾请用 **`references/agent_execution_checklist.md`**。
|
||||
|
||||
---
|
||||
|
||||
## 公司 Jira 附件文档政策
|
||||
|
||||
自 **2026年3月9日** 起,何时附加「开发计划」「影响分析」「任务摘要」以公司强制性指南为准。
|
||||
@@ -34,6 +42,10 @@ $head = git rev-parse HEAD
|
||||
$provided = git rev-parse <hash>
|
||||
if ($head -ne $provided) { Write-Error "提交不是 HEAD,无法 amend" }
|
||||
|
||||
# 检查提交是否已推送到远程
|
||||
git branch -r --contains <hash>
|
||||
# 如果有输出,说明已推送,Step 5 将跳过 amend
|
||||
|
||||
# 获取完整 diff 和元数据
|
||||
git show <hash>
|
||||
|
||||
@@ -55,6 +67,7 @@ git diff-tree --no-commit-id -r --name-status <hash>
|
||||
| `+` 行 | 新增代码(方法签名、接口、配置) |
|
||||
| `-` 行 | 删除/替换代码 |
|
||||
| 文件路径中的包名 | 推断所属服务(`g3fo-trade-service`, `g3fo-margin-service` 等) |
|
||||
| `git branch -r --contains` 输出 | 是否已推送(有输出 = 已推送,跳过 amend) |
|
||||
|
||||
G3FO 服务包路径规律:
|
||||
- `com.afe.g3fo.<service-short-name>.*` → 对应服务
|
||||
@@ -163,6 +176,17 @@ G3FO 服务包路径规律:
|
||||
|
||||
## Step 3: Jira 任务查找与确认
|
||||
|
||||
### 用户已提供 Jira 编号
|
||||
|
||||
如果用户已提供 Jira 编号(如 `G3SF-123`):
|
||||
- **直接使用该编号**,跳过 JQL 搜索、列表展示和新建流程
|
||||
- **禁止**执行搜索、展示列表或创建新 Issue
|
||||
- 直接进入 Step 4 上传文档和评论
|
||||
|
||||
### 用户未提供 Jira 编号
|
||||
|
||||
执行 JQL 查询:
|
||||
|
||||
### JQL 查询
|
||||
|
||||
```
|
||||
@@ -223,12 +247,23 @@ Summary 生成规则:
|
||||
- `<JIRA-ID>_Dev_Plan.md`
|
||||
- `<JIRA-ID>_Impact_Analysis.md`
|
||||
- `<JIRA-ID>_Task_Summary.md`
|
||||
仅保存本任务类型要求的那几份(见 [2.1 按任务类型确定需生成的文档](#21-按任务类型确定需生成的文档))。保存位置:当前 Git 项目根目录或项目约定目录(如 `docs/`)。
|
||||
仅保存本任务类型要求的那几份(见 [2.1 按任务类型确定需生成的文档](#21-按任务类型确定需生成的文档))。
|
||||
|
||||
**保存路径规则**:
|
||||
- 所有文档统一保存到项目根目录下的 `doc/{日期}` 文件夹
|
||||
- 日期格式为 `YYYY-MM-DD`(如 `doc/2026-03-26`)
|
||||
- 若该文件夹不存在,**必须先创建**再保存文件
|
||||
- 完整路径示例:`doc/2026-03-26/G3SF-123_Impact_Analysis.md`
|
||||
|
||||
2. **上传到 Jira 附件**:Atlassian MCP 不支持上传文件,使用本技能自带脚本上传上述 MD 到当前 Issue:
|
||||
- 配置文件:在技能目录下配置 `jira_upload.env`(`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`),详见 `references/env_config.md`。
|
||||
- 命令示例(PowerShell 用 `;` 连接,勿用 `&&`),按需上传多文件:
|
||||
```powershell
|
||||
python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3fo-commit-jira/jira_upload.env" --issue "G3SF-101" --file "G3SF-101_Impact_Analysis.md" --file "G3SF-101_Dev_Plan.md" --file "G3SF-101_Task_Summary.md"
|
||||
# 创建日期目录(若不存在)
|
||||
$dateFolder = "doc/$(Get-Date -Format 'yyyy-MM-dd')"
|
||||
if (-not (Test-Path $dateFolder)) { New-Item -ItemType Directory -Path $dateFolder -Force }
|
||||
|
||||
python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3fo-commit-jira/jira_upload.env" --issue "G3SF-101" --file "$dateFolder/G3SF-101_Impact_Analysis.md" --file "$dateFolder/G3SF-101_Dev_Plan.md" --file "$dateFolder/G3SF-101_Task_Summary.md"
|
||||
```
|
||||
- 未配置 `jira_upload.env` 时跳过上传,仅写评论。
|
||||
|
||||
@@ -251,6 +286,28 @@ Summary 生成规则:
|
||||
|
||||
## Step 5: Git Amend 命令详解
|
||||
|
||||
### 检查提交是否已推送
|
||||
|
||||
在执行 amend 前,必须检查提交是否已推送到远程:
|
||||
|
||||
```powershell
|
||||
# 检查提交是否已推送
|
||||
$pushedBranches = git branch -r --contains <hash>
|
||||
if ($pushedBranches) {
|
||||
Write-Host "提交已推送到远程,跳过 amend"
|
||||
Write-Host "已完成 Jira 绑定和文档上传"
|
||||
Write-Host "如需修改 commit message,请手动处理或提供特别说明"
|
||||
# 跳过 amend,直接结束
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**已推送的处理**:
|
||||
- 如果 `git branch -r --contains <hash>` 有输出,说明提交已推送到远程分支
|
||||
- **跳过 amend**,仅完成 Jira 绑定和文档上传
|
||||
- 告知用户提交已推送,无法 amend
|
||||
- 例外:用户明确要求修改已推送的提交时,需确认风险后再执行
|
||||
|
||||
### PowerShell(Windows 环境)
|
||||
|
||||
在 Windows PowerShell 中,直接使用 `$origMsg` 可能会导致编码问题(乱码)。必须显式指定输出编码为 UTF8。
|
||||
@@ -297,20 +354,43 @@ git log --oneline -1 # 查看 commit 摘要
|
||||
|
||||
---
|
||||
|
||||
## 无 MCP:`jira_cli.py` 速查
|
||||
|
||||
与 `upload_attachment.py` **共用** `jira_upload.env`(`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`)。从仓库根执行时建议 `--config` 指向技能目录下的配置文件。
|
||||
|
||||
| 操作 | 命令示例 |
|
||||
|------|----------|
|
||||
| 当前用户(accountId) | `python .../jira_cli.py --config ".../jira_upload.env" myself` |
|
||||
| 默认 JQL 搜索(G3SF 进行中、指派给我) | `python .../jira_cli.py --config "..." search --limit 5` |
|
||||
| 自定义 JQL | `python .../jira_cli.py --config "..." search --jql "project = G3SF AND key = G3SF-1" --limit 10` |
|
||||
| 机器可读 | 上述命令加 `--format json` |
|
||||
| 单条 Issue | `python .../jira_cli.py --config "..." issue G3SF-123` |
|
||||
| 新建 Task | `python .../jira_cli.py --config "..." create --summary "[G3SF] 标题" --description-file report.md` |
|
||||
| 新建(内联描述) | `create --summary "..." --description "多段用\n\n分隔"` |
|
||||
| 添加评论 | `python .../jira_cli.py --config "..." comment --issue G3SF-123 --body-file impact.md` |
|
||||
| 不上传经办人 | `create ... --no-assign-self`(若站点禁止创建时指定经办人) |
|
||||
|
||||
**说明**:`create` 默认 `project=G3SF`、`issuetype=Task`;描述与评论由脚本将 **Markdown** 转为 Jira Cloud **ADF**(`#` 标题、列表、`**粗体**`、代码块等会按富文本展示,不再出现 `###` 原文)。上传 MD 附件仍用 `upload_attachment.py`。
|
||||
|
||||
---
|
||||
|
||||
## 错误处理表
|
||||
|
||||
| 错误场景 | 检测方式 | 处理方式 |
|
||||
|---------|---------|---------|
|
||||
| 修订号不存在 | `git rev-parse <hash>` 报错 | **停止**,提示用户提供正确的修订号。 |
|
||||
| 提交 hash 不是 HEAD | `git rev-parse HEAD` ≠ `git rev-parse <hash>` | 停止,提示用户确认 hash(只有 HEAD 才能 amend)。 |
|
||||
| 提交已推送到远程 | `git branch -r --contains <hash>` 有输出 | **跳过 amend**,仅完成 Jira 绑定和文档上传,告知用户提交已推送。 |
|
||||
| 项目路径不正确 | `git rev-parse --is-inside-work-tree` 失败 | 提示用户在正确的 git 项目目录下操作。 |
|
||||
| Atlassian MCP 未鉴权 | `STATUS.md` 有提示 / 工具调用返回 401 | 调用 `mcp_auth`,等待用户完成授权。 |
|
||||
| Atlassian 插件未安装 | `tools/` 目录为空或 MCP 调用失败 | 引导用户在 Cursor Settings → MCP 安装 Atlassian 插件 |
|
||||
| Jira 项目 G3SF 不存在 | `jira_create_issue` 返回 404/400 | 提示用户确认 Jira 实例 URL 和项目 key |
|
||||
| 无 MCP 且未配置 jira_upload.env | 无法执行 jira_cli / 上传 | **必须**配置 `jira_upload.env` 或改用 Cursor + MCP。 |
|
||||
| Atlassian MCP 未鉴权 | `STATUS.md` 有提示 / 工具调用返回 401 | 调用 `mcp_auth`,或改用 `jira_cli.py`。 |
|
||||
| Atlassian 插件未安装 | `tools/` 目录为空或 MCP 调用失败 | 使用 `jira_cli.py` + `jira_upload.env`,或在 Cursor 安装 Atlassian 插件 |
|
||||
| Jira 项目 G3SF 不存在 | 创建/搜索返回 404/400 | 提示用户确认 `JIRA_BASE_URL`、项目 key;`create` 可用 `--project` |
|
||||
| 工作区有未提交变更 | `git status --porcelain` 有输出 | 警告:amend 会将未暂存变更排除在外,建议先 `git add` 或 stash |
|
||||
| commit message 中已有 Jira 行 | message 中包含 `**** Jira` | 跳过追加,提示用户该提交已绑定 |
|
||||
| 用户已提供 Jira 编号 | 用户输入包含 Jira key | **跳过搜索和新建**,直接使用用户提供的编号 |
|
||||
| Jira 搜索无结果 | 工具返回空列表 | 直接进入新建流程,无需用户确认 |
|
||||
| 未配置 jira_upload.env | 上传前检查配置不存在 | 跳过上传附件,仅通过 jira_add_comment 写入报告;提示用户可配置后使用脚本上传 |
|
||||
| 未配置 jira_upload.env | 上传前检查配置不存在 | **有 MCP**:跳过上传,仅评论。**无 MCP**:无法完成 Jira 操作,须配置 env 或使用 MCP。 |
|
||||
| 任务类型判定为「无需文档」 | 按政策表属简单漏洞/琐碎改动 | 不生成报告、不写评论、不上传附件;仍执行 Jira 绑定与 commit --amend,将 Jira 编号追加到 commit message |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
#!/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**, ~~strikethrough~~, `code`, *italic*, [text](url), ***bold italic***
|
||||
into ADF text nodes with marks.
|
||||
"""
|
||||
if not s:
|
||||
return []
|
||||
# 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]] = []
|
||||
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"}, {"type": "em"}],
|
||||
}
|
||||
)
|
||||
elif m.group(4) is not None:
|
||||
nodes.append(
|
||||
{"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):
|
||||
tail = s[last:]
|
||||
if tail:
|
||||
nodes.append({"type": "text", "text": tail})
|
||||
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
|
||||
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
|
||||
|
||||
# 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()
|
||||
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 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))
|
||||
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/jql"
|
||||
payload = {
|
||||
"jql": jql,
|
||||
"maxResults": limit,
|
||||
"fields": ["key", "summary", "status", "assignee", "updated"],
|
||||
}
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=session_headers(email, token),
|
||||
json=payload,
|
||||
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 unescape_text(p.read_text(encoding="utf-8"))
|
||||
return unescape_text(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())
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared Jira config loading for g3fo-commit-jira scripts.
|
||||
Uses the same jira_upload.env / env vars as upload_attachment.py.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
CONFIG_FILENAME = "jira_upload.env"
|
||||
|
||||
|
||||
def find_config_file(explicit_path: Optional[str], script_dir: Path) -> Optional[Path]:
|
||||
if explicit_path:
|
||||
p = Path(explicit_path)
|
||||
return p if p.exists() and p.is_file() else None
|
||||
cwd_file = Path.cwd() / CONFIG_FILENAME
|
||||
if cwd_file.exists():
|
||||
return cwd_file
|
||||
script_dir_file = script_dir / CONFIG_FILENAME
|
||||
if script_dir_file.exists():
|
||||
return script_dir_file
|
||||
parent_skill = script_dir.parent / CONFIG_FILENAME
|
||||
if parent_skill.exists():
|
||||
return parent_skill
|
||||
return None
|
||||
|
||||
|
||||
def load_config(config_path: Optional[str] = None, script_dir: Optional[Path] = None) -> Dict[str, str]:
|
||||
"""Load KEY=VALUE from jira_upload.env. Env vars override file values."""
|
||||
if script_dir is None:
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
path = find_config_file(config_path, script_dir)
|
||||
out: Dict[str, str] = {}
|
||||
if path:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||
for key in ("JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"):
|
||||
if key in os.environ:
|
||||
out[key] = os.environ[key]
|
||||
return out
|
||||
|
||||
|
||||
def require_credentials(cfg: Dict[str, str]) -> tuple:
|
||||
base_url = (cfg.get("JIRA_BASE_URL") or "https://n2nafe.atlassian.net").rstrip("/")
|
||||
email = cfg.get("JIRA_EMAIL")
|
||||
token = cfg.get("JIRA_API_TOKEN")
|
||||
if not email or not token:
|
||||
raise ValueError(
|
||||
"Missing JIRA_EMAIL or JIRA_API_TOKEN. "
|
||||
f"Please configure {CONFIG_FILENAME} file first. "
|
||||
f"You can apply for API token at: https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
)
|
||||
return base_url, email, token
|
||||
|
||||
|
||||
def auth_header(email: str, token: str) -> str:
|
||||
return base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||
|
||||
|
||||
def session_headers(email: str, token: str, json_body: bool = True) -> Dict[str, str]:
|
||||
h = {
|
||||
"Authorization": f"Basic {auth_header(email, token)}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if json_body:
|
||||
h["Content-Type"] = "application/json"
|
||||
return h
|
||||
@@ -2,18 +2,15 @@
|
||||
"""
|
||||
Upload one or more files as attachments to a Jira Cloud issue via REST API v3.
|
||||
|
||||
Config: from jira_upload.env (current dir or script dir) or env vars.
|
||||
Config: from jira_upload.env (current dir, script dir, or skill parent dir) or env vars.
|
||||
Usage:
|
||||
python upload_attachment.py --issue ISSUE_KEY --file path1 [--file path2 ...]
|
||||
python upload_attachment.py --config /path/to/jira_upload.env --issue KEY --file path1
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
@@ -21,57 +18,34 @@ except ImportError:
|
||||
print("ERROR: 'requests' is required. Run: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
CONFIG_FILENAME = "jira_upload.env"
|
||||
from jira_env import CONFIG_FILENAME, auth_header, load_config, require_credentials
|
||||
|
||||
|
||||
def _find_config_file(explicit_path: Optional[str], script_dir: Path) -> Optional[Path]:
|
||||
if explicit_path:
|
||||
p = Path(explicit_path)
|
||||
return p if p.exists() and p.is_file() else None
|
||||
cwd_file = Path.cwd() / CONFIG_FILENAME
|
||||
if cwd_file.exists():
|
||||
return cwd_file
|
||||
script_dir_file = script_dir / CONFIG_FILENAME
|
||||
if script_dir_file.exists():
|
||||
return script_dir_file
|
||||
return None
|
||||
|
||||
|
||||
def load_config(config_path: Optional[str]) -> Dict[str, str]:
|
||||
"""Load KEY=VALUE from jira_upload.env. Env vars override file values."""
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
path = _find_config_file(config_path, script_dir)
|
||||
out = {}
|
||||
if path:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||
for key in ("JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"):
|
||||
if key in os.environ:
|
||||
out[key] = os.environ[key]
|
||||
return out
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Upload files as attachments to a Jira issue")
|
||||
parser.add_argument("--issue", required=True, help="Jira issue key (e.g. G3SF-123)")
|
||||
parser.add_argument("--file", action="append", required=True, dest="files", help="Path to file to upload (can be repeated)")
|
||||
parser.add_argument("--config", default=None, help=f"Path to config file (default: {CONFIG_FILENAME} in cwd or script dir)")
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
action="append",
|
||||
required=True,
|
||||
dest="files",
|
||||
help="Path to file to upload (can be repeated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=None,
|
||||
help=f"Path to config file (default: {CONFIG_FILENAME} in cwd, script dir, or skill dir)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config(args.config)
|
||||
base_url = (cfg.get("JIRA_BASE_URL") or "").rstrip("/")
|
||||
email = cfg.get("JIRA_EMAIL")
|
||||
token = cfg.get("JIRA_API_TOKEN")
|
||||
|
||||
if not base_url or not email or not token:
|
||||
print("ERROR: Set JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN in config file or environment.", file=sys.stderr)
|
||||
print(f" Config file: {CONFIG_FILENAME} (in current dir or script dir), or use --config PATH", file=sys.stderr)
|
||||
try:
|
||||
cfg = load_config(args.config, SCRIPT_DIR)
|
||||
base_url, email, token = require_credentials(cfg)
|
||||
except ValueError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
print(f" Use --config PATH or create {CONFIG_FILENAME}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
issue_key = args.issue.strip()
|
||||
@@ -87,9 +61,8 @@ def main() -> int:
|
||||
files_to_upload.append(path)
|
||||
|
||||
url = f"{base_url}/rest/api/3/issue/{issue_key}/attachments"
|
||||
auth_str = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||
headers = {
|
||||
"Authorization": f"Basic {auth_str}",
|
||||
"Authorization": f"Basic {auth_header(email, token)}",
|
||||
"X-Atlassian-Token": "no-check",
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
|
||||
- **分析测试流程**:参考 `references/business_flows/_TEST_FLOW_TEMPLATE.md`。
|
||||
- **查标准运维流程**:去 `references/middleware/` 或 `references/system-init/`。
|
||||
- **查中间件总览/高可用架构**:查阅 `references/middleware/middleware-comprehensive-guide.md`。
|
||||
- **查中间件与标准组件版本**:查阅 `references/middleware/middleware-versions.md`(含 `g3fo-*-service` 应用版本、中间件与 JDK;**Web Server** 仅 Nginx / Vue / Flutter,其余为 **AP Server**;用户要「所有版本」时须分两类列出,见 §2.D)。
|
||||
- **查具体环境/客户资产**:
|
||||
- 内部环境(Dev/UAT):查阅 `references/inventory/internal.md`。
|
||||
- 外部客户(客户A、B等):查阅 `references/inventory/clients/[客户名].md`。
|
||||
@@ -61,6 +62,13 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
|
||||
4. **表格生成**:汇总信息,输出包含“步骤”、“涉及服务”、“关键接口/代码逻辑”、“测试内容/预期结果”的表格。
|
||||
- **格式参考**:`references/business_flows/_TEST_FLOW_TEMPLATE.md`。
|
||||
|
||||
### D. 版本清单问答
|
||||
1. **文档来源**:以 `references/middleware/middleware-versions.md` 为唯一标准版本表(除非用户指定环境资产清单覆盖)。
|
||||
2. **角色区分**:
|
||||
- **Web Server**:仅 **Nginx、Vue、Flutter**。
|
||||
- **AP Server**:全部 **`g3fo-*-service`**、**全部中间件**(MySQL、Redis、RocketMQ、Nacos、PowerJob、Beszel、Dozzle、dufs 等)、**OpenJDK**。
|
||||
3. **用户问「所有版本 / 完整版本 / 各组件版本号」等**:必须分两块输出,且标题或小节名明确为 **Web Server** 与 **AP Server**(可先 Web 后 AP,或先 AP 后 Web,但两类不可混为一张无标签表)。
|
||||
|
||||
## 3. 示例 Prompt (用户可参考)
|
||||
- **业务咨询**:
|
||||
- “我想测试下单业务流程,请分析代码并列出详细的测试步骤表格。”
|
||||
@@ -71,6 +79,8 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
|
||||
- “请根据 `references/middleware/middleware-comprehensive-guide.md` 和 `references/middleware/nacos/deploy.md`,结合客户资产清单生成 Nacos 部署配置。”
|
||||
- **故障排查**:
|
||||
- “我的 MySQL 出现了复制冲突,报错 `Duplicate entry`,请根据 `references/middleware/mysql/fault-analysis.md` 提供排查脚本 and 修复建议。”
|
||||
- **版本清单**:
|
||||
- “请根据 `middleware-versions.md` 列出当前约定的全部组件版本,并按 Web Server / AP Server 分开。”
|
||||
|
||||
## 4. 资源地图
|
||||
- **业务与服务**:
|
||||
@@ -79,6 +89,7 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
|
||||
- **架构概览**: `architecture/domain_overview.md`
|
||||
- **中间件标准**(部署/故障分析等详见各子目录):
|
||||
- **总览**:`middleware/middleware-comprehensive-guide.md`
|
||||
- **版本清单**:`middleware/middleware-versions.md`(Web:Nginx/Vue/Flutter;AP:`g3fo-*-service` + 中间件 + JDK)
|
||||
- **Keepalived**: `middleware/keepalived/`
|
||||
- **MySQL**: `middleware/mysql/`
|
||||
- **Nacos**: `middleware/nacos/`
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 中间件、应用服务与标准组件版本清单
|
||||
|
||||
本文档列出 g3fo 相关环境中**约定使用的镜像、发行或应用版本**,便于部署对齐、升级评估与问题排查。若与某客户现场或分支实际镜像不一致,以该环境资产清单或运行中的 `docker images` / 构建配置为准。
|
||||
|
||||
## 角色划分:AP Server 与 Web Server
|
||||
|
||||
| 角色 | 范围 | 说明 |
|
||||
| ---- | ---- | ---- |
|
||||
| **AP Server** | 全部 `g3fo-*-service` 业务应用、**全部中间件**(MySQL、Redis、RocketMQ、Nacos、PowerJob、观测与辅助组件等)、Java 运行时(OpenJDK) | 后端与基础设施侧;**不**含 Nginx / Vue / Flutter。 |
|
||||
| **Web Server** | 仅 **Nginx**、**Vue**、**Flutter** | 反向代理 / 静态与前端构建链;与 AP 侧分开罗列。 |
|
||||
|
||||
**AI / 读者输出约定**:当用户询问「所有版本」「完整版本清单」「环境上各组件版本」等时,须**分两节**回答:**先列 Web Server**,**再列 AP Server**(或按用户要求的顺序,但必须明确标注两类,不可混成一张不分角色的总表)。
|
||||
|
||||
---
|
||||
|
||||
## Web Server(仅下列三项)
|
||||
|
||||
| 组件 | 版本 / 标识 | 说明 |
|
||||
| ---- | ------------- | ---- |
|
||||
| Nginx | `1.27.3` | 反向代理 / 网关前置 |
|
||||
| Vue | `3.5.17` | 前端框架(与构建依赖对齐) |
|
||||
| Flutter | `3.38.4` | 移动端 / 跨端构建工具链 |
|
||||
|
||||
---
|
||||
|
||||
## AP Server
|
||||
|
||||
### 业务应用(`g3fo-*-service`)
|
||||
|
||||
以下应用版本统一为 **`1.5.1.22`**。
|
||||
|
||||
| 服务 | 版本 |
|
||||
| ---- | ---- |
|
||||
| g3fo-trade-service | `1.5.1.22` |
|
||||
| g3fo-product-service | `1.5.1.22` |
|
||||
| g3fo-base-service | `1.5.1.22` |
|
||||
| g3fo-admin-service | `1.5.1.22` |
|
||||
| g3fo-monitor-service | `1.5.1.22` |
|
||||
| g3fo-dx-service | `1.5.1.22` |
|
||||
| g3fo-user-service | `1.5.1.22` |
|
||||
| g3fo-margin-service | `1.5.1.22` |
|
||||
| g3fo-notification-service | `1.5.1.22` |
|
||||
| g3fo-exchange-fix-engine-service | `1.5.1.22` |
|
||||
| g3fo-gateway-service | `1.5.1.22` |
|
||||
| g3fo-push-service | `1.5.1.22` |
|
||||
| g3fo-utility-service | `1.5.1.22` |
|
||||
|
||||
### 中间件与运行时(同属 AP Server 侧)
|
||||
|
||||
| 组件 | 版本 / 镜像标识 | 说明 |
|
||||
| ---- | ---------------- | ---- |
|
||||
| MySQL | `8.4.7` | 关系型数据库 |
|
||||
| Redis | `8.4.0` | 缓存与会话等 |
|
||||
| RocketMQ | `5.3.2` | 消息队列 |
|
||||
| RocketMQ Dashboard | `2.1` | RocketMQ 管理控制台 |
|
||||
| Nacos Server | `v3.1.1` | 注册与配置中心 |
|
||||
| PowerJob Server | `v5.1.2` | 分布式任务调度 |
|
||||
| Beszel | `v0.18.4` | 轻量监控相关(容器化部署时按此标签) |
|
||||
| Dozzle | `v9.0.3` | 容器日志查看 |
|
||||
| dufs | `v0.43.0` | 文件服务(静态/上传等场景) |
|
||||
| OpenJDK | `amazoncorretto:17-al2023` | Java 运行时(Amazon Corretto 17,AL2023 基础镜像) |
|
||||
|
||||
---
|
||||
|
||||
## 使用说明
|
||||
|
||||
- **中间件**:部署、Compose 或 K8s 清单中的镜像 tag 建议与本表一致,避免隐式 `latest`。
|
||||
- **OpenJDK**:表内为 Docker 镜像习惯写法;本地非容器环境需安装同主版本 JDK 17 并与团队规范一致。
|
||||
- **Vue / Flutter**:仅归入 **Web Server**;升级时需同步 CI、锁文件与团队文档。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 中间件高可用与部署总览:`middleware-comprehensive-guide.md`
|
||||
- 各组件细则:同目录下 `mysql/`、`redis/`、`rocketmq/`、`nacos/`、`powerjob/` 等子目录
|
||||
Reference in New Issue
Block a user