carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package changelog builds docs/site/changelog.json: every release, the
2// branches that landed in it, their discussions and docs, and who did the
3// work. The website renders that file at /platform/changelog.
4//
5// Everything here is joining and parsing. The sources (git, the published
6// release notes, Amadan's discussions) sit behind interfaces so the
7// joining can be tested without a network, and cmd/changelog wires the
8// real ones.
9package changelog
10
11import (
12 "fmt"
13 "regexp"
14 "sort"
15 "strings"
16)
17
18// People maps an email address, or the keymail an agent account names
19// after its "~", to the name the page shows. Keys are lower case.
20type People map[string]string
21
22// Credit is one person and the agents that worked with them, or an agent
23// on its own when no person can be named.
24type Credit struct {
25 Person string `json:"person,omitempty"`
26 Agents []string `json:"agents,omitempty"`
27}
28
29// CommitIdent is the part of a commit credit is read from.
30type CommitIdent struct {
31 AuthorName, AuthorEmail, Message string
32}
33
34// ParseIdentity resolves one git identity. skip is true for identities
35// that never did the work (GitHub's merge button, Amadan's ledger). An
36// identity that names a person People does not know is an error that
37// names the address: the page would otherwise have to print it.
38func ParseIdentity(name, email string, people People) (person, agent string, skip bool, err error) {
39 email = strings.ToLower(strings.TrimSpace(email))
40 switch email {
41 case "noreply@github.com", "ledger@amadan":
42 return "", "", true, nil
43 case "noreply@anthropic.com":
44 return "", "claude", false, nil
45 }
46 if i := strings.Index(email, "~"); i > 0 {
47 agent = agentName(email[:i])
48 keymail := email[i+1:]
49 p, ok := people[keymail]
50 if !ok {
51 return "", "", false, fmt.Errorf("changelog: no entry in people.json for %s (operator of %s)", keymail, agent)
52 }
53 return p, agent, false, nil
54 }
55 lname := strings.ToLower(strings.TrimSpace(name))
56 switch {
57 case strings.HasPrefix(lname, "claude"):
58 return "", "claude", false, nil
59 case strings.HasPrefix(lname, "codex"):
60 return "", "codex", false, nil
61 }
62 p, ok := people[email]
63 if !ok {
64 return "", "", false, fmt.Errorf("changelog: no entry in people.json for %s (git author %q)", email, name)
65 }
66 return p, "", false, nil
67}
68
69func agentName(s string) string {
70 if s == "claude-code" {
71 return "claude"
72 }
73 return s
74}
75
76var coAuthorRe = regexp.MustCompile(`(?im)^co-authored-by:\s*(.*?)\s*<([^>]+)>\s*$`)
77
78// CoAuthors returns the name and email of each Co-Authored-By trailer.
79func CoAuthors(message string) [][2]string {
80 var out [][2]string
81 for _, m := range coAuthorRe.FindAllStringSubmatch(message, -1) {
82 out = append(out, [2]string{m[1], m[2]})
83 }
84 return out
85}
86
87// Tally credits a change's own commits. Each commit's author and
88// co-authors resolve to people and agents; an agent with no person of its
89// own (a Co-Authored-By: Claude trailer) works with the commit's person.
90// People are ordered by how many commits they appear on, then by name;
91// an agent no person can be found for anywhere stands as its own entry.
92func Tally(commits []CommitIdent, people People) ([]Credit, error) {
93 type tally struct {
94 count int
95 agents map[string]bool
96 }
97 byPerson := map[string]*tally{}
98 loose := map[string]bool{}
99 credit := func(p string, agents ...string) {
100 t := byPerson[p]
101 if t == nil {
102 t = &tally{agents: map[string]bool{}}
103 byPerson[p] = t
104 }
105 for _, a := range agents {
106 if a != "" {
107 t.agents[a] = true
108 }
109 }
110 }
111 for _, c := range commits {
112 type ident struct{ person, agent string }
113 var ids []ident
114 for _, id := range append([][2]string{{c.AuthorName, c.AuthorEmail}}, CoAuthors(c.Message)...) {
115 p, a, skip, err := ParseIdentity(id[0], id[1], people)
116 if err != nil {
117 return nil, err
118 }
119 if !skip {
120 ids = append(ids, ident{p, a})
121 }
122 }
123 if len(ids) == 0 {
124 continue
125 }
126 // Each identity keeps its own pairing: an agent account names its
127 // operator, a person is just a person. An agent with no person of
128 // its own (a Co-Authored-By: Claude trailer) worked with the
129 // commit's author, or failing that the first co-author who is a
130 // person; it is never spread over every person on the commit.
131 var loneAgents []string
132 owner := ""
133 for _, id := range ids {
134 if id.person == "" {
135 loneAgents = append(loneAgents, id.agent)
136 } else if owner == "" {
137 owner = id.person
138 }
139 }
140 if ids[0].person != "" {
141 owner = ids[0].person
142 }
143 counted := map[string]bool{}
144 for _, id := range ids {
145 if id.person == "" {
146 continue
147 }
148 credit(id.person, id.agent)
149 if !counted[id.person] {
150 counted[id.person] = true
151 byPerson[id.person].count++
152 }
153 }
154 if owner != "" {
155 credit(owner, loneAgents...)
156 } else {
157 for _, a := range loneAgents {
158 loose[a] = true
159 }
160 }
161 }
162 names := make([]string, 0, len(byPerson))
163 for p := range byPerson {
164 names = append(names, p)
165 }
166 sort.Slice(names, func(i, j int) bool {
167 a, b := byPerson[names[i]], byPerson[names[j]]
168 if a.count != b.count {
169 return a.count > b.count
170 }
171 return names[i] < names[j]
172 })
173 out := []Credit{}
174 claimed := map[string]bool{}
175 for _, p := range names {
176 out = append(out, Credit{Person: p, Agents: sortedKeys(byPerson[p].agents)})
177 for a := range byPerson[p].agents {
178 claimed[a] = true
179 }
180 }
181 for _, a := range sortedKeys(loose) {
182 if !claimed[a] {
183 out = append(out, Credit{Agents: []string{a}})
184 }
185 }
186 return out, nil
187}
188
189func sortedKeys(m map[string]bool) []string {
190 if len(m) == 0 {
191 return nil
192 }
193 out := make([]string, 0, len(m))
194 for k := range m {
195 out = append(out, k)
196 }
197 sort.Strings(out)
198 return out
199}
200