From 57fca4e46849e6bf13ad2fb4f30f2af5d9b4ccc0 Mon Sep 17 00:00:00 2001 From: "ken.li" Date: Mon, 23 Mar 2026 13:38:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(g3fo-commit-jira):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=97=A0=20MCP=20=E7=9A=84=20Jira=20CLI=20=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=20AI=20=E6=89=A7=E8=A1=8C=E6=B5=81=E7=A8=8B=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `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 格式以获得更好的渲染效果 --- skills/g3fo-commit-jira/IMPACT_ANALYSIS.md | 108 ++++ skills/g3fo-commit-jira/SKILL.md | 113 +++- skills/g3fo-commit-jira/TASK_SUMMARY.md | 32 + .../references/agent_execution_checklist.md | 73 +++ .../g3fo-commit-jira/references/env_config.md | 29 + .../g3fo-commit-jira/references/workflow.md | 37 +- skills/g3fo-commit-jira/scripts/jira_cli.py | 558 ++++++++++++++++++ skills/g3fo-commit-jira/scripts/jira_env.py | 75 +++ .../scripts/upload_attachment.py | 71 +-- 9 files changed, 1018 insertions(+), 78 deletions(-) create mode 100644 skills/g3fo-commit-jira/IMPACT_ANALYSIS.md create mode 100644 skills/g3fo-commit-jira/TASK_SUMMARY.md create mode 100644 skills/g3fo-commit-jira/references/agent_execution_checklist.md create mode 100644 skills/g3fo-commit-jira/scripts/jira_cli.py create mode 100644 skills/g3fo-commit-jira/scripts/jira_env.py diff --git a/skills/g3fo-commit-jira/IMPACT_ANALYSIS.md b/skills/g3fo-commit-jira/IMPACT_ANALYSIS.md new file mode 100644 index 0000000..0faf1cf --- /dev/null +++ b/skills/g3fo-commit-jira/IMPACT_ANALYSIS.md @@ -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. 验证与测试 + +- 文档结构与人读一致性自检;无自动化测试。 diff --git a/skills/g3fo-commit-jira/SKILL.md b/skills/g3fo-commit-jira/SKILL.md index 36d6fb2..d8ca8b3 100644 --- a/skills/g3fo-commit-jira/SKILL.md +++ b/skills/g3fo-commit-jira/SKILL.md @@ -1,6 +1,6 @@ --- name: g3fo-commit-jira -description: G3FO 项目 Git 提交规范自动化工具。强制要求提供 git 修订号(hash),支持用户直接提供 Jira 编号或自动查找/创建 Jira 任务。按任务类型(漏洞修复/新功能/重构等)判定需附加的文档(开发计划、影响分析、任务摘要),生成对应 MD 并按公司规范命名(_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 ` 成功;若需 amend,已确认该 hash **就是当前 HEAD** | 调用 Jira、生成报告正文、落盘 `_*.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 `;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" # 获取原始 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" # 获取原始 commit message ### Step 3 - 确定 Jira 任务 +**前置**:Step 2 已完成(至少已判定文档需求;若需文档,可先草稿内容,**真实 Key 确定后再按 `_*.md` 保存**)。 + **1. 如果用户已提供 Jira 编号:** - 直接使用该编号(如 `G3SF-123`),进入 Step 4。 @@ -96,11 +136,18 @@ git log -1 --format="%B" # 获取原始 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 文件名必须为 `_Dev_Plan.md`、`_Impact_Analysis.md`、`_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` diff --git a/skills/g3fo-commit-jira/TASK_SUMMARY.md b/skills/g3fo-commit-jira/TASK_SUMMARY.md new file mode 100644 index 0000000..8e14da4 --- /dev/null +++ b/skills/g3fo-commit-jira/TASK_SUMMARY.md @@ -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` 增加执行顺序提醒。 +- **测试**:文档审阅;逻辑无代码变更。 diff --git a/skills/g3fo-commit-jira/references/agent_execution_checklist.md b/skills/g3fo-commit-jira/references/agent_execution_checklist.md new file mode 100644 index 0000000..c86f2ac --- /dev/null +++ b/skills/g3fo-commit-jira/references/agent_execution_checklist.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 `,修订号存在 +- [ ] 若要对提交做 amend:已确认 `git rev-parse HEAD` **等于** `git rev-parse `(否则停止,提示用户) + +--- + +## Step 1 — 获取提交内容 + +- [ ] 已运行 `git show `(或等效)并理解改动范围 +- [ ] 已记录原始 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 前:禁止** 使用 `_*.md` 落盘上传(禁止占位符 Key)。 + +--- + +## Step 4 — 写入 Jira + +- [ ] 需要附件时:文件名为 `_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` 错误处理表。 diff --git a/skills/g3fo-commit-jira/references/env_config.md b/skills/g3fo-commit-jira/references/env_config.md index a4c7e54..fc7d352 100644 --- a/skills/g3fo-commit-jira/references/env_config.md +++ b/skills/g3fo-commit-jira/references/env_config.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 ` | 查看单条 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 速查」。 + +--- + ## 环境变量覆盖 若同时存在配置文件和环境变量,**环境变量优先**。 diff --git a/skills/g3fo-commit-jira/references/workflow.md b/skills/g3fo-commit-jira/references/workflow.md index 6412ac9..24cfa07 100644 --- a/skills/g3fo-commit-jira/references/workflow.md +++ b/skills/g3fo-commit-jira/references/workflow.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日** 起,何时附加「开发计划」「影响分析」「任务摘要」以公司强制性指南为准。 @@ -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 不是 HEAD | `git rev-parse HEAD` ≠ `git rev-parse ` | 停止,提示用户确认 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 | --- diff --git a/skills/g3fo-commit-jira/scripts/jira_cli.py b/skills/g3fo-commit-jira/scripts/jira_cli.py new file mode 100644 index 0000000..7b31e4e --- /dev/null +++ b/skills/g3fo-commit-jira/scripts/jira_cli.py @@ -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"|(? 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()) diff --git a/skills/g3fo-commit-jira/scripts/jira_env.py b/skills/g3fo-commit-jira/scripts/jira_env.py new file mode 100644 index 0000000..c9d1f41 --- /dev/null +++ b/skills/g3fo-commit-jira/scripts/jira_env.py @@ -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 diff --git a/skills/g3fo-commit-jira/scripts/upload_attachment.py b/skills/g3fo-commit-jira/scripts/upload_attachment.py index 3af2984..c780702 100644 --- a/skills/g3fo-commit-jira/scripts/upload_attachment.py +++ b/skills/g3fo-commit-jira/scripts/upload_attachment.py @@ -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", }