// Package changelog builds docs/site/changelog.json: every release, the // branches that landed in it, their discussions and docs, and who did the // work. The website renders that file at /platform/changelog. // // Everything here is joining and parsing. The sources (git, the published // release notes, Amadan's discussions) sit behind interfaces so the // joining can be tested without a network, and cmd/changelog wires the // real ones. package changelog import ( "fmt" "regexp" "sort" "strings" ) // People maps an email address, or the keymail an agent account names // after its "~", to the name the page shows. Keys are lower case. type People map[string]string // Credit is one person and the agents that worked with them, or an agent // on its own when no person can be named. type Credit struct { Person string `json:"person,omitempty"` Agents []string `json:"agents,omitempty"` } // CommitIdent is the part of a commit credit is read from. type CommitIdent struct { AuthorName, AuthorEmail, Message string } // ParseIdentity resolves one git identity. skip is true for identities // that never did the work (GitHub's merge button, Amadan's ledger). An // identity that names a person People does not know is an error that // names the address: the page would otherwise have to print it. func ParseIdentity(name, email string, people People) (person, agent string, skip bool, err error) { email = strings.ToLower(strings.TrimSpace(email)) switch email { case "noreply@github.com", "ledger@amadan": return "", "", true, nil case "noreply@anthropic.com": return "", "claude", false, nil } if i := strings.Index(email, "~"); i > 0 { agent = agentName(email[:i]) keymail := email[i+1:] p, ok := people[keymail] if !ok { return "", "", false, fmt.Errorf("changelog: no entry in people.json for %s (operator of %s)", keymail, agent) } return p, agent, false, nil } lname := strings.ToLower(strings.TrimSpace(name)) switch { case strings.HasPrefix(lname, "claude"): return "", "claude", false, nil case strings.HasPrefix(lname, "codex"): return "", "codex", false, nil } p, ok := people[email] if !ok { return "", "", false, fmt.Errorf("changelog: no entry in people.json for %s (git author %q)", email, name) } return p, "", false, nil } func agentName(s string) string { if s == "claude-code" { return "claude" } return s } var coAuthorRe = regexp.MustCompile(`(?im)^co-authored-by:\s*(.*?)\s*<([^>]+)>\s*$`) // CoAuthors returns the name and email of each Co-Authored-By trailer. func CoAuthors(message string) [][2]string { var out [][2]string for _, m := range coAuthorRe.FindAllStringSubmatch(message, -1) { out = append(out, [2]string{m[1], m[2]}) } return out } // Tally credits a change's own commits. Each commit's author and // co-authors resolve to people and agents; an agent with no person of its // own (a Co-Authored-By: Claude trailer) works with the commit's person. // People are ordered by how many commits they appear on, then by name; // an agent no person can be found for anywhere stands as its own entry. func Tally(commits []CommitIdent, people People) ([]Credit, error) { type tally struct { count int agents map[string]bool } byPerson := map[string]*tally{} loose := map[string]bool{} credit := func(p string, agents ...string) { t := byPerson[p] if t == nil { t = &tally{agents: map[string]bool{}} byPerson[p] = t } for _, a := range agents { if a != "" { t.agents[a] = true } } } for _, c := range commits { type ident struct{ person, agent string } var ids []ident for _, id := range append([][2]string{{c.AuthorName, c.AuthorEmail}}, CoAuthors(c.Message)...) { p, a, skip, err := ParseIdentity(id[0], id[1], people) if err != nil { return nil, err } if !skip { ids = append(ids, ident{p, a}) } } if len(ids) == 0 { continue } // Each identity keeps its own pairing: an agent account names its // operator, a person is just a person. An agent with no person of // its own (a Co-Authored-By: Claude trailer) worked with the // commit's author, or failing that the first co-author who is a // person; it is never spread over every person on the commit. var loneAgents []string owner := "" for _, id := range ids { if id.person == "" { loneAgents = append(loneAgents, id.agent) } else if owner == "" { owner = id.person } } if ids[0].person != "" { owner = ids[0].person } counted := map[string]bool{} for _, id := range ids { if id.person == "" { continue } credit(id.person, id.agent) if !counted[id.person] { counted[id.person] = true byPerson[id.person].count++ } } if owner != "" { credit(owner, loneAgents...) } else { for _, a := range loneAgents { loose[a] = true } } } names := make([]string, 0, len(byPerson)) for p := range byPerson { names = append(names, p) } sort.Slice(names, func(i, j int) bool { a, b := byPerson[names[i]], byPerson[names[j]] if a.count != b.count { return a.count > b.count } return names[i] < names[j] }) out := []Credit{} claimed := map[string]bool{} for _, p := range names { out = append(out, Credit{Person: p, Agents: sortedKeys(byPerson[p].agents)}) for a := range byPerson[p].agents { claimed[a] = true } } for _, a := range sortedKeys(loose) { if !claimed[a] { out = append(out, Credit{Agents: []string{a}}) } } return out, nil } func sortedKeys(m map[string]bool) []string { if len(m) == 0 { return nil } out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out }