diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f3ff4..d211795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,9 +73,290 @@ > **版本怎么走**(两行就够): > · **正式版本** = 下面那条 `## [x.y.z] - 日期`,是**发版事实**,由人批准后打注释 tag `vX.Y.Z`; > · **开发版本自动递增** = `<正式版本的主.次>.<自那个 tag 以来的提交数>`(正式 `0.1.0` 之后第 12 个提交 = -> `0.1.12`),由 `scripts/version.py` 从 git **算出来**,**不写进本文件** ⇒ 每个提交都有自己的版本号, +> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯, > 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。 +## 开发快照 snapshot/v0.1.26 - 2026-10-18 + +### Fixed + +- 版本记录收录已交付开发快照与当前运行提交,不再只同步 main;开发版本显示对应的独立变更说明。 +- 修复同步重复返回旧已完成任务:进行中同步合并,完成后允许新一轮同步。 +- 构建启动后自动请求版本同步;部署后独立读回当前完整 SHA、开发标签与变更说明,缺失时不宣称交付完成。 + +### Added + +- 增加开发快照计划、显式发布及远端读回工具;历史说明按原提交补记,正式 tag 与 Release 保持独立授权。 + +## 开发快照 snapshot/v0.1.25 - 2026-10-18 + +目标提交:90901a4458054aca721047c9a2896f2130d46b5c + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 问答记录新增“本周”快捷查询,默认展示北京时间本周数据;所选范围自动读取全部记录,校验完整数量后展示,不再需要分页或加载更多。固定展示完整字段,空列不再隐藏,中文原文直接展示、外语未翻译明确标记;保留源端有界传输、取消和失败保护。 + +## 开发快照 snapshot/v0.1.24 - 2026-10-18 + +目标提交:3566f208e0e15fdec2cc98c29f93c46f5f7c91ca + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 问答记录新增年份与自然周选择,周次展示起止日期,支持跨年及每年实际周数;按北京时间周一至周日填入查询范围,默认本周且仅查询截至当前的数据。保留自定义时间与原查询按钮,浏览器时区不改变查询边界。 + +## 开发快照 snapshot/v0.1.23 - 2026-10-18 + +目标提交:2ea2111420e501fec37e0ea0c4bcd3cd8c866403 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Fixed + +- 修复通过带端口的 HTTP 地址访问问答运营时,正常同源操作被误报“拒绝跨站配置请求”:网页代理保留原始 Host 及端口,使缺少 Fetch Metadata 的浏览器也能正确执行 Origin 校验。保留后端跨站、异端口、伪造转发头与缺失操作意图的拒绝保护。 + +## 开发快照 snapshot/v0.1.22 - 2026-10-18 + +目标提交:828c2c5fa0d4e3e0eee3987085a4d782872f7e95 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 问答运营按已确认口径显示知识覆盖率、回答正确率及原始分子/分母、排除数量与构成。使用完整筛选范围和当前有效人工优先结论,精确规则命中的未覆盖可进入覆盖分母;部分覆盖不算完全覆盖,待确认/未评估/失效/不计入按对应指标排除,零分母显示“暂无可计算数据”。返回独立规则版本,AI 模型与绩效范围仍未接入。 +- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。基础数量与本期经确认新增的质量比例分开显示;不接 AI 模型、不定义绩效分母、不回写 DobotAI 原问答。 + +## 开发快照 snapshot/v0.1.21 - 2026-10-17 + +目标提交:4b3aaff3219e78ae3dfd5d12ba36f82e323a21ba + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 问答详情支持治理侧人工复核及不可变审计:覆盖、正确性、产品分类、问题类型、根因与理由由管理员明确填写,操作人取登录身份;保存后独立读回复核历史。保留源端判断和自动规则,输入变化使旧人工结论失效,已观测的内容回退不自动恢复旧结论;并发修改冲突、重复请求不重复留档。 +- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。仅提供数量,不接 AI 模型、不定义质量率或绩效分母、不回写 DobotAI 原问答。 + +## 开发快照 snapshot/v0.1.20 - 2026-10-17 + +目标提交:9556db1ef01add21064553f64be6a7e25f838e9e + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。 + +## 开发快照 snapshot/v0.1.19 - 2026-10-17 + +目标提交:56b8cabed9213911b6beb4aeaac9baa22e872a00 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 问答运营按稳定身份关联当前治理通讯录,缺失部门/区域补全并保留源快照及目录/区域版本;空列判断包含完整筛选结果,不混合配置已变化的分页。新增完整时间/关键词范围的使用统计,问答计次、名册内人员去重,跨区/未匹配/身份缺失分列,不输出绩效结论;不回写 DobotAI 原表。 +- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。 +- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。基础使用统计见本期新增,不代表绩效验收;翻译及 AI 评估模型接入按用户要求暂缓。 +- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估尚未接入,不标为第一阶段完成。 + +## 开发快照 snapshot/v0.1.18 - 2026-10-17 + +目标提交:cc4906b6a9f33683a5c0017d83b24d96d5c997b1 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 「问答运营 → 区域使用」支持管理员从网页发起持久化通讯录同步,重复请求重放、进行中操作合并;按服务器固定范围注册后台执行器,未接线或未检测到心跳时不创建新任务。界面显示真实任务状态、不编造百分比;失败保留旧快照与区域规则,同步完成不覆盖未保存草稿。旧执行器丢失租约后不能激活结果,重启接管和已提交快照的续跑使用原任务。 +- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件;问答组织关联尚未接入。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。 +- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。 + +## 开发快照 snapshot/v0.1.17 - 2026-10-17 + +目标提交:6adfdcdd4fe0e5c35018bf0e4857c203dd24e5d1 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 区域归属表支持长部门路径换行,避免继承全局不换行规则后将生效区域与归属依据列挤出桌面面板。 + +## 开发快照 snapshot/v0.1.16 - 2026-10-17 + +目标提交:5874134e049b0896a202b41d9d400d1be5386f89 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件;网页触发的后台同步队列及问答组织关联尚未接入。 + +## 开发快照 snapshot/v0.1.15 - 2026-10-17 + +目标提交:dab74b706b5b87da3ecb71a8e0f6b6caa5fba4ad + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是实时通讯录同步或使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。 +- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。 + +## 开发快照 snapshot/v0.1.14 - 2026-10-17 + +目标提交:fd04bf7886aa4eba68b247dd7c1092894a5282da + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估、人员区域映射尚未接入,不标为第一阶段完成。 +- 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。 +- **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。 + +## 开发快照 snapshot/v0.1.13 - 2026-10-14 + +目标提交:c1d49adb622f3bf18e51bddb415795a894ed227e + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Changed + +- 补三Agent交接闭环工作流(状态机、快照tag与验收模板)。 + +## 开发快照 snapshot/v0.1.12 - 2026-10-14 + +目标提交:9a8a3e77956149d116360bbb561efd67e20fb062 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Fixed + +- **登记钉钉知识库失败(HTTP 409 `space_registration_conflict`)的真实原因与修复**:`source_connections.name` 有唯一约束,而早期路径登记的钉钉来源(`base_url` 为空、id 与 dws 现在推导的确定性 id 不同)被**归档后仍占着名字槽位** ⇒ 重新登记时 INSERT 撞唯一约束;处理器又把**所有**存储层错误都翻译成同一个 409,真实原因被吞掉(线上只能看到一个无细节的错误码)。现在:① 同名来源**已归档**时,把它改名腾位(`…(已下架 · <短ID>)`,归档行不显示、数据与审计一行不动);② 同名来源**仍在使用**时返回 `space_name_conflict` + 可行动提示(先「去掉来源」);③ 同一登记键指向别的空间且该行仍在用时返回 `space_identity_conflict`(已归档的行则允许就地改写 —— 这就是"去掉后再用 dws 重新获取");④ 其余内部故障改回 **500** `space_registration_failed`,不再伪装成冲突;前端优先显示后端 `hint`,并为这两个新码补了中文说明。 + +## 开发快照 snapshot/v0.1.11 - 2026-10-14 + +目标提交:2c7265efc47a2cd6b324bf278fa5ddb0c4a9fe57 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Changed + +- 「系统设置 → 数据源配置」**收敛成两个入口**:**dws 导入**(按当前钉钉账号列出可访问的知识库与钉盘空间)与 **PP 导入**。AList(已由钉盘文件替代)、静态登记的钉钉知识库、本地手工导入与 PP legacy 都**不再出现在这一页**;已接入的这类来源用**归档**下架(`POST /api/v1/source-connections/{id}/archive`,管理员、幂等,带写意图头与幂等键):从列表、来源与目录、计划刷新与手动扫描里消失,**目录快照、发布记录与审计数据一行不删**。恢复用 `…/restore`(保持停用、不自动带回调度),或让 dws 重新登记同一来源 —— 钉钉空间登记是确定性 ID + `ON CONFLICT`,**原地复活同一行、不产生重复**。卡片上新增「去掉来源」按钮(管理员,带确认弹窗)。 +- 迁移 `000047_source_connection_archive`:新增 `archived_at` / `archived_by` 与只覆盖未归档行的部分索引;列表、目录、计划刷新、手动扫描四类查询一律排除归档行(漏一条就等于入口没去掉)。 + +## 开发快照 snapshot/v0.1.10 - 2026-10-14 + +目标提交:64b32c2da64881b3c9bf6de6b8f9af3f833128fd + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Changed + +- 「系统设置 → 数据源配置」默认只列 PP 资料与「可加入的来源」:AList 来源(已由钉盘文件替代)与静态登记的钉钉知识库来源(改为按当前钉钉账号发现)**不再默认列出**,收进「显示 AList/钉钉知识库来源(N)」折叠开关 —— **来源一个都没删**,展开后照旧可改刷新周期与启停,「来源与目录」里的内容不变。手动导入来源(本地手工验收)不在这次范围内,照旧显示。 + +## 开发快照 snapshot/v0.1.9 - 2026-10-14 + +目标提交:b7f7e4f12101569db5bb76af9d836043eefc342b + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Fixed + +- **上线前护栏 + 事故复盘**:`scripts/deploy-api-web.sh` 现在先校验 `.env` 的键值形状(值里再出现 `KEY=` 或键名不合法 ⇒ **拒绝上线**),配置校验失败时也会给出"两行粘成一行"的线索。事故:`.env` 最后一行没有结尾换行、又被追加新键 ⇒ 两行粘成一行 ⇒ `VERSION_HISTORY_GITEA_BASE_URL` 变成非法值 ⇒ api/worker 按设计拒绝启动、崩溃重启(平台整体不可用)。已修数据、加护栏、加回归用例。 + +## 开发快照 snapshot/v0.1.8 - 2026-10-14 + +目标提交:4cb51108454c3559e8a6305ad3316f74ed4e2daf + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Fixed + +- 「版本记录」页的链接改为**抓取与链接分离**:抓取仍走配置的(内网)地址,页面上的「查看提交 / 查看 tag」用 `VERSION_HISTORY_LINK_ORIGIN`(公网 origin)拼 —— 原先两者共用内网地址,内网外的浏览器点开是空白;另外 tag 记录原先不带链接(空 `href` 点下去没反应),现在补上链接、没有链接就不渲染该按钮。 + +## 开发快照 snapshot/v0.1.7 - 2026-10-14 + +目标提交:90db243a68a5d16d643dd0273614b93666d65ca7 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Fixed + +- 前端入口与静态资源的缓存语义("新功能看起来没上线"与"页面偶尔裸奔/空白"的根因): + +## 开发快照 snapshot/v0.1.6 - 2026-10-14 + +目标提交:062a3b3cb60ede5ebcca64a0f14df53fffa1f359 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Fixed + +- 前端入口的缓存策略:`index.html` 此前没有缓存头,CDN/浏览器会启发式缓存它,于是**页面会一直停在旧构建上**(实测被腾讯云 EdgeOne 卡住:源站已是新包,公网入口仍发旧包,看起来像"新菜单/新页面没上线")。现在 `index.html` 每次回源校验(`no-cache, must-revalidate`)、带内容哈希的 `/assets/` 长缓存(`immutable`),旧哈希资源明确 404 而不是回退成 HTML。 + +## 开发快照 snapshot/v0.1.5 - 2026-10-14 + +目标提交:4d43a9386a8a97969a1568956213f6f17b13edfb + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Changed + +- tag 之前的提交不再被编号 + 部署脚本注入构建身份。 + +## 开发快照 snapshot/v0.1.4 - 2026-10-13 + +目标提交:0974d8dfa2aad5650eefbcee3edb365a8afd4868 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Added + +- **「版本记录」页**(一级菜单第 5 项):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。 +- 新增只读接口 `GET /api/v1/build-info`(当前运行产物的构建身份)、`GET /api/v1/version-history`(快照游标分页,不返回伪造的 total)、`POST /api/v1/version-history/sync`(仅管理员手动同步,并发同步复用同一作业)。 +- 新增迁移 `000046_version_history`(提交记录 + 同步状态):`commit_sha` 是唯一身份,**版本号不是唯一键** —— 两个提交算出同一个开发号时各占一行,用 SHA 区分。 +- 「当前运行」只认运行产物注入的**完整 SHA 精确匹配**:匹配到就标该条;开发构建显式标注"开发构建";拿不到标识就显示"当前运行版本未知",**不拿最新 main 或最新 tag 顶替**;CI 成功不等于已部署。 + +### Security + +- 版本记录同步**匿名只读**远端仓库(**不使用任何凭据**),来源限配置里批准的 origin 与固定仓库:拒绝任意 URL、拒绝跨主机重定向,单个响应设大小上限与超时、5xx/429 才有退避重试。 +- 提交正文与变更记录一律按**纯文本**渲染(不产生 HTML/脚本注入面);页面不含打 tag / 发布 / 部署入口;同步动作仅管理员可用,服务端再次判角色。 + +## 开发快照 snapshot/v0.1.3 - 2026-10-13 + +目标提交:ad9c5d20d467a077e88bbfe63b1a56355d263e51 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Changed + +- Merge remote-tracking branch 'origin/main'。 + +## 开发快照 snapshot/v0.1.1 - 2026-10-13 + +目标提交:3a1d32da8fba90eb5081865b1f3ad424a46639ee + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Changed + +- 重写平台定位与能力边界并归档原说明。 + +## 开发快照 snapshot/v0.1.1 - 2026-10-13 + +目标提交:950c1b429bc814d9fa7c2c1449f999a05537af21 + +历史开发快照补记,不改变原提交、原提交时间或正式发布状态。 + +### Changed + +- 抬档门禁(破坏性 ⇒ 0.x 抬次 / ≥1.0.0 抬主)+ 1.0.0 前置(稳定契约承诺)。 + ## [0.1.0] - 2026-10-13 ### Added diff --git a/cmd/api/main.go b/cmd/api/main.go index ca18dae..a25a2f7 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -23,6 +23,7 @@ import ( "knowledge-governance/internal/qaopsorg" "knowledge-governance/internal/qaopsquality" "knowledge-governance/internal/serviceverify" + "knowledge-governance/internal/version" ) // dingtalkSourceLister 是「系统设置 → 数据源配置」的数据源发现实现:只读列举钉钉知识库与 @@ -37,6 +38,9 @@ func (dingtalkSourceLister) ListAvailableDingtalkSources(ctx context.Context) (d func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + if len(os.Args) == 2 && os.Args[1] == "--version-record-status" { + os.Exit(versionRecordStatus(ctx, os.Getenv)) + } if err := run(ctx, os.Getenv); err != nil { log.Fatal(err) } @@ -126,6 +130,12 @@ func run(ctx context.Context, getenv func(string) string) error { versionHistoryStore, versionHistoryRepo := httpapi.VersionHistoryStore(nil), "" if config.VersionHistoryGiteaBaseURL != "" { versionHistoryStore, versionHistoryRepo = postgresstore.NewStore(pool), config.VersionHistoryRepo + info := version.Snapshot() + if info.IdentityAvailable && info.CommitSHA != nil { + if _, e := actionStore.EnqueueVersionHistorySync(ctx, config.VersionHistoryRepo, "system:deployment", *info.CommitSHA, time.Now()); e != nil { + log.Print("version history deployment synchronization could not be queued") + } + } } qaReader, qaSettings := openQAOperations(ctx, getenv, config.DatabaseURL) if closer, ok := qaReader.(interface{ Close() }); ok { diff --git a/cmd/api/version_record_status.go b/cmd/api/version_record_status.go new file mode 100644 index 0000000..6f0cfa1 --- /dev/null +++ b/cmd/api/version_record_status.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/appconfig" + "knowledge-governance/internal/version" + "os" + "time" +) + +// Operator-only read-back in the existing API binary; no new public endpoint, +// no session/credential creation and no migrations or remote forge access. +func versionRecordStatus(ctx context.Context, getenv func(string) string) int { + info := version.Snapshot() + if !info.IdentityAvailable || info.CommitSHA == nil { + return printVersionStatus(false, "build_identity_missing", "") + } + config, e := appconfig.Load(getenv) + if e != nil { + return printVersionStatus(false, "configuration_invalid", *info.CommitSHA) + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + pool, e := pgxpool.New(ctx, config.DatabaseURL) + if e != nil { + return printVersionStatus(false, "database_unavailable", *info.CommitSHA) + } + defer pool.Close() + tag := "snapshot/v" + info.DisplayVersion + kind := "development" + if info.BuildKind == "release" { + tag = "v" + info.DisplayVersion + kind = "release" + } + evidence, _ := json.Marshal([]map[string]any{{"name": tag, "annotated": true}}) + var ready bool + e = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM version_history_commits c WHERE repo_key=$1 AND commit_sha=$2 AND version_label=$3 AND record_kind=$4 AND btrim(release_notes)<>'' AND release_tags @> $5::jsonb AND snapshot_id=(SELECT max(id) FROM version_history_sync_runs WHERE repo_key=$1 AND status='completed' AND complete))`, config.VersionHistoryRepo, *info.CommitSHA, info.DisplayVersion, kind, evidence).Scan(&ready) + if e != nil { + return printVersionStatus(false, "database_unavailable", *info.CommitSHA) + } + if !ready { + return printVersionStatus(false, "version_record_pending", *info.CommitSHA) + } + return printVersionStatus(true, "verified", *info.CommitSHA) +} +func printVersionStatus(ready bool, code, sha string) int { + raw, _ := json.Marshal(map[string]any{"ready": ready, "code": code, "commit_sha": sha}) + fmt.Fprintln(os.Stdout, string(raw)) + if ready { + return 0 + } + return 1 +} diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 99e2e4e..1de7bc7 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -137,11 +137,20 @@ func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[st triggeredBy := "" var payload struct { TriggeredBy string `json:"triggered_by"` + TargetSHA string `json:"target_sha"` } if err := json.Unmarshal(job.Payload, &payload); err == nil { triggeredBy = payload.TriggeredBy } - run, err := syncer.Sync(ctx, job.ID, triggeredBy) + activeSyncer := syncer + if payload.TargetSHA != "" { + var e error + activeSyncer, e = versionhistory.NewSyncer(versionhistory.SyncerConfig{Source: client, Store: store, RepoKey: config.VersionHistoryRepo, MaxPages: config.VersionHistoryMaxPages, TargetSHAs: []string{payload.TargetSHA}}) + if e != nil { + return e + } + } + run, err := activeSyncer.Sync(ctx, job.ID, triggeredBy) if err != nil { log.Printf("version history sync failed job=%s run=%d code=%s: %v", job.ID, run.ID, run.ErrorCode, err) return err diff --git a/internal/governance/development_notes.go b/internal/governance/development_notes.go new file mode 100644 index 0000000..7923915 --- /dev/null +++ b/internal/governance/development_notes.go @@ -0,0 +1,27 @@ +package governance + +import ( + "strings" +) + +// Development headings cannot replace the formal release baseline. +func ExtractDevelopmentNotes(changelog, version string) string { + if !SemverValid(version) { + return "" + } + lines := strings.Split(changelog, "\n") + prefix := "## 开发快照 snapshot/v" + version + " - " + for i, line := range lines { + if strings.HasPrefix(line, prefix) { + end := len(lines) + for j := i + 1; j < len(lines); j++ { + if strings.HasPrefix(lines[j], "## ") { + end = j + break + } + } + return strings.TrimSpace(strings.Join(lines[i+1:end], "\n")) + } + } + return "" +} diff --git a/internal/httpapi/version_history.go b/internal/httpapi/version_history.go index ffa48d5..7278cc4 100644 --- a/internal/httpapi/version_history.go +++ b/internal/httpapi/version_history.go @@ -225,10 +225,26 @@ func versionHistorySync(w http.ResponseWriter, r *http.Request, config Config) { versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "构造同步任务失败") return } + if queue, ok := config.Store.(interface { + EnqueueVersionHistorySync(context.Context, string, string, string, time.Time) (*jobs.Job, error) + }); ok { + target := "" + info := version.Snapshot() + if info.IdentityAvailable && info.CommitSHA != nil { + target = *info.CommitSHA + } + job, e := queue.EnqueueVersionHistorySync(r.Context(), config.VersionHistoryRepo, triggeredBy, target, now) + if e != nil || job == nil { + versionHistoryError(w, 500, "version_history_unavailable", "入队同步任务失败") + return + } + writeJSON(w, 202, map[string]any{"job_id": job.ID, "status": job.State}) + return + } requestedID := fmt.Sprintf("version-history-sync-%d", now.UnixNano()) job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{ID: requestedID, Type: VersionHistoryJobType, Payload: payload, AvailableAt: now, MaxAttempts: 3, - IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo}, now) + IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo + ":" + requestedID}, now) if err != nil || job == nil { versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "入队同步任务失败") return diff --git a/internal/httpapi/version_history_test.go b/internal/httpapi/version_history_test.go index 82b5bad..aae654e 100644 --- a/internal/httpapi/version_history_test.go +++ b/internal/httpapi/version_history_test.go @@ -237,7 +237,7 @@ func TestVersionHistorySyncRequiresAdminAndEnqueuesOnce(t *testing.T) { if recorder.Code != http.StatusAccepted { t.Fatalf("首次同步应 202,得到 %d(%s)", recorder.Code, recorder.Body.String()) } - if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance" { + if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance:"+queue.enqueued.ID { t.Fatalf("入队参数不对:%+v", queue.enqueued) } if queue.enqueued.MaxAttempts != 3 || queue.enqueued.AvailableAt != now { diff --git a/internal/platform/postgres/version_history_queue.go b/internal/platform/postgres/version_history_queue.go new file mode 100644 index 0000000..72a3d7f --- /dev/null +++ b/internal/platform/postgres/version_history_queue.go @@ -0,0 +1,63 @@ +package postgres + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5" + "knowledge-governance/internal/jobs" + "knowledge-governance/internal/versionhistory" + "regexp" + "strings" + "time" +) + +func (s *Store) EnqueueVersionHistorySync(ctx context.Context, repo, actor, target string, now time.Time) (*jobs.Job, error) { + if strings.Count(repo, "/") != 1 || strings.TrimSpace(repo) != repo || len(repo) > 200 || actor == "" || len(actor) > 256 || target != "" && !regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(target) { + return nil, errors.New("invalid version synchronization request") + } + tx, e := s.db.Begin(ctx) + if e != nil { + return nil, e + } + defer tx.Rollback(context.Background()) + if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "version-history:"+repo); e != nil { + return nil, e + } + // Retire exhausted expired owners. Terminal jobs are history, not a perpetual + // idempotency key: a later refresh must be allowed to execute again. + if _, e = tx.Exec(ctx, `UPDATE jobs SET state='failed',lease_owner=NULL,lease_until=NULL,updated_at=$3 WHERE type=$1 AND payload->>'repo_key'=$2 AND state='running' AND lease_until=max_attempts`, versionhistory.JobType, repo, now); e != nil { + return nil, e + } + var active string + e = tx.QueryRow(ctx, `SELECT id FROM jobs WHERE type=$1 AND payload->>'repo_key'=$2 AND state IN ('pending','running','retry') ORDER BY created_at DESC,id DESC LIMIT 1`, versionhistory.JobType, repo).Scan(&active) + if e != nil && !errors.Is(e, pgx.ErrNoRows) { + return nil, e + } + if active != "" { + job, e := scanJob(tx.QueryRow(ctx, `SELECT id,type,payload,state,priority,available_at,lease_owner,lease_until,attempt,max_attempts,last_error,idempotency_key,created_at,updated_at FROM jobs WHERE id=$1`, active)) + if e != nil { + return nil, e + } + if e = tx.Commit(ctx); e != nil { + return nil, e + } + return job, nil + } + var random [16]byte + if _, e = rand.Read(random[:]); e != nil { + return nil, e + } + id := "vh-" + hex.EncodeToString(random[:]) + payload, _ := json.Marshal(map[string]string{"repo_key": repo, "triggered_by": actor, "target_sha": target}) + job, e := NewStore(tx).Enqueue(ctx, jobs.EnqueueRequest{ID: id, Type: versionhistory.JobType, Payload: payload, AvailableAt: now, MaxAttempts: 3, IdempotencyKey: "version-history:" + repo + ":" + id}, now) + if e != nil { + return nil, e + } + if e = tx.Commit(ctx); e != nil { + return nil, e + } + return job, nil +} diff --git a/internal/platform/postgres/version_history_queue_integration_test.go b/internal/platform/postgres/version_history_queue_integration_test.go new file mode 100644 index 0000000..bdddc5b --- /dev/null +++ b/internal/platform/postgres/version_history_queue_integration_test.go @@ -0,0 +1,52 @@ +package postgres + +import ( + "context" + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/versionhistory" + "net/url" + "os" + "strings" + "testing" + "time" +) + +func TestVersionHistorySyncQueueCanRefreshAfterCompletion(t *testing.T) { + dsn := os.Getenv("KG_VERSION_HISTORY_TEST_DSN") + if dsn == "" { + t.Skip("explicit disposable version-history DB required") + } + u, e := url.Parse(dsn) + if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_version_history_test_") { + t.Fatal("refusing shared database") + } + ctx := context.Background() + p, e := pgxpool.New(ctx, dsn) + if e != nil { + t.Fatal(e) + } + defer p.Close() + if e = ApplyMigrations(ctx, p); e != nil { + t.Fatal(e) + } + s := NewStore(p) + one, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("a", 40), time.Now()) + if e != nil || one == nil { + t.Fatal("first sync missing", e) + } + same, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("a", 40), time.Now()) + if e != nil || same.ID != one.ID { + t.Fatal("pending sync not coalesced", e) + } + claimed, e := s.Claim(ctx, "fixture-worker", []string{versionhistory.JobType}, time.Now(), time.Minute) + if e != nil || claimed == nil { + t.Fatal(e) + } + if e = s.Complete(ctx, claimed.ID, claimed.LeaseOwner, time.Now()); e != nil { + t.Fatal(e) + } + next, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("b", 40), time.Now()) + if e != nil || next == nil || next.ID == one.ID || next.State != "pending" { + t.Fatal("completed request was replayed forever instead of starting a new refresh", e) + } +} diff --git a/internal/versionhistory/delivered.go b/internal/versionhistory/delivered.go new file mode 100644 index 0000000..4de3a33 --- /dev/null +++ b/internal/versionhistory/delivered.go @@ -0,0 +1,58 @@ +package versionhistory + +import ( + "context" + "fmt" + "knowledge-governance/internal/governance" + "sort" + "strings" +) + +func (s *Syncer) includeDeliveredCommits(ctx context.Context, commits []RawCommit, tags []RawTag) ([]RawCommit, error) { + seen := map[string]bool{} + for _, c := range commits { + seen[c.SHA] = true + } + refs := append([]string{}, s.targetSHAs...) + snapshots := []RawTag{} + for _, tag := range tags { + if tag.Annotated && strings.HasPrefix(tag.Name, "snapshot/v") && governance.SemverValid(strings.TrimPrefix(tag.Name, "snapshot/v")) { + snapshots = append(snapshots, tag) + } + } + sort.Slice(snapshots, func(i, j int) bool { + return governance.CompareSemver(strings.TrimPrefix(snapshots[i].Name, "snapshot/v"), strings.TrimPrefix(snapshots[j].Name, "snapshot/v")) > 0 + }) + for _, tag := range snapshots { + refs = append(refs, tag.TargetSHA) + } + for _, ref := range refs { + if seen[ref] { + continue + } + if !fullSHA.MatchString(ref) { + return nil, fmt.Errorf("开发快照缺少完整提交身份") + } + extra, total, e := s.fetchCommitsAt(ctx, ref) + if e != nil { + return nil, e + } + if total > 0 && len(extra) != total { + return nil, codeError{code: ErrorCodeCountMismatch, message: "开发快照提交历史不完整"} + } + found := false + for _, c := range extra { + if c.SHA == ref { + found = true + } + if !seen[c.SHA] { + seen[c.SHA] = true + commits = append(commits, c) + } + } + if !found { + return nil, codeError{code: ErrorCodeIncompletePages, message: "开发快照目标没有被取回"} + } + } + return commits, nil +} diff --git a/internal/versionhistory/development_test.go b/internal/versionhistory/development_test.go new file mode 100644 index 0000000..f4cfe9d --- /dev/null +++ b/internal/versionhistory/development_test.go @@ -0,0 +1,87 @@ +package versionhistory + +import ( + "context" + "fmt" + "knowledge-governance/internal/governance" + "net/http" + "strings" + "testing" + "time" +) + +func TestGiteaSnapshotRefIsExplicitAndNotesArePreserved(t *testing.T) { + server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/commits" { + if r.URL.Query().Get("sha") != shaB { + t.Error("snapshot commit history silently used default branch") + } + fmt.Fprintf(w, `[{"sha":%q,"parents":[],"commit":{"message":"fixture"}}]`, shaB) + return + } + if r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/git/refs/tags" { + fmt.Fprintf(w, `[{"ref":"refs/tags/snapshot/v0.1.1","object":{"type":"tag","sha":%q}}]`, shaT) + return + } + fmt.Fprintf(w, `{"message":"development notes","object":{"type":"commit","sha":%q}}`, shaB) + }) + c, _ := NewGiteaClient(validConfig(server.URL)) + if _, _, e := c.ListCommitsAtRef(context.Background(), shaB, 1, 50); e != nil { + t.Fatal(e) + } + tags, e := c.ListTags(context.Background()) + if e != nil || len(tags) != 1 || tags[0].Message != "development notes" { + t.Fatal("annotated snapshot notes lost", e) + } +} +func TestDevelopmentVersionsHaveTheirOwnChangelogNotes(t *testing.T) { + a, b := shaA, shaB + now := time.Now() + text := "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- unrelated future work\n\n## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n### Fixed\n\n- fix specifically delivered here\n\n## [0.1.0] - 2026-10-13\n\n### Added\n\n- baseline\n" + result := DeriveVersions(DeriveInput{Commits: []RawCommit{{SHA: a, CommittedAt: now}, {SHA: b, Parents: []string{a}, CommittedAt: now}}, Tags: []RawTag{{Name: "v0.1.0", TargetSHA: a, Annotated: true}}, Changelog: func(string) (string, error) { return text, nil }}) + for _, v := range result.Records { + if v.CommitSHA == b && (v.ReleaseNotes != "### Fixed\n\n- fix specifically delivered here" || v.RecordKind != governance.RecordKindDevelopment) { + t.Fatalf("development version lacks its own notes: %q", v.ReleaseNotes) + } + } +} + +func TestHistoricDevelopmentArchiveBindsExactCommit(t *testing.T) { + archive := "## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n目标提交:" + shaB + "\n\n### Fixed\n\n- historic delivery notes\n" + input := DeriveInput{DeliveryArchive: archive, Commits: []RawCommit{{SHA: shaA}, {SHA: shaB, Parents: []string{shaA}}}, Tags: []RawTag{{Name: "v0.1.0", TargetSHA: shaA, Annotated: true}}, Changelog: func(string) (string, error) { return "## [0.1.0] - 2026-10-13\n\n### Added\n\n- baseline", nil }} + result := DeriveVersions(input) + for _, v := range result.Records { + if v.CommitSHA == shaB && !strings.Contains(v.ReleaseNotes, "historic delivery notes") { + t.Fatal("archived notes missing") + } + } + input.DeliveryArchive = strings.ReplaceAll(archive, shaB, strings.Repeat("c", 40)) + result = DeriveVersions(input) + for _, v := range result.Records { + if v.CommitSHA == shaB && v.ReleaseNotes != "" { + t.Fatal("different commit reused same-number archive notes") + } + } +} + +type snapshotRefSource struct { + fakeSource + other []RawCommit +} + +func (s *snapshotRefSource) ListCommitsAtRef(_ context.Context, _ string, page, limit int) ([]RawCommit, int, error) { + if page == 1 { + return s.other, len(s.other), nil + } + return nil, len(s.other), nil +} +func TestSyncIncludesDeliveredSnapshotOutsideMain(t *testing.T) { + a := RawCommit{SHA: shaA} + b := RawCommit{SHA: shaB, Parents: []string{shaA}} + src := &snapshotRefSource{fakeSource: fakeSource{commits: []RawCommit{a}, total: 1, pageSize: 50, head: shaA, tags: []RawTag{{Name: "snapshot/v0.1.1", TargetSHA: shaB, Annotated: true}}}, other: []RawCommit{b, a}} + store := &fakeStore{} + run, e := newTestSyncer(t, src, store).Sync(context.Background(), "fixture", "admin") + if e != nil || len(store.saved) != 1 || len(store.saved[0].records) != 2 || !run.Complete { + t.Fatal("delivered non-main snapshot omitted", e, run.FetchedCount) + } +} diff --git a/internal/versionhistory/gitea.go b/internal/versionhistory/gitea.go index 7d04160..546400c 100644 --- a/internal/versionhistory/gitea.go +++ b/internal/versionhistory/gitea.go @@ -188,6 +188,12 @@ var errGiteaNotFound = newGiteaError(KindNotFound, http.StatusNotFound, "版本 // ListCommits 读一页提交;同时返回 X-Total-Count(用于完整性对账,不做假 total)。 func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCommit, int, error) { + return c.ListCommitsAtRef(ctx, "main", page, limit) +} +func (c *GiteaClient) ListCommitsAtRef(ctx context.Context, ref string, page, limit int) ([]RawCommit, int, error) { + if ref != "main" && !fullSHA.MatchString(ref) { + return nil, 0, newGiteaError(KindParse, 0, "版本记录:只接受 main 或完整提交 SHA", nil) + } if limit <= 0 || limit > giteaDefaultPageSize { limit = giteaDefaultPageSize } @@ -196,6 +202,7 @@ func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCo query.Set("page", fmt.Sprintf("%d", page)) query.Set("stat", "false") query.Set("verification", "false") + query.Set("sha", ref) body, header, err := c.get(ctx, c.apiURL("/commits", query)) if err != nil { return nil, 0, err @@ -322,7 +329,8 @@ func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) { return nil, err } var annotated struct { - Object struct { + Message string `json:"message"` + Object struct { SHA string `json:"sha"` } `json:"object"` } @@ -333,6 +341,7 @@ func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) { continue } tag.TargetSHA = annotated.Object.SHA + tag.Message = annotated.Message } tags = append(tags, tag) } diff --git a/internal/versionhistory/rules.go b/internal/versionhistory/rules.go index e4a826d..446ed0c 100644 --- a/internal/versionhistory/rules.go +++ b/internal/versionhistory/rules.go @@ -8,6 +8,7 @@ package versionhistory import ( "sort" + "strings" "time" "knowledge-governance/internal/governance" @@ -25,6 +26,7 @@ type RawCommit struct { // RawTag 是一个 tag 及其可核验属性;Annotated=false 表示轻量 tag(不算正式版本的证据)。 type RawTag struct { + Message string Name string TagSHA string TargetSHA string @@ -36,13 +38,14 @@ type ChangelogSource func(sha string) (string, error) // DeriveInput 是一次换算的全部输入。 type DeriveInput struct { - RepoKey string - CommitURLBase string // 形如 "///commit/",由配置拼好,不接受外部传入 - TagURLBase string // 形如 "///src/tag/";空则不下发 tag 链接 - Commits []RawCommit - Tags []RawTag - Changelog ChangelogSource - SnapshotID int64 + DeliveryArchive string + RepoKey string + CommitURLBase string // 形如 "///commit/",由配置拼好,不接受外部传入 + TagURLBase string // 形如 "///src/tag/";空则不下发 tag 链接 + Commits []RawCommit + Tags []RawTag + Changelog ChangelogSource + SnapshotID int64 } // DeriveResult 是换算结果。 @@ -192,6 +195,25 @@ func DeriveVersions(in DeriveInput) DeriveResult { } if kind == governance.RecordKindRelease { record.ReleaseNotes = governance.ExtractReleaseNotes(changelog, label) + } else if kind == governance.RecordKindDevelopment { + record.ReleaseNotes = governance.ExtractDevelopmentNotes(changelog, label) + if record.ReleaseNotes == "" { + archived := governance.ExtractDevelopmentNotes(in.DeliveryArchive, label) + if strings.Contains(archived, "目标提交:"+commit.SHA) { + record.ReleaseNotes = archived + } + } + if record.ReleaseNotes == "" { + for _, tag := range tagsByTarget[commit.SHA] { + if tag.Annotated && tag.Name == "snapshot/v"+label { + _, body, found := strings.Cut(tag.Message, "\n\n") + if found { + record.ReleaseNotes = strings.TrimSpace(body) + } + break + } + } + } } records = append(records, record) } diff --git a/internal/versionhistory/sync.go b/internal/versionhistory/sync.go index b2cf0ff..dfd277d 100644 --- a/internal/versionhistory/sync.go +++ b/internal/versionhistory/sync.go @@ -130,22 +130,24 @@ type SyncOutcome struct { // SyncerConfig 装配同步器。 type SyncerConfig struct { - Source Source - Store SyncStore - RepoKey string - Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md - Now func() time.Time - MaxPages int + Source Source + Store SyncStore + RepoKey string + Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md + Now func() time.Time + MaxPages int + TargetSHAs []string } // Syncer 抓取远端 main 可达的全部提交并换算版本记录。 type Syncer struct { - source Source - store SyncStore - repoKey string - changelog string - now func() time.Time - maxPages int + source Source + store SyncStore + repoKey string + changelog string + now func() time.Time + maxPages int + targetSHAs []string } const syncerDefaultMaxPages = 200 @@ -170,7 +172,12 @@ func NewSyncer(config SyncerConfig) (*Syncer, error) { if maxPages <= 0 { maxPages = syncerDefaultMaxPages } - return &Syncer{source: config.Source, store: config.Store, repoKey: config.RepoKey, + for _, sha := range config.TargetSHAs { + if !fullSHA.MatchString(sha) { + return nil, errors.New("版本记录:运行身份必须是完整 SHA") + } + } + return &Syncer{targetSHAs: append([]string{}, config.TargetSHAs...), source: config.Source, store: config.Store, repoKey: config.RepoKey, changelog: changelog, now: now, maxPages: maxPages}, nil } @@ -204,9 +211,22 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc return fail(err) } + commits, err = s.includeDeliveredCommits(ctx, commits, tags) + if err != nil { + return fail(err) + } + total = len(commits) + deliveryArchive := "" + if len(s.targetSHAs) > 0 { + deliveryArchive, err = s.source.ReadFile(ctx, s.targetSHAs[0], s.changelog) + if err != nil { + return fail(err) + } + } derived := DeriveVersions(DeriveInput{RepoKey: s.repoKey, CommitURLBase: s.source.CommitURLBase(), - TagURLBase: s.source.TagURLBase(), - Commits: commits, Tags: tags, SnapshotID: runID, + TagURLBase: s.source.TagURLBase(), + DeliveryArchive: deliveryArchive, + Commits: commits, Tags: tags, SnapshotID: runID, Changelog: func(sha string) (string, error) { return s.source.ReadFile(ctx, sha, s.changelog) }}) @@ -220,6 +240,9 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc } else { sourceHead = headSHA(commits) // 读分支失败不致命:退回提交列表最新一条 } + if len(s.targetSHAs) > 0 { + sourceHead = s.targetSHAs[0] + } outcome := SyncOutcome{SourceHeadSHA: sourceHead, FetchedCount: len(commits), ExpectedCount: total, Complete: derived.GraphComplete && (total == 0 || len(commits) == total)} if err := s.store.FinishVersionHistorySync(ctx, runID, outcome, s.now().UTC()); err != nil { @@ -232,6 +255,9 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc // fetchAllCommits 逐页抓取并按 SHA 去重;短页即终止(也防住服务端忽略 page 参数)。 func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error) { + return s.fetchCommitsAt(ctx, "") +} +func (s *Syncer) fetchCommitsAt(ctx context.Context, ref string) ([]RawCommit, int, error) { pageSize := s.source.PageSize() if pageSize <= 0 { pageSize = giteaDefaultPageSize @@ -240,7 +266,18 @@ func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error) commits := make([]RawCommit, 0, pageSize) total := 0 for page := 1; page <= s.maxPages; page++ { - pageCommits, pageTotal, err := s.source.ListCommits(ctx, page, pageSize) + var pageCommits []RawCommit + var pageTotal int + var err error + if ref == "" { + pageCommits, pageTotal, err = s.source.ListCommits(ctx, page, pageSize) + } else if source, ok := s.source.(interface { + ListCommitsAtRef(context.Context, string, int, int) ([]RawCommit, int, error) + }); ok { + pageCommits, pageTotal, err = source.ListCommitsAtRef(ctx, ref, page, pageSize) + } else { + return nil, 0, errors.New("版本来源不支持开发快照历史") + } if err != nil { return nil, 0, err } diff --git a/scripts/ci_checks.py b/scripts/ci_checks.py index e13a02a..8150a39 100644 --- a/scripts/ci_checks.py +++ b/scripts/ci_checks.py @@ -356,6 +356,9 @@ def cmd_internal_web_test() -> int: def cmd_internal_contracts(go: str) -> int: """版本契约:结构 + 注入身份必须**真 PASS**(SKIP 判红), 并且**开发构建不许自称正式版**(负例:标成 release ⇒ 必须红)。""" + snapshot_rc = subprocess.run(py("scripts/development_snapshot.py","--self-test"),cwd=ROOT).returncode + if snapshot_rc: + return 1 rc = subprocess.run(py("scripts/check_changelog.py"), cwd=ROOT).returncode if rc: return 1 diff --git a/scripts/deploy-api-web.sh b/scripts/deploy-api-web.sh index 314f467..37b961c 100644 --- a/scripts/deploy-api-web.sh +++ b/scripts/deploy-api-web.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # 重建并上线 governance api + web(本机 docker build 需要宿主代理 + --network host) set -u -cd /home/zheromiku/knowledge-governance || exit 1 +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 +[ -z "$(git status --porcelain --untracked-files=normal)" ] || { printf "%s\n" "REFUSED_DIRTY_SOURCE"; exit 1; } TS=$(date +%Y%m%d-%H%M%S) PROXY_ARGS=(--build-arg HTTP_PROXY=http://127.0.0.1:7890 --build-arg HTTPS_PROXY=http://127.0.0.1:7890 --build-arg NO_PROXY=localhost,127.0.0.1 --build-arg GOPROXY=https://goproxy.cn,direct) @@ -123,4 +124,12 @@ if docker exec knowledge-governance-p1-api-1 sh -lc "strings /app/api | grep -q else echo "INJECTION_MISSING(产物里没有 $INJECTED —— 页面会显示当前运行未知)" fi +echo "=== 10. 版本记录同步读回(当前 SHA + 注释快照 + 独立变更说明)" +# Startup queues the read-only history job. A healthy API alone is not proof. +VERIFIED=0 +for ((attempt=0;attempt<90;attempt++)); do + if docker exec knowledge-governance-p1-api-1 /app/api --version-record-status; then VERIFIED=1; break; fi + sleep 2 +done +[ "$VERIFIED" = "1" ] || { printf '%s\n' 'VERSION_RECORD_NOT_VERIFIED'; exit 1; } echo "DONE" \ No newline at end of file diff --git a/scripts/development_snapshot.py b/scripts/development_snapshot.py new file mode 100644 index 0000000..43ec7fc --- /dev/null +++ b/scripts/development_snapshot.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Explicit development delivery tags; never creates formal tags or releases.""" +import argparse,json,re,subprocess,tempfile +from pathlib import Path +ROOT=Path(__file__).resolve().parent.parent +SHA=re.compile(r'^[a-f0-9]{40}$') +def git(root,*args): + p=subprocess.run(['git',*args],cwd=root,text=True,capture_output=True) + if p.returncode:raise RuntimeError('git operation failed: '+args[0]) + return p.stdout.strip() +def notes(text,version): + heading=re.search(r'^## 开发快照 snapshot/v'+re.escape(version)+r' - \d{4}-\d{2}-\d{2}\s*$',text,re.M) + if not heading:return '' + tail=text[heading.end():];return re.split(r'^##\s',tail,maxsplit=1,flags=re.M)[0].strip() +def plan(root,sha,backfill=False): + if not sha or not SHA.fullmatch(sha):raise ValueError('exact 40-character commit SHA required') + if git(root,'cat-file','-t',sha)!='commit':raise ValueError('snapshot target must be a commit') + text=git(root,'show',sha+':CHANGELOG.md');m=re.search(r'^##\s*\[(\d+\.\d+\.\d+)\]',text,re.M) + if not m:raise ValueError('formal baseline missing') + baseline=m.group(1);base='v'+baseline + if git(root,'cat-file','-t','refs/tags/'+base)!='tag':raise ValueError('annotated formal baseline missing') + git(root,'merge-base','--is-ancestor',base,sha) + n=int(git(root,'rev-list','--count',base+'..'+sha));version='.'.join(baseline.split('.')[:2]+[str(n)]) + if n<=0:raise ValueError('formal baseline cannot be tagged as a development delivery') + body=notes(text,version) + if not body and backfill: + body=notes(git(root,'show','HEAD:CHANGELOG.md'),version) + if '目标提交:'+sha not in body:raise ValueError('backfilled notes must bind the exact historic SHA') + if not body:raise ValueError('this development version has no independent CHANGELOG entry') + if not re.search(r'^### (Added|Changed|Fixed|Security)\s*$',body,re.M):raise ValueError('snapshot notes have no change category') + return {'commit_sha':sha,'display_version':version,'tag':'snapshot/v'+version,'message':'Development snapshot '+version+'\n\n'+body,'backfilled':bool(backfill)} +def publish(root,p): + # Only explicitly named development refs are written. Existing refs, including + # legacy misnumbered snapshots, are never moved or overwritten. + tag=p['tag'];sha=p['commit_sha'];ref='refs/tags/'+tag + remote=git(root,'ls-remote','origin');pairs=[line.split() for line in remote.splitlines() if line.strip()];refs={name:obj for obj,name in pairs} + if ref in refs: + if refs.get(ref+'^{}')!=sha:raise ValueError('remote snapshot already exists with different or non-annotated identity') + return {'tag':tag,'commit_sha':sha,'state':'already_published'} + reachable=False + for obj,name in pairs: + if name.startswith('refs/heads/') and subprocess.run(['git','merge-base','--is-ancestor',sha,obj],cwd=root,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:reachable=True;break + if not reachable:raise ValueError('snapshot target has not been pushed to a remote branch') + exists=subprocess.run(['git','show-ref','--verify','--quiet',ref],cwd=root).returncode==0 + if exists: + if git(root,'cat-file','-t',ref)!='tag' or git(root,'rev-parse',ref+'^{}')!=sha:raise ValueError('local snapshot conflicts with requested identity') + if git(root,'for-each-ref','--format=%(contents)',ref).strip()!=p['message'].strip():raise ValueError('existing snapshot annotation differs; will not overwrite') + else: + with tempfile.NamedTemporaryFile(mode='w',prefix='kg-snapshot-note-',encoding='utf-8') as f: + f.write(p['message']+'\n');f.flush();git(root,'tag','-a',tag,sha,'-F',f.name) + git(root,'push','origin',ref+':'+ref) + readback={line.split()[1]:line.split()[0] for line in git(root,'ls-remote','--tags','origin',ref,ref+'^{}').splitlines()} + if readback.get(ref+'^{}')!=sha or readback.get(ref)!=git(root,'rev-parse',ref):raise RuntimeError('remote snapshot read-back failed') + return {'tag':tag,'commit_sha':sha,'state':'published_and_read_back'} +def self_test(): + with tempfile.TemporaryDirectory(prefix='kg-snapshot-test-') as d: + r=Path(d);git(r,'init','-b','main');git(r,'config','user.name','Fixture');git(r,'config','user.email','fixture@example.invalid') + base='# Changelog\n\n## [0.1.0] - 2026-10-13\n\n### Added\n\n- fixture baseline\n' + (r/'CHANGELOG.md').write_text(base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','feat: fixture baseline');git(r,'tag','-a','v0.1.0','-m','fixture') + (r/'CHANGELOG.md').write_text('## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n### Fixed\n\n- specific fixture fix\n\n'+base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','fix: fixture change') + sha=git(r,'rev-parse','HEAD');before=git(r,'tag');p=plan(r,sha) + assert p['tag']=='snapshot/v0.1.1' and 'specific fixture fix' in p['message'] + assert before==git(r,'tag'),'planning wrote a tag' + try:plan(r,'a'*7) + except ValueError:pass + else:raise AssertionError('short SHA accepted') + bare=r/'remote.git';git(r,'init','--bare',str(bare));git(r,'remote','add','origin',str(bare));git(r,'push','origin','main') + out=publish(r,p);assert out['state']=='published_and_read_back' + assert git(r,'rev-parse',p['tag']+'^{}')==sha + assert publish(r,p)['state']=='already_published' + wrong=dict(p,commit_sha='a'*40) + try:publish(r,wrong) + except ValueError:pass + else:raise AssertionError('conflicting remote snapshot overwritten') + assert git(r,'tag','--list','v*').splitlines()==['v0.1.0'],'formal tags changed' + print('development snapshot fixture passed; real repository tags untouched') +def main(): + a=argparse.ArgumentParser();a.add_argument('--sha');a.add_argument('--backfill',action='store_true');a.add_argument('--publish',action='store_true');a.add_argument('--self-test',action='store_true');o=a.parse_args() + if o.self_test:return self_test() + p=plan(ROOT,o.sha,o.backfill) + print(json.dumps(publish(ROOT,p) if o.publish else p,ensure_ascii=False)) +if __name__=='__main__':main() diff --git a/web/src/VersionHistory.test.tsx b/web/src/VersionHistory.test.tsx index 9e139ef..516e22d 100644 --- a/web/src/VersionHistory.test.tsx +++ b/web/src/VersionHistory.test.tsx @@ -98,6 +98,13 @@ const render = (over: Partial[0]> = {}) => ) describe('VersionHistoryView', () => { + it('renders the exact development changelog instead of hiding all non-release notes', () => { + const html = render({ + expanded: developmentItem.commit_sha, + page: { ...page, items: [{ ...developmentItem, release_notes: '这次开发独有的变更说明' }] }, + }) + expect(html).toContain('这次开发独有的变更说明') + }) it('lists commits newest-first with kind badges, short sha, times and links', () => { const html = render() expect(html).toContain('版本记录') diff --git a/web/src/VersionHistory.tsx b/web/src/VersionHistory.tsx index dd09dd8..a3d6b31 100644 --- a/web/src/VersionHistory.tsx +++ b/web/src/VersionHistory.tsx @@ -11,7 +11,7 @@ import { import { useOptionalAuth } from './auth' import './versionHistory.css' -// 「版本记录」页:追踪**所有已进入远端 main 的提交**,按提交时间倒序。 +// 「版本记录」页:追踪远端 main、已交付开发快照及当前运行 SHA 的提交,按提交时间倒序。 // 只读:不提供打 tag / 发布 / 部署按钮;版本号只按证据展示,证据不足就说"历史提交"。 const FILTERS: ReadonlyArray<{ id: string; label: string }> = [ { id: 'all', label: '全部' }, @@ -158,7 +158,7 @@ function TimelineEntry({ }) { const isRelease = item.record_kind === 'release' && item.version_state === 'verified' const heading = isRelease ? item.version_label : item.version_label || `历史提交 · ${item.short_sha}` - const note = isRelease ? item.release_notes : '' + const note = item.version_state === 'verified' ? item.release_notes : '' const hasDetail = Boolean(item.body) || Boolean(note) return (
  • @@ -185,7 +185,7 @@ function TimelineEntry({ {expanded ? (
    - {item.record_kind === 'release' && note ?
    {note}
    : null} + {note ?
    {note}
    : null} {item.body ?
    {item.body}
    : null}
    ) : null}