feat: 统一阶段通知模板并支持逐项提醒负责人
This commit is contained in:
1 parent
1d85de4c7c
commit
3fb9c0658d
10 files changed
+301
-58
No files matched your search
+29
-4
@@ -42,12 +42,37 @@ DWS 安装与登录以 [官方项目](https://github.com/DingTalk-Real-AI/dingta
|
||||
## 日常调用
|
||||
|
||||
```text
|
||||
python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已归档。下一步安排联调。"
|
||||
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
|
||||
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名"
|
||||
python scripts/dingtalk_notify.py progress milestone --stage "阶段八:复盘结项" "**已确认的进展**
|
||||
|
||||
- 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
|
||||
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
|
||||
- 已通知相关负责人进行阶段交接,不新增审批。
|
||||
|
||||
**待办事项**
|
||||
|
||||
- 结项资料整理:由 Hub 整理四类结项工作稿。
|
||||
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
|
||||
- 旧事项停止:由 Hub 停止此前的验收准备催办。
|
||||
- 结项确认:四类成果具备后,由 Hub 请 @{示例负责人} 确认结项,确认后再锁定成果。
|
||||
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
|
||||
|
||||
**遗留**
|
||||
|
||||
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
|
||||
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。" --at "示例负责人"
|
||||
python scripts/dingtalk_notify.py progress blocked --stage "阶段三:方案设计" "**待办事项**
|
||||
|
||||
- 方案设计:请 @{示例甲} 补充接口说明,并回自己的角色会话同步;接口说明缺失影响方案确认。" --at "示例甲"
|
||||
python scripts/dingtalk_notify.py message --title "阶段三:方案设计|资料补充提醒" --text "**待办事项**
|
||||
|
||||
- 方案设计:请 @{示例甲} 补齐已约定的接口资料,并回自己的角色会话确认。" --at "示例甲"
|
||||
```
|
||||
|
||||
通知保留 AI 标识并标注自动汇报;摘要用简短中文 Markdown。`--at` 按完整姓名查询唯一人员,再生成个人消息的原生提及标记。两个发送命令都支持 `--file` 正文和完全离线的 `--dry-run`。不支持临时换群、手填人员编号或退回机器人发送。
|
||||
以上结项场景、事项和姓名仅作格式演示;验收结论、成果数量、延期安排和确认人不作为其他项目的默认事实或要求。真实发送须使用当前项目的有效记录和已绑定负责人姓名。
|
||||
|
||||
`progress` 现在必须传入 `--stage`,每次使用当前项目记录里的阶段编号和名称;未登记编号时仅写已知名称。标题显示“项目名称|阶段编号:阶段名称”,不再显示固定的“阶段里程碑”。旧调用需补齐参数,并同步部署脚本和相关 Hub Skill/Hook;本地改动不等于在途项目已更新。
|
||||
|
||||
进度通知使用加粗标题和“已确认的进展”“待办事项”“遗留”三个栏目:待办按“事项名称:由谁做什么”逐条写清,当前阶段尚未解决的问题列入遗留。保留已定前提、停止事项和工作范围,阶段启动不写成项目关闭。确实没有待办或遗留时写“无”,未核实不能当作没有;沿用已定责任和期限,不能为了通知新增工作或重填清单。具体写法见[通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md#消息写法)。通知保留 AI 标识并标注自动汇报;摘要用简短中文 Markdown。在对应事项中写 `@{完整姓名}`,并通过 `--at` 传入相同姓名,脚本唯一匹配后原位生成原生 @;多人分别对应各自事项,同一接收人去重。仅传 `--at` 的旧写法仍在末尾追加提醒。离线预览只显示“@姓名”,不证明实际提醒成功。两个发送命令都支持 `--file` 正文和完全离线的 `--dry-run`。不支持临时换群、手填人员编号或退回机器人发送。
|
||||
|
||||
事件含义见 [通知规则](../etunel-role-collaboration/references/dingtalk-progress-reporting.md),详细调用与结果见 [脚本用法](../etunel-role-collaboration/references/dingtalk-script-usage.md)。
|
||||
|
||||
|
||||
@@ -64,13 +64,17 @@
|
||||
- Agent 只用 `scripts/dingtalk_notify.py` 的 `progress`/`message`,或等价的 `dingtalk-progress`/`dingtalk-message` Shell、PowerShell 入口,统一以已确认的个人 DWS 账号发送。进度事件仍限定为 `start`、`milestone`、`blocked`、`complete`、`failed`;负责人提醒不新增状态。同一事项不重复通知,不得绕过封装直接调用 OpenAPI 或 `dws api`。可验证里程碑先完成项目留档,再发送简短中文 Markdown。例如:
|
||||
|
||||
```sh
|
||||
python scripts/dingtalk_notify.py progress milestone "**已完成**
|
||||
python scripts/dingtalk_notify.py progress milestone --stage "阶段六:测试验证" "**已确认的进展**
|
||||
|
||||
- 阶段成果已确认并归档
|
||||
- 本轮测试报告已归档。
|
||||
|
||||
**下一步**
|
||||
**待办事项**
|
||||
|
||||
- Hub 安排后续任务"
|
||||
- 结果确认:由 Hub 请 @{示例负责人} 确认本轮测试结果。
|
||||
|
||||
**遗留**
|
||||
|
||||
- 无。" --at "示例负责人"
|
||||
```
|
||||
- 事件语义与消息格式以 [dingtalk-progress-reporting.md](etunel-role-collaboration/references/dingtalk-progress-reporting.md) 为准;安装与本机配置参考 [.dingtalk/README.md](.dingtalk/README.md)。
|
||||
- 通知封装是辅助接入参考,不得据此反推或改变 Etunel 主流程;不创建通知队列或外层重试。仅明确 DNS 失败最多 3 次、间隔 10 秒,结果未知不盲目重发。
|
||||
|
||||
@@ -59,7 +59,7 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol
|
||||
- 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。
|
||||
- 编写给负责人、角色或群组的任务、提问、结果和状态消息:读 [人类可读沟通](references/human-readable-communication.md)。
|
||||
- 创建项目资料目录、保存成员清单、归档成果、更新进度或阶段记录:读 [项目文件与进度留档](references/project-files-and-progress.md)。
|
||||
- 仅当当前会话是 Hub 且需初始化个人 DWS 通知、钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),实际调用再读其中的脚本用法。
|
||||
- 仅当当前会话是 Hub 且需初始化个人 DWS 通知、钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),标题使用当前阶段,按“已确认的进展、待办事项、遗留”三个栏目逐条表述,并 @ 已明确的真人处理人;实际调用再读其中的脚本用法。
|
||||
- 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。
|
||||
|
||||
不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。
|
||||
|
||||
@@ -56,19 +56,56 @@ Hub 必须先确认进展真实有效,并完成成果文件、成果索引和
|
||||
|
||||
## 消息写法
|
||||
|
||||
使用中文 Markdown 自然排版,可以使用标题、短段落和列表,不要求固定模板,也不要把全部内容挤成一行。像项目负责人向团队说明进展,不像日志转储。
|
||||
标题使用“项目名称|阶段编号:阶段名称”,例如“示例项目|阶段六:测试验证”。Hub 每次从有效项目记录取得当前阶段编号和名称,通过 `progress --stage` 整体传入;编号未登记时只写已知阶段名称,不自行编号或因通知示例改变阶段顺序。阶段变化后使用新名称,不把“阶段里程碑”等事件标签当成阶段,也不根据正文猜测阶段。提醒入口的自定义标题同样先写当前阶段,再写提醒事项。跨阶段事项在对应条目中注明所属阶段,通知标题仍写项目当前阶段。
|
||||
|
||||
进度通知采用已确认的格式:加粗项目与阶段标题、自动汇报时间、“已确认的进展”“待办事项”“遗留”三个加粗栏目,栏目间留空行。每件事单独一条,用日常说法写清谁做什么。确实没有待办或遗留时写“无”;资料不足时说明尚未核实,不把未知写成没有。本轮没有新增已确认进展时如实说明。
|
||||
|
||||
- 已确认的进展:写清完成了什么、谁已作出什么决定以及适用范围。区分“已进入阶段/开始整理”和“已完成/已关闭”;已通知交接不等于对方已经完成交接。已决定的事项直接说明结果,不再请人重复批准。
|
||||
- 待办事项:合并列出本轮需要决定、确认或处理的全部事项,用“事项名称:由谁做什么”逐条表述,例如资料整理、资料补充、旧事项停止、结果确认和本轮工作范围;按实际情况命名,不要求凑齐示例条目。标题已说明当前阶段,不必每条重复阶段名,涉及其他阶段时注明。需人工决定时写“条件具备后,由 Hub 请某负责人确认具体事项,确认后再执行后续动作”,条件只取已有约定;有可选方案时简短列明。需执行时写清已明确的处理人和动作,有已约定期限时沿用,必要时说明结果回哪个角色会话。已明确停止的催办、需要复用的成果和不新增的工作须保留,不改写成新任务。
|
||||
- 遗留:逐条列出当前阶段尚未解决、需要继续跟进的事项,说明现状、已确认的后续安排及对应处理人;有已定期限、后续阶段或对阶段推进的影响时一并说明。列为遗留不等于获准延期、豁免、关闭问题或通过阶段;同一问题在待办里写本轮动作,在遗留里交代未解决状态,避免重复整段内容。
|
||||
- 待办和需跟进的遗留,在已明确真人处理人或决策人时,于对应条目中 @ 该人;Hub 代为请求确认时 @ 被请求确认的真人负责人,不把“Hub”或角色名称当成可查询姓名。纯 Agent 工作可直接写 Hub 或实际角色 Agent;真人姓名缺失时沿用已知角色,不猜测或编造 @ 对象,确需提醒再按脚本用法取得已绑定姓名。多人分工写清各自动作,同一人可在对应条目中重复出现,发送接收人去重。已完成且无需行动的进展不额外 @ 人。未确定的责任、期限或事实不编造,也不为填写通知重新要求分配或填表。
|
||||
- 用具体动作替代“收口”“闭环”“拉齐”等笼统表述。例如“处理方式已收口”可写成“负责人已确定处理方式”,但不得把处理方式确定写成问题已解决。原决定中的“暂不修复”“无需新增清单”等范围和限制必须保留。
|
||||
|
||||
以下用结项整理场景演示已确认的通知格式,项目名与人员称呼均已泛化。示例中的验收结论、成果数量、确认人及延期安排只属于该场景,不成为其他项目的默认事实、职责或放行条件;各项目仍依据自己的有效记录和流程基线生成内容。
|
||||
|
||||
```markdown
|
||||
**示例项目|阶段八:复盘结项**
|
||||
|
||||
项目进度自动汇报 · 发送时自动生成带时区的时间
|
||||
|
||||
**已确认的进展**
|
||||
|
||||
- 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
|
||||
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
|
||||
- 已通知相关负责人进行阶段交接,不新增审批。
|
||||
|
||||
**待办事项**
|
||||
|
||||
- 结项资料整理:由 Hub 整理四类结项工作稿。
|
||||
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
|
||||
- 旧事项停止:由 Hub 停止此前的验收准备催办。
|
||||
- 结项确认:四类成果具备后,由 Hub 请 @示例负责人 确认结项,确认后再锁定成果。
|
||||
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
|
||||
|
||||
**遗留**
|
||||
|
||||
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
|
||||
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。
|
||||
```
|
||||
|
||||
示例标题、时间和栏目只说明最终呈现方式;调用时正文文件仅含正文,项目名、阶段标题与时间由脚本生成,不重复粘贴整条通知。
|
||||
|
||||
保留个人消息的 AI 标识和自动汇报说明,让群成员知道这是 Hub 自动通知,不是负责人本人新增批准、验收或承诺。
|
||||
|
||||
根据事件选择必要内容:项目名称、当前阶段、已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。通常保持一屏内可读。
|
||||
根据事件选择必要内容:已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。尽量简短,但不能为了控制篇幅省掉本轮需要决定或处理的事项。专业细节留在成果或问题系统中,通知不新建另一份问题台账。
|
||||
|
||||
不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。
|
||||
|
||||
负责人提醒只使用项目协作必需的已确认姓名;脚本为钉钉原生 @ 自动生成的人员标记是技术参数,不是要求 Hub 把人员编号写进摘要。需要负责人行动时,说明缺什么、影响什么、请谁做什么,以及回哪个角色会话补充结果。不要在群里建立另一套任务或审批流程。
|
||||
负责人提醒只使用项目协作必需的已确认姓名。Hub 在原始正文中用 `@{完整姓名}` 指定条目内的位置,并用 `--at` 列出相同姓名;脚本查询唯一匹配后原位转换为钉钉原生 @。纯文字“@姓名”只用于人读预览,不能代替实际提及参数。脚本自动生成的人员标记是技术参数,不是要求 Hub 把人员编号写进摘要。需要负责人行动时,说明缺什么、影响什么、请谁做什么,以及回哪个角色会话补充结果。不要在群里建立另一套任务或审批流程。
|
||||
|
||||
## 调用与结果
|
||||
|
||||
1. Hub 判断事件并形成中文 Markdown 摘要。
|
||||
1. Hub 判断事件、取得当前阶段名称,按上面的栏目形成逐条中文摘要。用户要求先看内容时,先展示完整通知预览,得到本次发送确认后再调用真实发送;离线预览不代表已发送。
|
||||
2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。
|
||||
3. 只有真实个人发送响应成功且含非空 `openTaskId`,才表示请求已经接受;不证明已投递、已读或接收端 @ 提醒成功。必要时按脚本用法查询投递状态,不把任务回执当消息编号。
|
||||
4. 缺少或未启用配置时不真实发送;显式 `--dry-run` 仅在本地生成正文,不查通讯录、不联网,也不证明目标配置和 @ 有效。配置仍为旧格式时停止并请求重新初始化。
|
||||
|
||||
@@ -28,21 +28,47 @@ Windows 优先用 `.ps1`,须同时部署共用启动器 `dingtalk-python.ps1`
|
||||
## 日常发送
|
||||
|
||||
```text
|
||||
python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已确认并归档。下一步安排联调。"
|
||||
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
|
||||
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名"
|
||||
python scripts/dingtalk_notify.py progress milestone --stage "阶段八:复盘结项" "**已确认的进展**
|
||||
|
||||
- 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
|
||||
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
|
||||
- 已通知相关负责人进行阶段交接,不新增审批。
|
||||
|
||||
**待办事项**
|
||||
|
||||
- 结项资料整理:由 Hub 整理四类结项工作稿。
|
||||
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
|
||||
- 旧事项停止:由 Hub 停止此前的验收准备催办。
|
||||
- 结项确认:四类成果具备后,由 Hub 请 @{示例负责人} 确认结项,确认后再锁定成果。
|
||||
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
|
||||
|
||||
**遗留**
|
||||
|
||||
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
|
||||
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。" --at "示例负责人"
|
||||
python scripts/dingtalk_notify.py progress blocked --stage "阶段三:方案设计" "**待办事项**
|
||||
|
||||
- 方案设计:请 @{示例甲} 补充接口说明,并回自己的角色会话同步;接口说明缺失影响方案确认。" --at "示例甲"
|
||||
python scripts/dingtalk_notify.py message --title "阶段三:方案设计|资料补充提醒" --text "**待办事项**
|
||||
|
||||
- 方案设计:请 @{示例甲} 补齐已约定的接口资料,并回自己的角色会话确认。" --at "示例甲"
|
||||
```
|
||||
|
||||
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。脚本增加项目名、中文标题、带时区时间和自动汇报说明,不替 Hub 判断阶段。保留 DWS 的 AI 标识,不能冒充负责人本人批准或承诺。
|
||||
以上结项场景、事项和姓名仅作格式演示;验收结论、成果数量、延期安排和确认人不作为其他项目的默认事实或要求。真实发送须使用当前项目的有效记录和已绑定负责人姓名。
|
||||
|
||||
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。`progress` 必须传入 `--stage`,值为 Hub 从当前项目记录取得的阶段编号及中文名称;未登记编号时仅使用已知名称。脚本生成“项目名称|阶段编号:阶段名称”、带时区时间和自动汇报说明,不推断阶段、不把阶段写进固定配置。阶段缺失、空白、包含控制字符或超过 200 字时停止,不发送泛称“阶段里程碑”的通知。保留 DWS 的 AI 标识,不能冒充负责人本人批准或承诺。
|
||||
|
||||
旧调用需补充 `--stage`,并同步更新目标 Hub 项目的脚本、Skill 和 Hook;仓库修改不等于目标项目已经部署。正文由 Hub 按[通知写法](dingtalk-progress-reporting.md#消息写法)组织“已确认的进展”“待办事项”“遗留”三个栏目,待办用“事项名称:由谁做什么”逐条编写。脚本生成加粗标题、时间与自动汇报说明,原样保留正文的条件、范围、顺序和完成状态,仅转换指定的 @ 标记,不自动拆句、推断责任人或追加任务。较长通知优先使用 `--file`,文件只保存三个栏目的正文。
|
||||
|
||||
- 多行中文 Markdown 作为一个参数传入;较长摘要可先保存成 UTF-8 文件,再用 `--file "实际正文文件路径"` 替代摘要或 `--text`。文件须实际存在,路径相对工作目录;这是正文,不是上传附件,不发送大段成果或日志。
|
||||
- `progress` 和 `message` 均支持 `--at`,多个姓名可逗号分隔或重复传入。负责人姓名来自 Etunel 已绑定的 `member_alias`,不把角色名称当姓名;人员查询和发送固定使用初始化账号,不跟随默认账号切换。
|
||||
- 在待办或遗留条目中使用 `@{完整姓名}`,并在 `--at` 中列出同一姓名;脚本在该位置生成原生 @,不把已原位提及的人再追加到末尾。只传 `--at` 而未在正文指定位置的人仍追加到末尾。多人可分别放在各自事项中,同一人重复出现时只查询一次、发送接收人去重。缺少配对姓名或标记不完整时停止,不把普通文字 @ 当成原生提醒。
|
||||
- 脚本只接受唯一姓名匹配,自动构造个人消息所需的 `openDingTalkId` 和提及标记。查不到、重名或权限不足时停止,不让负责人手填编号,不删掉 @ 降级发送。
|
||||
- 默认且仅发配置的项目群。同一事项合成一条,事件通知可直接 @ 人,不额外重复提醒。提醒讨论结果回各自角色会话,不在群里另派任务或批准。
|
||||
|
||||
## 检查与结果
|
||||
|
||||
排障可用 `python scripts/dingtalk_notify.py check --at "完整姓名"`,省略 `--at` 只查登录;它联网但不发送,不是每轮必做。两个发送入口加 `--dry-run` 可完全离线预览,不查人、不验证目标或 @ 效果。
|
||||
排障可用 `python scripts/dingtalk_notify.py check --at "完整姓名"`,省略 `--at` 只查登录;它联网但不发送,不是每轮必做。两个发送入口加 `--dry-run` 可完全离线预览,条目中的标记显示为“@姓名”;不查人、不验证目标或 @ 效果。
|
||||
|
||||
- `initialized`:已创建经确认的个人通知配置,未发送。
|
||||
- `accepted`:真实个人发送响应成功且含非空 `openTaskId`,请求已接受,不等于已投递、已读或接收端提醒成功。保留回执供排障,面向负责人只说中文结果。
|
||||
|
||||
@@ -162,7 +162,7 @@ Hub 不把 AI 自主生成当成人类确认。Hub 自己的负责人在以下
|
||||
|
||||
## 钉钉进度
|
||||
|
||||
只有 Hub 可以发送项目进度。Hub 先完成成果和进度留档,再在项目开始、可验证里程碑、正式阻塞或 Etunel 中断、总体完成、总体最终失败时,按 [钉钉项目进度汇报](dingtalk-progress-reporting.md) 调用进度封装。需要负责人处理具体问题或超过约定时间需提醒时,可按 [脚本用法](dingtalk-script-usage.md) 发送简短中文提醒;同一事项合并发送,不重复发进度和提醒。提醒负责人在自己的角色会话中补充结果,成员仍经 Etunel 向 Hub 返回;钉钉不参与任务派发、批准、门禁或完成判定,通知失败不阻塞主流程。
|
||||
只有 Hub 可以发送项目进度。通知标题使用项目当前阶段编号和名称,正文按已确认的进展、待办事项、遗留逐条说明,待办写清“事项名称:由谁做什么”,在事项中 @ 已明确的真人处理人,保留条件、停止事项和工作范围,阶段启动不写成项目关闭;格式按通知细则。Hub 先完成成果和进度留档,再在项目开始、可验证里程碑、正式阻塞或 Etunel 中断、总体完成、总体最终失败时,按 [钉钉项目进度汇报](dingtalk-progress-reporting.md) 调用进度封装。需要负责人处理具体问题或超过约定时间需提醒时,可按 [脚本用法](dingtalk-script-usage.md) 发送简短中文提醒;同一事项合并发送,不重复发进度和提醒。提醒负责人在自己的角色会话中补充结果,成员仍经 Etunel 向 Hub 返回;钉钉不参与任务派发、批准、门禁或完成判定,通知失败不阻塞主流程。
|
||||
|
||||
## Hub 禁止事项
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ Etunel 消息可以把简短的事项说明放入可选 `context`,完整要求
|
||||
|
||||
报告阻塞时说明:当前问题、已经尝试了什么、影响范围、需要谁采取什么动作、恢复条件。机器标识由 Etunel 工具关联,不要求负责人手工抄写。
|
||||
|
||||
涉及阶段决定或待处理事项时,一事一条:说明所属阶段、具体问题、需要谁决定什么或处理什么。列全本轮需要行动的事项;已确定的安排直接说明结果,保留原有范围、限制和完成状态,不用“收口”“闭环”等词代替具体动作,不把已决定写成已解决。
|
||||
|
||||
## 面向 Hub 与钉钉
|
||||
|
||||
成员交给 Hub 的消息也应简明,但可以附成果路径、版本和证据引用供校验。Hub 转发时忠实保留专业结论,不把内部机器字段一并转给下游。
|
||||
|
||||
钉钉消息可以使用 Markdown 标题、段落和列表。保持中文、简短、可公开,只写已验证进展、问题、影响和下一步;详细规则见 [钉钉项目进度汇报](dingtalk-progress-reporting.md)。
|
||||
钉钉标题显示项目当前阶段编号和名称,正文按“已确认的进展”“待办事项”“遗留”组织;确认、决定和处理动作按“事项名称:由谁做什么”列入待办,未解决问题及跟进安排列入遗留,并在各事项中 @ 已明确的真人处理人。保留条件、停止事项和工作范围,阶段启动不写成阶段完成或项目关闭;纯 Agent 工作直接写对应角色。保持中文、简短、可公开,只写已验证进展、问题、影响和下一步;详细规则见 [钉钉项目进度汇报](dingtalk-progress-reporting.md)。
|
||||
@@ -27,7 +27,7 @@
|
||||
"non_goals": [
|
||||
"不代专业角色开发、测试、验收或批准,不替成员完成任务,不自行批准豁免或创建Etunel角色。",
|
||||
"不重造Etunel的队列、传输、重试和去重机制;不以通知送达或消息结算替代成果完成。",
|
||||
"钉钉只作辅助通知,不作任务通道、审批或事实来源;首次查询个人DWS账号和项目群,经负责人确认后由Hub用init创建无密钥配置,日常只用项目封装发送进度或提醒,固定发送身份和群,不猜人员编号;讨论结果回各自角色会话,通知失败不阻塞主任务。"
|
||||
"钉钉只作辅助通知,不作任务通道、审批或事实来源;首次查询个人DWS账号和项目群,经负责人确认后由Hub用init创建无密钥配置,日常只用项目封装发送进度或提醒,标题用项目当前阶段编号和名称,正文分已确认的进展、待办事项、遗留,待办用“事项:动作”写清责任与条件并@已明确的真人处理人,纯Agent工作写对应角色;已确认安排不重复要求批准,阶段启动不写成项目关闭;固定发送身份和群,不猜人员编号;讨论结果回各自角色会话,通知失败不阻塞主任务。"
|
||||
],
|
||||
"completion_tools": [
|
||||
"etunel_list_roles",
|
||||
@@ -50,5 +50,5 @@
|
||||
用 mcp__etunel__etunel_list_roles 获取实际角色;role用于路由,display_name用于显示,member_alias用于称呼已绑定负责人,不向人核对内部编号。按需用 mcp__etunel__etunel_get_project_status 查状态,但消息状态不等于成果完成。
|
||||
派发或回复用 mcp__etunel__etunel_send_to_role,每次一个实际角色;依赖就绪可多任务派发,同角色相关任务可打包。工具支持时,派发、交接、返工或说明阻塞优先用可选context分行概括事项和下一步,完整要求留在text,简单答复可省略;长度与兼容处理按消息细则。给足输入并写清成果要求,要求成员先与真人对齐、完成后自审并取得确认。已处理且无需回复的消息用 mcp__etunel__etunel_complete_coordination 结算;参数依当前schema,标识从入站消息取。
|
||||
只检查当前必需条件,复用成果,阶段通过后推进,后续资料按需索取。决定变化要说明旧任务停续和新输入;资料冲突交专业角色核实。按阶段留档并更新进度、成果索引和记录;直交测试文件登记索引、版本及产品收讫。阻塞请相应负责人协调;跳转、回退或暂缓如实记录,模拟默认不跳过交接、不关闭真实问题。
|
||||
钉钉统一用个人DWS:首次按通知细则查询账号和项目群,经负责人确认后用init创建配置,已有配置沿用。可验证里程碑留档后用 python scripts/dingtalk_notify.py progress milestone "中文Markdown摘要" 汇报,Windows优先对应.ps1;其他事件按规则。需行动可在progress或message中加--at "完整姓名",同一事项合并,保留自动汇报说明。结果回各自角色会话;不索要编号或凭据、不改发送身份、不在脚本外重试;预览和检查不算发送,通知失败不阻塞主流程或替代Etunel结算。
|
||||
钉钉统一用个人DWS:首次按通知细则查询账号和项目群,经负责人确认后用init创建配置,已有配置沿用。可验证里程碑留档后用 python scripts/dingtalk_notify.py progress milestone --stage "当前阶段编号及中文名称" "逐条中文Markdown摘要" 汇报,Windows优先对应.ps1;其他事件按规则。阶段编号和名称每次从有效项目记录取得,不自行编号;正文用已确认的进展、待办事项、遗留三个栏目;待办按“事项名称:由谁做什么”列清,保留前提、停止事项和已定范围,阶段启动不写成项目关闭。纯Agent工作写对应角色;需真人处理且姓名已明确时,在对应条目写@{完整姓名},并加--at "完整姓名",请求决定时@有权决定的真人;遗留不等于获准延期或通过门禁。同一事项合并,保留自动汇报说明。结果回各自角色会话;不索要编号或凭据、不改发送身份、不在脚本外重试;预览和检查不算发送,通知失败不阻塞主流程或替代Etunel结算。
|
||||
```
|
||||
+40
-12
@@ -16,8 +16,8 @@ import time
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PREFIX = 'DINGTALK_PROGRESS_'
|
||||
KEYS = {'ENABLED', 'PROFILE', 'TARGET', 'PROJECT'}
|
||||
EVENTS = {'start': '开始推进', 'milestone': '阶段里程碑', 'blocked': '遇到阻塞',
|
||||
'complete': '本轮完成', 'failed': '执行失败'}
|
||||
EVENTS = ('start', 'milestone', 'blocked', 'complete', 'failed')
|
||||
INLINE_MENTION = re.compile(r'@\{([^{}\r\n]+)\}')
|
||||
DNS_ERROR = re.compile(r'lookup [\w.-]+(?: on [^:\s]+(?::\d+)?)?: [^\n]*'
|
||||
r'(?:i/o timeout|no such host|server misbehaving|temporary failure in name resolution)', re.I)
|
||||
|
||||
@@ -123,12 +123,36 @@ def check_login(cli, profile, run, sleep):
|
||||
raise NoticeError('个人 DWS 未登录或授权已失效,请负责人完成登录;本次未发送。')
|
||||
|
||||
|
||||
def mention_names(names):
|
||||
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
|
||||
if any(not name for name in names):
|
||||
raise NoticeError('提醒对象不能为空。')
|
||||
return names
|
||||
|
||||
|
||||
def format_mentions(text, names, people=None):
|
||||
names = mention_names(names)
|
||||
placed = {match.group(1).strip() for match in INLINE_MENTION.finditer(text)}
|
||||
if '@{' in INLINE_MENTION.sub('', text) or not placed.issubset(names):
|
||||
raise NoticeError('正文中的 @{完整姓名} 必须完整填写,并在 --at 中列出同一姓名。')
|
||||
by_name = {name: person for person, name in people.items()} if people is not None else None
|
||||
if by_name is not None and not set(names).issubset(by_name):
|
||||
raise NoticeError('提醒对象未全部唯一匹配,本次停止,不删掉 @ 后发送。')
|
||||
|
||||
def marker(name):
|
||||
return f'<@{by_name[name]}>' if by_name is not None else f'@{name}'
|
||||
|
||||
text = INLINE_MENTION.sub(lambda match: marker(match.group(1).strip()), text)
|
||||
remaining = [marker(name) for name in names if name not in placed]
|
||||
if remaining:
|
||||
text += '\n\n' + ' '.join(remaining)
|
||||
return text
|
||||
|
||||
|
||||
def mentions(names, cli, profile, run, sleep):
|
||||
people = {}
|
||||
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
|
||||
names = mention_names(names)
|
||||
for name in names:
|
||||
if not name:
|
||||
raise NoticeError('提醒对象不能为空。')
|
||||
data = invoke([cli, 'aisearch', 'person', '--query', name, '--dimension', 'name',
|
||||
'--profile', profile, '--format', 'json'], run, sleep)
|
||||
candidates = {}
|
||||
@@ -187,6 +211,7 @@ def parse_args(argv):
|
||||
init.add_argument('--replace', action='store_true')
|
||||
progress = commands.add_parser('progress')
|
||||
progress.add_argument('event', choices=EVENTS)
|
||||
progress.add_argument('--stage', required=True, help='项目当前阶段编号及中文名称;每次从有效项目记录取得')
|
||||
progress.add_argument('summary', nargs='*')
|
||||
progress.add_argument('--file')
|
||||
message = commands.add_parser('message')
|
||||
@@ -196,7 +221,7 @@ def parse_args(argv):
|
||||
body.add_argument('--file')
|
||||
check = commands.add_parser('check', help='检查固定账号登录及可选姓名,不发送')
|
||||
for command in (progress, message, check):
|
||||
command.add_argument('--at', action='append', default=[], help='完整姓名;可重复或用逗号分隔')
|
||||
command.add_argument('--at', action='append', default=[], help='完整姓名;正文可用 @{完整姓名} 定位,可重复或用逗号分隔')
|
||||
for command in (progress, message):
|
||||
command.add_argument('--dry-run', action='store_true', help='完全离线,只预览中文正文')
|
||||
return parser.parse_args(argv)
|
||||
@@ -215,12 +240,13 @@ def render(args, config, root):
|
||||
text = ' '.join(args.summary) if args.command == 'progress' else args.text
|
||||
if not text.strip() or len(text.encode('utf-8')) > 12000:
|
||||
raise NoticeError('请提供不超过 12000 字节的简短中文摘要。')
|
||||
title = EVENTS[args.event] if args.command == 'progress' else args.title.strip()
|
||||
if not title or len(title) > 200 or '\n' in title or '\r' in title:
|
||||
raw_title = args.stage if args.command == 'progress' else args.title
|
||||
title = raw_title.strip()
|
||||
if not title or len(title) > 200 or any(ord(c) < 32 or ord(c) == 127 for c in raw_title):
|
||||
raise NoticeError('请使用不超过 200 字的单行标题。')
|
||||
project = config.get('PROJECT') or root.name
|
||||
timestamp = dt.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %z')
|
||||
return f'## {project}|{title}\n\n项目进度自动汇报 · {timestamp}\n\n{text}'
|
||||
return f'**{project}|{title}**\n\n项目进度自动汇报 · {timestamp}\n\n{text}'
|
||||
|
||||
|
||||
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
|
||||
@@ -236,7 +262,8 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
|
||||
return 0
|
||||
config = read_config(root)
|
||||
if args.command != 'check' and args.dry_run:
|
||||
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=render(args, config, root), mentions=args.at)
|
||||
text = format_mentions(render(args, config, root), args.at)
|
||||
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=text, mentions=mention_names(args.at))
|
||||
return 0
|
||||
if args.command != 'check' and config.get('ENABLED') != '1':
|
||||
emit('skipped', '通知尚未初始化或未启用;本次未发送,主任务继续。')
|
||||
@@ -244,6 +271,8 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
|
||||
if not config.get('PROFILE') or not config.get('TARGET'):
|
||||
raise NoticeError('请由 Hub 先确认个人账号和项目群并执行 init。')
|
||||
text = render(args, config, root) if args.command != 'check' else None
|
||||
if text is not None:
|
||||
format_mentions(text, args.at) # Validate inline names before any DWS call.
|
||||
cli = dws_path()
|
||||
profile = config['PROFILE']
|
||||
if args.command == 'check':
|
||||
@@ -252,8 +281,7 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
|
||||
if args.command == 'check':
|
||||
emit('checked', '登录及所请求的姓名查询通过;未发送,未验证实际提醒效果。', names=list(people.values()))
|
||||
return 0
|
||||
if people:
|
||||
text += '\n\n' + ' '.join(f'<@{person}>' for person in people)
|
||||
text = format_mentions(text, args.at, people)
|
||||
command = [cli, 'chat', '+messages-send', '--as', 'user', '--group', config['TARGET'],
|
||||
'--markdown', text, '--profile', profile, '--yes', '--format', 'json']
|
||||
if people:
|
||||
|
||||
+145
-24
@@ -61,8 +61,8 @@ class NotifyTests(unittest.TestCase):
|
||||
return code, json.loads(out.getvalue())
|
||||
|
||||
def test_each_event_personal_markdown(self):
|
||||
for event, label in n.EVENTS.items():
|
||||
code, result = self.execute(['progress', event, '**进展**\n\n- 已归档'])
|
||||
for event in n.EVENTS:
|
||||
code, result = self.execute(['progress', event, '--stage', '测试验证', '**进展**\n\n- 已归档'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(result['status'], 'accepted')
|
||||
cmd = self.calls[-1][0]
|
||||
@@ -70,12 +70,37 @@ class NotifyTests(unittest.TestCase):
|
||||
self.assertEqual(cmd[cmd.index('--group') + 1], GROUP)
|
||||
self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE)
|
||||
body = cmd[cmd.index('--markdown') + 1]
|
||||
self.assertIn(label, body)
|
||||
self.assertEqual(body.splitlines()[0], '**测试项目|测试验证**')
|
||||
self.assertIn('项目进度自动汇报', body)
|
||||
self.assertIn('**进展**\n\n- 已归档', body)
|
||||
self.assertNotIn('--ai-tag=false', cmd)
|
||||
self.assertNotIn('--robot-code', cmd)
|
||||
|
||||
def test_progress_requires_stage_before_any_dws_call(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error:
|
||||
self.execute(['progress', 'milestone', '已更新'])
|
||||
self.assertEqual(error.exception.code, 2)
|
||||
self.assertFalse(self.calls)
|
||||
|
||||
def test_invalid_stage_never_sends(self):
|
||||
for stage in ['', ' ', '测试\n验证', '测试验证\r', '测试\t验证', '测试\0验证', '验' * 201]:
|
||||
with self.subTest(stage=stage):
|
||||
code, result = self.execute(['progress', 'milestone', '已更新', '--stage', stage])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(result['status'], 'error')
|
||||
self.assertFalse(self.calls)
|
||||
|
||||
def test_stage_changes_per_notification_without_config_change(self):
|
||||
original = self.path.read_bytes()
|
||||
text = '**待办事项**\n\n- 由 Hub 请项目负责人确认测试结果。\n\n**遗留**\n\n- 无。'
|
||||
for stage in [' 阶段六:测试验证 ', '阶段七:平台/客户验收']:
|
||||
code, result = self.execute(['progress', 'milestone', text, '--stage', stage, '--dry-run'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(result['text'].splitlines()[0], '**测试项目|' + stage.strip() + '**')
|
||||
self.assertTrue(result['text'].endswith(text))
|
||||
self.assertEqual(self.path.read_bytes(), original)
|
||||
self.assertFalse(self.calls)
|
||||
|
||||
def test_message_mentions_exact_name_fixed_profile(self):
|
||||
code, data = self.execute(['message', '--title', '提醒', '--text', '请补充资料', '--at', '测试人,测试人'])
|
||||
self.assertEqual(code, 0)
|
||||
@@ -88,11 +113,105 @@ class NotifyTests(unittest.TestCase):
|
||||
self.assertEqual(data['mentions'], ['测试人'])
|
||||
self.assertEqual(data['openTaskId'], 'fake-task')
|
||||
|
||||
def test_approved_closing_template_preserves_conditions_and_scope(self):
|
||||
body = '''**已确认的进展**
|
||||
|
||||
- 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
|
||||
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
|
||||
- 已通知相关负责人进行阶段交接,不新增审批。
|
||||
|
||||
**待办事项**
|
||||
|
||||
- 结项资料整理:由 Hub 整理四类结项工作稿。
|
||||
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
|
||||
- 旧事项停止:由 Hub 停止此前的验收准备催办。
|
||||
- 结项确认:四类成果具备后,由 Hub 请 @{测试人} 确认结项,确认后再锁定成果。
|
||||
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
|
||||
|
||||
**遗留**
|
||||
|
||||
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
|
||||
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。'''
|
||||
path = self.root / '通知正文.md'
|
||||
path.write_bytes(body.replace('\n', '\r\n').encode('utf-8'))
|
||||
before = self.path.read_bytes()
|
||||
args = ['progress', 'milestone', '--stage', '阶段八:复盘结项', '--file', str(path), '--at', '测试人']
|
||||
code, preview = self.execute(args + ['--dry-run'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertFalse(self.calls)
|
||||
self.assertEqual(preview['text'].splitlines()[0], '**测试项目|阶段八:复盘结项**')
|
||||
self.assertEqual(preview['text'].split('\n\n', 2)[2], body.replace('@{测试人}', '@测试人'))
|
||||
code, result = self.execute(args)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(result['status'], 'accepted')
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
command = self.calls[-1][0]
|
||||
markdown = command[command.index('--markdown') + 1]
|
||||
self.assertEqual(markdown.split('\n\n', 2)[2], body.replace('@{测试人}', '<@fake-person>'))
|
||||
self.assertEqual(command[command.index('--at-open-dingtalk-ids') + 1], PERSON)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_progress_can_include_mention_once(self):
|
||||
code, _ = self.execute(['progress', 'blocked', '缺输入', '--at', '测试人'])
|
||||
code, _ = self.execute(['progress', 'blocked', '--stage', '测试验证', '缺输入', '--at', '测试人'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(sum(c[0][1:3] == ['chat', '+messages-send'] for c in self.calls), 1)
|
||||
|
||||
def test_inline_mentions_match_each_item_and_deduplicate_recipients(self):
|
||||
ids = {'甲': 'fake-a', '乙': 'fake-b', '丙': 'fake-c'}
|
||||
def lookup(command, **kwargs):
|
||||
if command[1:3] == ['aisearch', 'person']:
|
||||
self.calls.append((command, kwargs))
|
||||
name = command[command.index('--query') + 1]
|
||||
return reply({'result': [{'openDingTalkId': ids[name], 'meta': {'name': name}}]})
|
||||
return self.fake(command, **kwargs)
|
||||
body = '**待办事项**\n\n- 由 Hub 请 @{甲} 确认测试结果。\n- 请 @{乙} 补充说明。\n\n**遗留**\n\n- @{乙} 按已定计划跟进未解决问题。'
|
||||
code, data = self.execute(['progress', 'milestone', '--stage', '阶段六:测试验证',
|
||||
body, '--at', '甲,乙,甲', '--at', '丙'], lookup)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(data['mentions'], ['甲', '乙', '丙'])
|
||||
self.assertEqual(len(self.calls), 4)
|
||||
cmd = self.calls[-1][0]
|
||||
actual = cmd[cmd.index('--markdown') + 1]
|
||||
expected = body.replace('@{甲}', '<@fake-a>').replace('@{乙}', '<@fake-b>')
|
||||
self.assertTrue(actual.endswith(expected + '\n\n<@fake-c>'))
|
||||
self.assertEqual(cmd[cmd.index('--at-open-dingtalk-ids') + 1], 'fake-a,fake-b,fake-c')
|
||||
for command, _ in self.calls:
|
||||
self.assertEqual(command[command.index('--profile') + 1], PROFILE)
|
||||
|
||||
def test_inline_mentions_in_message_file(self):
|
||||
path = self.root / '正文.md'
|
||||
path.write_text('**待办事项**\n\n- 请 @{测试人} 补充资料。', encoding='utf-8')
|
||||
code, _ = self.execute(['message', '--title', '阶段三:方案设计', '--file', str(path), '--at', '测试人'])
|
||||
self.assertEqual(code, 0)
|
||||
cmd = self.calls[-1][0]
|
||||
text = cmd[cmd.index('--markdown') + 1]
|
||||
self.assertTrue(text.endswith('- 请 <@fake-person> 补充资料。'))
|
||||
self.assertEqual(text.count('<@fake-person>'), 1)
|
||||
|
||||
def test_inline_preview_shows_names_without_lookup(self):
|
||||
code, data = self.execute(['progress', 'blocked', '--stage', '阶段三:方案设计',
|
||||
'- 请 @{甲} 补充接口说明。', '--at', '甲,甲', '--at', '乙', '--dry-run'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(data['text'].endswith('- 请 @甲 补充接口说明。\n\n@乙'))
|
||||
self.assertEqual(data['mentions'], ['甲', '乙'])
|
||||
self.assertFalse(self.calls)
|
||||
|
||||
def test_invalid_inline_mentions_stop_before_lookup_or_send(self):
|
||||
for body in ['请 @{乙} 确认', '请 @{} 确认', '请 @{甲 确认', '请 @{甲\n} 确认']:
|
||||
for options in [[], ['--dry-run']]:
|
||||
with self.subTest(body=body, options=options):
|
||||
code, result = self.execute(['message', '--title', '提醒', '--text', body, '--at', '甲'] + options)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(result['status'], 'error')
|
||||
self.assertFalse(self.calls)
|
||||
|
||||
def test_inline_mentions_with_conflicting_lookup_results_never_send(self):
|
||||
code, result = self.execute(['message', '--title', '提醒', '--text', '- @{甲} 和 @{乙} 请核对。', '--at', '甲,乙'])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(result['status'], 'error')
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
self.assertTrue(all(c[0][1:3] == ['aisearch', 'person'] for c in self.calls))
|
||||
|
||||
def test_lookup_ambiguity_no_match_and_malformed_never_send(self):
|
||||
for rows in [[], [{'openDingTalkId': 'a', 'meta': {'name': '甲'}},
|
||||
{'openDingTalkId': 'b', 'meta': {'name': '甲'}}],
|
||||
@@ -119,14 +238,14 @@ class NotifyTests(unittest.TestCase):
|
||||
|
||||
def test_disabled_and_missing_config_no_tools(self):
|
||||
self.config(ENABLED='0')
|
||||
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
|
||||
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'])[1]['status'], 'skipped')
|
||||
self.path.unlink()
|
||||
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
|
||||
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'])[1]['status'], 'skipped')
|
||||
self.assertFalse(self.calls)
|
||||
|
||||
def test_preview_without_config_or_dws(self):
|
||||
self.path.unlink()
|
||||
code, result = self.execute(['progress', 'blocked', '说明', '--at', '甲', '--dry-run'])
|
||||
code, result = self.execute(['progress', 'blocked', '--stage', '测试验证', '说明', '--at', '甲', '--dry-run'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(result['status'], 'preview')
|
||||
self.assertFalse(self.calls)
|
||||
@@ -136,7 +255,7 @@ class NotifyTests(unittest.TestCase):
|
||||
self.config()
|
||||
with self.path.open('a', encoding='utf-8') as f:
|
||||
f.write(n.PREFIX + suffix + '\n')
|
||||
code, data = self.execute(['progress', 'start', '开始'])
|
||||
code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始'])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn('fake-secret', json.dumps(data))
|
||||
self.assertFalse(self.calls)
|
||||
@@ -144,20 +263,20 @@ class NotifyTests(unittest.TestCase):
|
||||
def test_config_bom_crlf_and_literal_values(self):
|
||||
raw = self.path.read_text(encoding='utf-8').replace('测试项目', '中文 "引号" $()')
|
||||
self.path.write_bytes(b'\xef\xbb\xbf' + raw.replace('\n', '\r\n').encode())
|
||||
code, data = self.execute(['progress', 'start', '开始', '--dry-run'])
|
||||
code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始', '--dry-run'])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('中文 "引号" $()', data['text'])
|
||||
|
||||
def test_files_and_invalid_bodies(self):
|
||||
body = self.root / '正文.md'
|
||||
body.write_text('**进展**\n\n- 中文 "引号" $()', encoding='utf-8')
|
||||
for args in [['progress', 'milestone'], ['message', '--title', '说明']]:
|
||||
for args in [['progress', 'milestone', '--stage', '测试验证'], ['message', '--title', '说明']]:
|
||||
self.assertEqual(self.execute(args + ['--file', str(body)])[0], 0)
|
||||
before = len(self.calls)
|
||||
for args in [['progress', 'start', ''], ['progress', 'start', 'x' * 12001],
|
||||
['progress', 'start', '正文', '--file', str(body)],
|
||||
for args in [['progress', 'start', '--stage', '测试验证', ''], ['progress', 'start', '--stage', '测试验证', 'x' * 12001],
|
||||
['progress', 'start', '--stage', '测试验证', '正文', '--file', str(body)],
|
||||
['message', '--title', '坏\n标题', '--text', '内容'],
|
||||
['progress', 'start', '--file', str(self.path), '--dry-run']]:
|
||||
['progress', 'start', '--stage', '测试验证', '--file', str(self.path), '--dry-run']]:
|
||||
self.assertEqual(self.execute(args)[0], 1)
|
||||
self.assertEqual(len(self.calls), before)
|
||||
|
||||
@@ -166,14 +285,14 @@ class NotifyTests(unittest.TestCase):
|
||||
def failed(*a, **kw):
|
||||
calls.append(a)
|
||||
return reply(code=1, error='lookup api.dingtalk.com: no such host')
|
||||
code, _ = self.execute(['progress', 'start', '开始'], failed)
|
||||
code, _ = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], failed)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(self.sleeps, [10, 10])
|
||||
|
||||
def test_dns_then_success(self):
|
||||
responses = iter([reply(code=1, error='lookup api.dingtalk.com: no such host'), reply(OK)])
|
||||
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: next(responses))[0], 0)
|
||||
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'], lambda *a, **kw: next(responses))[0], 0)
|
||||
self.assertEqual(self.sleeps, [10])
|
||||
|
||||
def test_failure_and_unknown_never_retry(self):
|
||||
@@ -186,14 +305,14 @@ class NotifyTests(unittest.TestCase):
|
||||
def failed(*a, **kw):
|
||||
count.append(1)
|
||||
return result
|
||||
code, data = self.execute(['progress', 'start', '开始'], failed)
|
||||
code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], failed)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(len(count), 1)
|
||||
self.assertNotIn('fake-secret', json.dumps(data))
|
||||
self.assertFalse(self.sleeps)
|
||||
|
||||
def test_receipt_wins_nonzero_exit(self):
|
||||
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0)
|
||||
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0)
|
||||
|
||||
def test_wrong_receipts_rejected(self):
|
||||
for bad in [OK | {'identity': 'bot'}, OK | {'tool': 'other'}, OK | {'failures': [1]},
|
||||
@@ -207,13 +326,13 @@ class NotifyTests(unittest.TestCase):
|
||||
def fail(*a, **kw):
|
||||
calls.append(1)
|
||||
raise error
|
||||
code, _ = self.execute(['progress', 'start', '开始'], fail)
|
||||
code, _ = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], fail)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_no_app_credentials_in_child_environment(self):
|
||||
with patch.dict(os.environ, {'DWS_CLIENT_SECRET': 'fake-secret', 'DWS_CLIENT_ID': 'fake-app', 'DING_SEC': 'fake-key'}):
|
||||
self.execute(['progress', 'start', '开始'])
|
||||
self.execute(['progress', 'start', '--stage', '测试验证', '开始'])
|
||||
env = self.calls[-1][1]['env']
|
||||
self.assertNotIn('DWS_CLIENT_SECRET', env)
|
||||
self.assertNotIn('DWS_CLIENT_ID', env)
|
||||
@@ -282,7 +401,7 @@ class NotifyTests(unittest.TestCase):
|
||||
def test_hardlinked_config_cannot_be_message_body(self):
|
||||
other = self.root / 'not-a-summary.md'
|
||||
os.link(self.path, other)
|
||||
self.assertEqual(self.execute(['progress', 'start', '--file', str(other), '--dry-run'])[0], 1)
|
||||
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '--file', str(other), '--dry-run'])[0], 1)
|
||||
|
||||
def test_real_powershell_launcher_in_isolated_project(self):
|
||||
pwsh = shutil.which('pwsh')
|
||||
@@ -295,15 +414,17 @@ class NotifyTests(unittest.TestCase):
|
||||
shutil.copyfile(source / name, folder / name)
|
||||
env = n.clean_env()
|
||||
env['DINGTALK_PYTHON'] = sys.executable
|
||||
text = '**中文**\n\n- "引号" $() `原样`'
|
||||
for script, args in [('dingtalk-progress.ps1', ['milestone', text]),
|
||||
text = '**中文**\n\n- 请 @{测试人} 确认 "引号" $() `原样`'
|
||||
for script, args in [('dingtalk-progress.ps1', ['milestone', text, '--stage', '测试验证']),
|
||||
('dingtalk-message.ps1', ['--title', '中文 "标题"', '--text', text])]:
|
||||
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--dry-run'],
|
||||
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--at', '测试人', '--dry-run'],
|
||||
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
data = json.loads(result.stdout)
|
||||
self.assertEqual(data['status'], 'preview')
|
||||
self.assertIn(text, data['text'])
|
||||
self.assertIn(text.replace('@{测试人}', '@测试人'), data['text'])
|
||||
if script == 'dingtalk-progress.ps1':
|
||||
self.assertEqual(data['text'].splitlines()[0], '**测试项目|测试验证**')
|
||||
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / 'dingtalk-python.ps1'), 'init',
|
||||
'--profile', PROFILE, '--group', GROUP, '--project', '甲'],
|
||||
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)
|
||||
|
||||
Reference in new issue
Block a user