Files
knowledge-governance/internal/versionhistory/rules.go
T
姚章浩 74ba737b1e
build / build (push) Successful in 1m4s
fix(version): 按完整提交区分同号历史开发说明
历史分支中存在两条开发号 0.1.1;归档选择按 SHA 匹配,保留原编号与冲突标签。
增加同号前置说明遮蔽及错 SHA 不复用回归,标签计划使用相同精确选择方式。
已运行:定向 Go 测试、快照工具临时仓正反例及整仓 make verify;未部署。
2026-10-18 18:18:43 +08:00

228 lines
7.2 KiB
Go

// Package versionhistory 把「远端 main 可达的提交」+「tag 证据」+「提交内 CHANGELOG」
// 换算成版本记录页要的提交记录。
//
// 它**只读、纯计算**:不发网络请求、不写库、不执行任何历史提交里的脚本。版本号口径
// 委托给 internal/governance.DecideVersion,提交数按提交图算(等价 `git rev-list --count <tag>..<sha>`),
// 与 scripts/version.py 同规则 —— 两处算出的号必须一致。
package versionhistory
import (
"sort"
"strings"
"time"
"knowledge-governance/internal/governance"
)
// RawCommit 是一条从 Gitea 读回的提交(只保留展示与判版本所需的字段)。
type RawCommit struct {
SHA string
Parents []string
CommittedAt time.Time
AuthorDisplay string
Subject string
Body string
}
// RawTag 是一个 tag 及其可核验属性;Annotated=false 表示轻量 tag(不算正式版本的证据)。
type RawTag struct {
Message string
Name string
TagSHA string
TargetSHA string
Annotated bool
}
// ChangelogSource 读「某个提交内的 CHANGELOG.md」;读不到返回错误(调用方按"无记录"处理)。
type ChangelogSource func(sha string) (string, error)
// DeriveInput 是一次换算的全部输入。
type DeriveInput struct {
DeliveryArchive string
RepoKey string
CommitURLBase string // 形如 "<origin>/<owner>/<repo>/commit/",由配置拼好,不接受外部传入
TagURLBase string // 形如 "<origin>/<owner>/<repo>/src/tag/";空则不下发 tag 链接
Commits []RawCommit
Tags []RawTag
Changelog ChangelogSource
SnapshotID int64
}
// DeriveResult 是换算结果。
type DeriveResult struct {
Records []governance.VersionHistoryCommit
GraphComplete bool
MissingParents []string
}
// DeriveVersions 逐提交判定版本号(含合并提交与侧分支提交;不展示任何未推送的本地提交,
// 因为输入本身就是远端 main 可达集合)。
func DeriveVersions(in DeriveInput) DeriveResult {
bySHA := make(map[string]RawCommit, len(in.Commits))
for _, commit := range in.Commits {
bySHA[commit.SHA] = commit
}
missing := map[string]bool{}
ancestors := make(map[string]map[string]struct{}, len(in.Commits))
var walk func(sha string) map[string]struct{}
walk = func(sha string) map[string]struct{} {
if set, ok := ancestors[sha]; ok {
return set
}
set := map[string]struct{}{sha: {}}
ancestors[sha] = set // 先占位,DAG 不会有环
commit, ok := bySHA[sha]
if !ok {
return set
}
for _, parent := range commit.Parents {
if _, known := bySHA[parent]; !known {
missing[parent] = true
continue
}
for item := range walk(parent) {
set[item] = struct{}{}
}
}
return set
}
for _, commit := range in.Commits {
walk(commit.SHA)
}
tagsByTarget := map[string][]RawTag{}
tagByName := map[string]RawTag{}
for _, tag := range in.Tags {
tagsByTarget[tag.TargetSHA] = append(tagsByTarget[tag.TargetSHA], tag)
if _, exists := tagByName[tag.Name]; !exists {
tagByName[tag.Name] = tag
}
}
changelogCache := map[string]string{}
readChangelog := func(sha string) string {
if text, ok := changelogCache[sha]; ok {
return text
}
text := ""
if in.Changelog != nil {
if loaded, err := in.Changelog(sha); err == nil {
text = loaded
}
}
changelogCache[sha] = text
return text
}
graphComplete := len(missing) == 0
result := DeriveResult{GraphComplete: graphComplete}
for sha := range missing {
result.MissingParents = append(result.MissingParents, sha)
}
sort.Strings(result.MissingParents)
records := make([]governance.VersionHistoryCommit, 0, len(in.Commits))
for _, commit := range in.Commits {
changelog := readChangelog(commit.SHA)
top := governance.ChangelogTopVersion(changelog)
baseTagName := ""
var sinceBase *int
tagCreatedLater := false
if governance.SemverValid(top) {
candidate := "v" + top
if base, exists := tagByName[candidate]; exists {
own, ownKnown := ancestors[commit.SHA]
_, tagIsAncestor := own[base.TargetSHA]
switch {
case !graphComplete:
// tag 存在但图不完整 ⇒ 不给号(宁可没号,也不报错号)
baseTagName = candidate
case !ownKnown:
// 连自己的祖先都给不出 ⇒ 不给号
case !tagIsAncestor:
// **该 tag 是更晚才打在这个提交之后的**:这个提交当时还没有发布基线。
// (提交数在这里算会得到 0 —— 因为它的祖先全被那个更晚的 tag 覆盖 —— 拿它编号是错的。)
tagCreatedLater = true
default:
baseSet := ancestors[base.TargetSHA]
count := 0
for item := range own {
if _, inBase := baseSet[item]; !inBase {
count++
}
}
baseTagName = candidate
sinceBase = &count
}
}
}
evidenceTags := make([]governance.VersionTagEvidence, 0, len(tagsByTarget[commit.SHA]))
for _, tag := range tagsByTarget[commit.SHA] {
url := ""
if in.TagURLBase != "" {
url = in.TagURLBase + tag.Name
}
evidenceTags = append(evidenceTags, governance.VersionTagEvidence{
Name: tag.Name, TagSHA: tag.TagSHA, Annotated: tag.Annotated, URL: url})
}
sort.Slice(evidenceTags, func(i, j int) bool { return evidenceTags[i].Name < evidenceTags[j].Name })
label, state, kind, evidence := governance.DecideVersion(top, evidenceTags, baseTagName, sinceBase)
switch {
case label != "":
case !graphComplete:
evidence.Reason = governance.ReasonIncompleteGraph
case tagCreatedLater:
evidence.Reason = governance.ReasonTagCreatedLater
}
record := governance.VersionHistoryCommit{
RepoKey: in.RepoKey,
CommitSHA: commit.SHA,
Parents: commit.Parents,
CommittedAt: commit.CommittedAt,
AuthorDisplay: commit.AuthorDisplay,
Subject: commit.Subject,
Body: commit.Body,
VersionLabel: label,
VersionState: state,
RecordKind: kind,
ReleaseTags: evidenceTags,
CommitURL: in.CommitURLBase + commit.SHA,
VersionEvidence: evidence,
SnapshotID: in.SnapshotID,
}
if kind == governance.RecordKindRelease {
record.ReleaseNotes = governance.ExtractReleaseNotes(changelog, label)
} else if kind == governance.RecordKindDevelopment {
record.ReleaseNotes = governance.ExtractDevelopmentNotes(changelog, label)
if record.ReleaseNotes == "" {
record.ReleaseNotes = governance.ExtractDevelopmentNotesForCommit(in.DeliveryArchive, label, commit.SHA)
}
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)
}
// 最新在上;同一提交时间用 SHA 兜底 ⇒ 排序确定,分页不重不漏。
sort.SliceStable(records, func(i, j int) bool {
if !records[i].CommittedAt.Equal(records[j].CommittedAt) {
return records[i].CommittedAt.After(records[j].CommittedAt)
}
return records[i].CommitSHA > records[j].CommitSHA
})
result.Records = records
return result
}