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:
2026-03-23 13:38:41 +08:00
parent cd6c8a0450
commit 57fca4e468
9 changed files with 1018 additions and 78 deletions
+108
View File
@@ -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. 验证与测试
- 文档结构与人读一致性自检;无自动化测试。
+88 -25
View File
@@ -1,6 +1,6 @@
--- ---
name: g3fo-commit-jira 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 技能 # G3FO Commit Jira 技能
@@ -12,6 +12,39 @@ 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. **禁止用 Atlassian MCP 或其它方式冒充「已上传 MD 附件」**;上传文件 **必须** 使用 `scripts/upload_attachment.py`(MCP 无可靠上传能力时不得虚构成功)。
4. **禁止**在用户未选定 Issue、也未完成新建并取得 Key 的情况下,把某 Key 写进 commit message。
5. **禁止**未读 `references/jira_commit_docs_policy.md`(或本 SKILL 中的条件表)就默认「三份全要」或「一律不要文档」。
### 3. Step 3 交互规则(易走偏)
- 用户**已给** Jira 编号 → 直接进入 Step 4,Key = 用户给的编号。
- 用户**未给** → 搜索展示列表后,**必须等待用户输入 1~N 或 0**(或明确同意新建),**禁止**擅自替用户选一个 Issue 绑定。
- 搜索为空 → 可进入新建流程;新建成功后 Key 以 API 返回为准。
### 4. 自检
逐步执行时可对照 **`references/agent_execution_checklist.md`** 逐项确认。
---
## 前置要求 ## 前置要求
**1. 强制提供 Git 修订号 (hash)** **1. 强制提供 Git 修订号 (hash)**
@@ -27,25 +60,29 @@ description: G3FO 项目 Git 提交规范自动化工具。强制要求提供 gi
- 如果提供了 Jira 编号,直接引用该编号,跳过“查找/选择 Jira 任务”的步骤。 - 如果提供了 Jira 编号,直接引用该编号,跳过“查找/选择 Jira 任务”的步骤。
- 如果未提供,则按流程自动查找或提示用户新建。 - 如果未提供,则按流程自动查找或提示用户新建。
**3. Atlassian MCP 授权** **3. Jira 访问方式(二选一)**
- 查看 `mcps/plugin-atlassian-atlassian/STATUS.md` 确认已在 Cursor 中授权 MCP 插件。 | 方式 | 适用场景 |
- 如果未授权,调用 `mcp_auth`,server: `plugin-atlassian-atlassian`,参数 `{}`。 |------|----------|
- 若 MCP 插件未安装,引导用户按以下步骤操作: | **REST 脚本(推荐通用)** | 任意 IDE;仅需 `jira_upload.env`(与上传附件**同一套** Token)。查 Issue、新建 Task、写评论、上传附件**全部**可走脚本。 |
1. 打开 Cursor Settings -> MCP | **Atlassian MCP** | 仅 Cursor 等已安装并授权 Atlassian 插件的环境;可与脚本混用(附件仍必须用脚本)。 |
2. 添加名为 `Atlassian` 的插件
3. 按照提示完成授权并启用 skill
- 确保可用工具在 `mcps/plugin-atlassian-atlassian/tools/` 目录下已列出。
**4. 上传附件配置(用于将影响分析报告 MD 上传到 Jira)** **无 MCP 时**:必须配置 `jira_upload.env`,并用 `scripts/jira_cli.py` 完成 Step 3/4 中的查询、创建与评论(见下文「无 MCP 执行要点」)。
- Atlassian MCP 插件不支持上传文件,本技能使用自带脚本 `scripts/upload_attachment.py` 将报告 MD 上传为 Issue 附件。 **有 MCP 时**:可用 MCP 搜索/创建/评论;或仍用 `jira_cli.py`(行为一致,便于脚本化)。
- 在技能目录或脚本目录放置 `jira_upload.env`(或使用 `--config` 指定路径),包含:`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`。格式见 `jira_upload.env.example`,详细说明见 `references/env_config.md`。
- 若未配置,上传附件步骤跳过,仅通过 `jira_add_comment` 写入报告;已配置则先上传 MD 再写评论。 **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 - 获取提交内容 ### Step 1 - 获取提交内容
@@ -61,7 +98,8 @@ git log -1 --format="%B" <hash> # 获取原始 commit message
### Step 2 - 判定任务类型并确定需生成的文档 ### Step 2 - 判定任务类型并确定需生成的文档
根据改动内容与 `references/jira_commit_docs_policy.md` 中的**详细条件表**,先判定任务类型,再决定需要生成并上传的文档: **前置**:Step 1 已完成。
根据改动内容与 `references/jira_commit_docs_policy.md` 中的**详细条件表**,先判定任务类型,再决定需要生成并上传的文档(须在回复中写明类型与三文档要/不要):
| 任务类型 | 开发计划 | 影响分析 | 任务摘要 | | 任务类型 | 开发计划 | 影响分析 | 任务摘要 |
|----------|:--------:|:--------:|:--------:| |----------|:--------:|:--------:|:--------:|
@@ -88,6 +126,8 @@ git log -1 --format="%B" <hash> # 获取原始 commit message
### Step 3 - 确定 Jira 任务 ### Step 3 - 确定 Jira 任务
**前置**:Step 2 已完成(至少已判定文档需求;若需文档,可先草稿内容,**真实 Key 确定后再按 `<KEY>_*.md` 保存**)。
**1. 如果用户已提供 Jira 编号:** **1. 如果用户已提供 Jira 编号:**
- 直接使用该编号(如 `G3SF-123`),进入 Step 4。 - 直接使用该编号(如 `G3SF-123`),进入 Step 4。
@@ -96,11 +136,18 @@ git log -1 --format="%B" <hash> # 获取原始 commit message
``` ```
project = G3SF AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC 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(附件 + 评论) ### Step 4 - 写入 Jira(附件 + 评论)
**前置**:已持有本任务最终 **Issue Key**(Step 3)。
**附件命名规范(公司要求)**:上传到 Jira 的 MD 文件名必须为 **附件命名规范(公司要求)**:上传到 Jira 的 MD 文件名必须为
`<JIRA-ID>_Dev_Plan.md`、`<JIRA-ID>_Impact_Analysis.md`、`<JIRA-ID>_Task_Summary.md`。 `<JIRA-ID>_Dev_Plan.md`、`<JIRA-ID>_Impact_Analysis.md`、`<JIRA-ID>_Task_Summary.md`。
根据 Step 2 判定结果,仅上传“需要”的文档;每个文件先按该命名写入本地再上传(脚本以本地文件名为 Jira 附件名)。 根据 Step 2 判定结果,仅上传“需要”的文档;每个文件先按该命名写入本地再上传(脚本以本地文件名为 Jira 附件名)。
@@ -115,22 +162,37 @@ project = G3SF AND assignee = currentUser() AND statusCategory != Done ORDER BY
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 "G3SF-123_Impact_Analysis.md" --file "G3SF-123_Dev_Plan.md" --file "G3SF-123_Task_Summary.md"
``` ```
若本任务仅需影响分析,则只写入并上传 `G3SF-123_Impact_Analysis.md`。未配置 `jira_upload.env` 时跳过上传,仅写评论。 若本任务仅需影响分析,则只写入并上传 `G3SF-123_Impact_Analysis.md`。未配置 `jira_upload.env` 时跳过上传;有 MCP 时可仅写评论,无 MCP 则必须配置 env 才能完成评论。
2. **写评论**:若有影响分析内容,调用 `jira_add_comment` 将影响分析报告正文作为评论写入 issue。 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。 3. 记录该任务的 issue key。
**如果用户选择 0 或搜索无结果:** **如果用户选择 0 或搜索无结果:**
- 调用 `jira_create_issue` 创建新任务,详情见 `references/workflow.md` - **MCP**:调用 `jira_create_issue`(见 `references/workflow.md`)。
- 设置 project key: `G3SF`, issue type: `Task` - **无 MCP**:使用 `jira_cli.py create`(经办人默认为 Token 对应用户):
- **Summary**:必须在标题前添加 `[G3SF]` 前缀,格式为 `[G3SF] 提炼后的改动摘要` ```powershell
- **Description**:完整影响分析报告(Markdown 格式);若本任务需开发计划/任务摘要,可在创建后通过附件上传 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"
- **Assignee**:必须设置为当前登录账号(由 MCP 传入当前用户或等效参数) ```
- 获取返回的新 issue key(如 `G3SF-123`) 脚本会打印新 issue key(如 `G3SF-123`);可加 `--format json` 解析 `key` 字段。
- 若已配置 `jira_upload.env` 且本任务需要文档,创建成功后按公司命名写入并上传对应 MD 文件到该新 Issue
**新建 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 5 - 更新 Git 提交信息
**前置**:Step 4 已按政策完成(无需文档的绑定类任务可跳过上传/评论,但须已有 Key)。
使用获取到的 Jira 编号,通过 `git commit --amend` 更新 commit message。**严禁 push**。 使用获取到的 Jira 编号,通过 `git commit --amend` 更新 commit message。**严禁 push**。
**注意**:在 Windows PowerShell 环境下,需确保 UTF8 编码以防止中文乱码。 **注意**:在 Windows PowerShell 环境下,需确保 UTF8 编码以防止中文乱码。
@@ -159,6 +221,7 @@ git commit --amend -m "$origMsg`n`n**** Jira $jiraKey" --cleanup=verbatim
## 相关参考 ## 相关参考
- **AI 逐步检查清单(防走偏)**:`references/agent_execution_checklist.md`
- **公司 Jira 附件文档政策**(何时附加哪些文件):`references/jira_commit_docs_policy.md` - **公司 Jira 附件文档政策**(何时附加哪些文件):`references/jira_commit_docs_policy.md`
- **报告模板、三文档说明与错误处理**:`references/workflow.md` - **报告模板、三文档说明与错误处理**:`references/workflow.md`
- **上传附件配置**:`references/env_config.md`;脚本 `scripts/upload_attachment.py`,依赖见 `scripts/requirements.txt` - **上传附件配置**:`references/env_config.md`;脚本 `scripts/upload_attachment.py`,依赖见 `scripts/requirements.txt`
+32
View File
@@ -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,73 @@
# 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>`(否则停止,提示用户)
---
## 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` 成功返回
- [ ] 若走列表:已 **等用户选 1~N 或 0**,未擅自替用户绑定
**未取得真实 Key 前:禁止** 使用 `<JIRA-ID>_*.md` 落盘上传(禁止占位符 Key)。
---
## Step 4 — 写入 Jira
- [ ] 需要附件时:文件名为 `<KEY>_Dev_Plan.md` / `_Impact_Analysis.md` / `_Task_Summary.md`(仅实际上传需要的)
- [ ] 附件 **仅** 通过 `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 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 |
---
**原则**:顺序 = 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 速查」。
---
## 环境变量覆盖 ## 环境变量覆盖
若同时存在配置文件和环境变量,**环境变量优先**。 若同时存在配置文件和环境变量,**环境变量优先**。
+33 -4
View File
@@ -1,6 +1,7 @@
# G3FO Commit Jira — 详细工作流参考 # G3FO Commit Jira — 详细工作流参考
## 目录 ## 目录
0. [AI 执行顺序提醒](#ai-执行顺序提醒)
1. [公司 Jira 附件文档政策](#公司-jira-附件文档政策) 1. [公司 Jira 附件文档政策](#公司-jira-附件文档政策)
2. [Step 1: 获取并解析提交内容](#step-1-获取并解析提交内容) 2. [Step 1: 获取并解析提交内容](#step-1-获取并解析提交内容)
3. [Step 2: 任务类型判定与三文档模板](#step-2-任务类型判定与三文档模板) 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 附件文档政策 ## 公司 Jira 附件文档政策
自 **2026年3月9日** 起,何时附加「开发计划」「影响分析」「任务摘要」以公司强制性指南为准。 自 **2026年3月9日** 起,何时附加「开发计划」「影响分析」「任务摘要」以公司强制性指南为准。
@@ -297,6 +305,26 @@ 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`。
---
## 错误处理表 ## 错误处理表
| 错误场景 | 检测方式 | 处理方式 | | 错误场景 | 检测方式 | 处理方式 |
@@ -304,13 +332,14 @@ git log --oneline -1 # 查看 commit 摘要
| 修订号不存在 | `git rev-parse <hash>` 报错 | **停止**,提示用户提供正确的修订号。 | | 修订号不存在 | `git rev-parse <hash>` 报错 | **停止**,提示用户提供正确的修订号。 |
| 提交 hash 不是 HEAD | `git rev-parse HEAD` ≠ `git rev-parse <hash>` | 停止,提示用户确认 hash(只有 HEAD 才能 amend)。 | | 提交 hash 不是 HEAD | `git rev-parse HEAD` ≠ `git rev-parse <hash>` | 停止,提示用户确认 hash(只有 HEAD 才能 amend)。 |
| 项目路径不正确 | `git rev-parse --is-inside-work-tree` 失败 | 提示用户在正确的 git 项目目录下操作。 | | 项目路径不正确 | `git rev-parse --is-inside-work-tree` 失败 | 提示用户在正确的 git 项目目录下操作。 |
| Atlassian MCP 未鉴权 | `STATUS.md` 有提示 / 工具调用返回 401 | 调用 `mcp_auth`,等待用户完成授权。 | | 无 MCP 且未配置 jira_upload.env | 无法执行 jira_cli / 上传 | **必须**配置 `jira_upload.env` 或改用 Cursor + MCP。 |
| Atlassian 插件未安装 | `tools/` 目录为空或 MCP 调用失败 | 引导用户在 Cursor Settings → MCP 安装 Atlassian 插件 | | Atlassian MCP 未鉴权 | `STATUS.md` 有提示 / 工具调用返回 401 | 调用 `mcp_auth`,或改用 `jira_cli.py`。 |
| Jira 项目 G3SF 不存在 | `jira_create_issue` 返回 404/400 | 提示用户确认 Jira 实例 URL 和项目 key | | 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 | | 工作区有未提交变更 | `git status --porcelain` 有输出 | 警告:amend 会将未暂存变更排除在外,建议先 `git add` 或 stash |
| commit message 中已有 Jira 行 | message 中包含 `**** Jira` | 跳过追加,提示用户该提交已绑定 | | commit message 中已有 Jira 行 | message 中包含 `**** Jira` | 跳过追加,提示用户该提交已绑定 |
| Jira 搜索无结果 | 工具返回空列表 | 直接进入新建流程,无需用户确认 | | Jira 搜索无结果 | 工具返回空列表 | 直接进入新建流程,无需用户确认 |
| 未配置 jira_upload.env | 上传前检查配置不存在 | 跳过上传附件,仅通过 jira_add_comment 写入报告;提示用户可配置后使用脚本上传 | | 未配置 jira_upload.env | 上传前检查配置不存在 | **有 MCP**:跳过上传,仅评论。**无 MCP**:无法完成 Jira 操作,须配置 env 或使用 MCP。 |
| 任务类型判定为「无需文档」 | 按政策表属简单漏洞/琐碎改动 | 不生成报告、不写评论、不上传附件;仍执行 Jira 绑定与 commit --amend,将 Jira 编号追加到 commit message | | 任务类型判定为「无需文档」 | 按政策表属简单漏洞/琐碎改动 | 不生成报告、不写评论、不上传附件;仍执行 Jira 绑定与 commit --amend,将 Jira 编号追加到 commit message |
--- ---
+558
View File
@@ -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())
@@ -0,0 +1,75 @@
#!/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 "").rstrip("/")
email = cfg.get("JIRA_EMAIL")
token = cfg.get("JIRA_API_TOKEN")
if not base_url or not email or not token:
raise ValueError(
"Missing JIRA_BASE_URL, JIRA_EMAIL, or JIRA_API_TOKEN. "
f"Set in {CONFIG_FILENAME} or environment (see references/env_config.md)."
)
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. 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: Usage:
python upload_attachment.py --issue ISSUE_KEY --file path1 [--file path2 ...] 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 python upload_attachment.py --config /path/to/jira_upload.env --issue KEY --file path1
""" """
import argparse import argparse
import base64
import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Dict, Optional
try: try:
import requests import requests
@@ -21,57 +18,34 @@ except ImportError:
print("ERROR: 'requests' is required. Run: pip install requests", file=sys.stderr) print("ERROR: 'requests' is required. Run: pip install requests", file=sys.stderr)
sys.exit(1) sys.exit(1)
CONFIG_FILENAME = "jira_upload.env" from jira_env import CONFIG_FILENAME, auth_header, load_config, require_credentials
SCRIPT_DIR = Path(__file__).resolve().parent
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
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Upload files as attachments to a Jira issue") 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("--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(
parser.add_argument("--config", default=None, help=f"Path to config file (default: {CONFIG_FILENAME} in cwd or script dir)") "--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() args = parser.parse_args()
cfg = load_config(args.config) try:
base_url = (cfg.get("JIRA_BASE_URL") or "").rstrip("/") cfg = load_config(args.config, SCRIPT_DIR)
email = cfg.get("JIRA_EMAIL") base_url, email, token = require_credentials(cfg)
token = cfg.get("JIRA_API_TOKEN") except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
if not base_url or not email or not token: print(f" Use --config PATH or create {CONFIG_FILENAME}", file=sys.stderr)
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)
return 1 return 1
issue_key = args.issue.strip() issue_key = args.issue.strip()
@@ -87,9 +61,8 @@ def main() -> int:
files_to_upload.append(path) files_to_upload.append(path)
url = f"{base_url}/rest/api/3/issue/{issue_key}/attachments" url = f"{base_url}/rest/api/3/issue/{issue_key}/attachments"
auth_str = base64.b64encode(f"{email}:{token}".encode()).decode()
headers = { headers = {
"Authorization": f"Basic {auth_str}", "Authorization": f"Basic {auth_header(email, token)}",
"X-Atlassian-Token": "no-check", "X-Atlassian-Token": "no-check",
} }