feat(g3fo-commit-jira): 新增无 MCP 的 Jira CLI 并增强 AI 执行流程约束
- 新增 `jira_cli.py` 脚本,支持在不依赖 Atlassian MCP 的环境下通过同一 Jira Token 进行搜索、创建 Issue 和评论 - 新增共享配置模块 `jira_env.py`,统一加载 `jira_upload.env` 并支持在技能根目录查找配置文件 - 更新 `SKILL.md` 和参考文档,增加「AI 执行契约」和详细的前置条件说明,强制 Step 1→5 线性执行顺序 - 新增 `agent_execution_checklist.md` 检查清单,防止 AI 跳步、误用工具或未等用户确认就绑定 Issue - 改进 `jira_cli.py` 的评论和描述生成,将常见 Markdown 语法转换为 Jira ADF 格式以获得更好的渲染效果
This commit is contained in:
@@ -0,0 +1,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,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)**
|
||||
@@ -27,25 +60,29 @@ description: G3FO 项目 Git 提交规范自动化工具。强制要求提供 gi
|
||||
- 如果提供了 Jira 编号,直接引用该编号,跳过“查找/选择 Jira 任务”的步骤。
|
||||
- 如果未提供,则按流程自动查找或提示用户新建。
|
||||
|
||||
**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 - 获取提交内容
|
||||
|
||||
@@ -61,7 +98,8 @@ git log -1 --format="%B" <hash> # 获取原始 commit message
|
||||
|
||||
### 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 2 已完成(至少已判定文档需求;若需文档,可先草稿内容,**真实 Key 确定后再按 `<KEY>_*.md` 保存**)。
|
||||
|
||||
**1. 如果用户已提供 Jira 编号:**
|
||||
- 直接使用该编号(如 `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
|
||||
```
|
||||
- 展示列表供用户选择,或允许用户输入 `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 附件名)。
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
若本任务仅需影响分析,则只写入并上传 `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)。
|
||||
使用获取到的 Jira 编号,通过 `git commit --amend` 更新 commit message。**严禁 push**。
|
||||
|
||||
**注意**:在 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`
|
||||
- **报告模板、三文档说明与错误处理**:`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,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 速查」。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量覆盖
|
||||
|
||||
若同时存在配置文件和环境变量,**环境变量优先**。
|
||||
|
||||
@@ -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日** 起,何时附加「开发计划」「影响分析」「任务摘要」以公司强制性指南为准。
|
||||
@@ -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>` 报错 | **停止**,提示用户提供正确的修订号。 |
|
||||
| 提交 hash 不是 HEAD | `git rev-parse HEAD` ≠ `git rev-parse <hash>` | 停止,提示用户确认 hash(只有 HEAD 才能 amend)。 |
|
||||
| 项目路径不正确 | `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_upload.env | 上传前检查配置不存在 | 跳过上传附件,仅通过 jira_add_comment 写入报告;提示用户可配置后使用脚本上传 |
|
||||
| 未配置 jira_upload.env | 上传前检查配置不存在 | **有 MCP**:跳过上传,仅评论。**无 MCP**:无法完成 Jira 操作,须配置 env 或使用 MCP。 |
|
||||
| 任务类型判定为「无需文档」 | 按政策表属简单漏洞/琐碎改动 | 不生成报告、不写评论、不上传附件;仍执行 Jira 绑定与 commit --amend,将 Jira 编号追加到 commit message |
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user