authors.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. // Copyright (C) 2015 The Syncthing Authors.
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this file,
  5. // You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //go:build ignore
  7. // +build ignore
  8. // Generates the list of contributors in gui/index.html based on contents of
  9. // AUTHORS.
  10. package main
  11. import (
  12. "bytes"
  13. "cmp"
  14. "fmt"
  15. "log"
  16. "math"
  17. "os"
  18. "os/exec"
  19. "regexp"
  20. "slices"
  21. "strings"
  22. )
  23. const htmlFile = "gui/default/syncthing/core/aboutModalView.html"
  24. var (
  25. nicknameRe = regexp.MustCompile(`\(([^\s]*)\)`)
  26. emailRe = regexp.MustCompile(`<([^\s]*)>`)
  27. authorBotsRegexps = []string{
  28. `\[bot\]`,
  29. `Syncthing.*Automation`,
  30. }
  31. )
  32. var authorBotsRe = regexp.MustCompile(strings.Join(authorBotsRegexps, "|"))
  33. const authorsHeader = `# This is the official list of Syncthing authors for copyright purposes.
  34. #
  35. # THIS FILE IS MOSTLY AUTO GENERATED. IF YOU'VE MADE A COMMIT TO THE
  36. # REPOSITORY YOU WILL BE ADDED HERE AUTOMATICALLY WITHOUT THE NEED FOR
  37. # ANY MANUAL ACTION.
  38. #
  39. # That said, you are welcome to correct your name or add a nickname / GitHub
  40. # user name as appropriate. The format is:
  41. #
  42. # Name Name Name (nickname) <[email protected]> <[email protected]>
  43. #
  44. # The in-GUI authors list is periodically automatically updated from the
  45. # contents of this file.
  46. #
  47. `
  48. type author struct {
  49. name string
  50. nickname string
  51. emails []string
  52. commits int
  53. log10commits int
  54. }
  55. func main() {
  56. // Read authors from the AUTHORS file
  57. authorSet := getAuthors()
  58. // Grab the set of all known authors based on the git log, and add any
  59. // missing ones to the authors list.
  60. addAuthors(authorSet)
  61. authors := authorSet.filteredAuthors()
  62. // Write authors to the about dialog
  63. slices.SortFunc(authors, func(a, b author) int {
  64. return cmp.Or(
  65. -cmp.Compare(a.log10commits, b.log10commits),
  66. cmp.Compare(strings.ToLower(a.name), strings.ToLower(b.name)))
  67. })
  68. var lines []string
  69. for _, author := range authors {
  70. lines = append(lines, author.name)
  71. }
  72. replacement := strings.Join(lines, ", ")
  73. authorsRe := regexp.MustCompile(`(?s)id="contributor-list">.*?</div>`)
  74. bs, err := os.ReadFile(htmlFile)
  75. if err != nil {
  76. log.Fatal(err)
  77. }
  78. bs = authorsRe.ReplaceAll(bs, []byte("id=\"contributor-list\">\n"+replacement+"\n </div>"))
  79. if err := os.WriteFile(htmlFile, bs, 0o644); err != nil {
  80. log.Fatal(err)
  81. }
  82. // Write AUTHORS file
  83. out, err := os.Create("AUTHORS")
  84. if err != nil {
  85. log.Fatal(err)
  86. }
  87. fmt.Fprintf(out, "%s\n", authorsHeader)
  88. for _, author := range authors {
  89. fmt.Fprintf(out, "%s", author.name)
  90. if author.nickname != "" {
  91. fmt.Fprintf(out, " (%s)", author.nickname)
  92. }
  93. for _, email := range author.emails {
  94. fmt.Fprintf(out, " <%s>", email)
  95. }
  96. fmt.Fprint(out, "\n")
  97. }
  98. out.Close()
  99. }
  100. func getAuthors() *authorSet {
  101. bs, err := os.ReadFile("AUTHORS")
  102. if err != nil {
  103. log.Fatal(err)
  104. }
  105. lines := strings.Split(string(bs), "\n")
  106. authors := &authorSet{
  107. emails: make(map[string]int),
  108. commits: make(map[string]stringSet),
  109. }
  110. for _, line := range lines {
  111. if len(line) == 0 || line[0] == '#' {
  112. continue
  113. }
  114. fields := strings.Fields(line)
  115. var author author
  116. for _, field := range fields {
  117. if field == "#" {
  118. break
  119. } else if m := nicknameRe.FindStringSubmatch(field); len(m) > 1 {
  120. author.nickname = m[1]
  121. } else if m := emailRe.FindStringSubmatch(field); len(m) > 1 {
  122. author.emails = append(author.emails, m[1])
  123. } else {
  124. if author.name == "" {
  125. author.name = field
  126. } else {
  127. author.name = author.name + " " + field
  128. }
  129. }
  130. }
  131. authors.add(author)
  132. }
  133. return authors
  134. }
  135. // list of commits that we don't include in our author file; because they
  136. // are legacy things that don't affect code, are committed with incorrect
  137. // address, or for other reasons.
  138. var excludeCommits = stringSetFromStrings([]string{
  139. "a9339d0627fff439879d157c75077f02c9fac61b",
  140. "254c63763a3ad42fd82259f1767db526cff94a14",
  141. "32a76901a91ff0f663db6f0830e0aedec946e4d0",
  142. "bc7639b0ffcea52b2197efb1c0bb68b338d1c915",
  143. "9bdcadf6345aba3a939e9e58d85b89dbe9d44bc9",
  144. "b933e9666abdfcd22919dd458c930d944e1e1b7f",
  145. "b84d960a81c1282a79e2b9477558de4f1af6faae",
  146. "4dfb9d7c83ed172f12ae19408517961f4a49beeb",
  147. })
  148. func addAuthors(authors *authorSet) {
  149. // All existing source-tracked files
  150. bs, err := exec.Command("git", "ls-tree", "-r", "HEAD", "--name-only").CombinedOutput()
  151. if err != nil {
  152. fmt.Println(string(bs))
  153. log.Fatal("git ls-tree:", err)
  154. }
  155. files := strings.Split(string(bs), "\n")
  156. files = slices.DeleteFunc(files, func(s string) bool {
  157. return !(strings.HasPrefix(s, "assets/") ||
  158. strings.HasPrefix(s, "cmd/") ||
  159. strings.HasPrefix(s, "etc/") ||
  160. strings.HasPrefix(s, "gui/") ||
  161. strings.HasPrefix(s, "internal/") ||
  162. strings.HasPrefix(s, "lib/") ||
  163. strings.HasPrefix(s, "proto/") ||
  164. strings.HasPrefix(s, "script/") ||
  165. strings.HasPrefix(s, "test/") ||
  166. strings.HasPrefix(s, "Dockerfile") ||
  167. s == "build.go")
  168. })
  169. coAuthoredPrefix := "Co-authored-by: "
  170. for _, file := range files {
  171. // All commits affecting those files, following any renames to their
  172. // origin. Format is hash, email, name, newline, body. The body is
  173. // indented with one space, to differentiate from the hash lines.
  174. args := []string{"log", "--format=%H %ae %an%n%w(,1,1)%b", "--follow", "--", file}
  175. bs, err = exec.Command("git", args...).CombinedOutput()
  176. if err != nil {
  177. fmt.Println(string(bs))
  178. log.Fatal("git log:", err)
  179. }
  180. skipCommit := false
  181. var hash, email, name string
  182. for _, line := range bytes.Split(bs, []byte{'\n'}) {
  183. if len(line) == 0 {
  184. continue
  185. }
  186. switch line[0] {
  187. case ' ':
  188. // Look for Co-authored-by: lines in the commit body.
  189. if skipCommit {
  190. continue
  191. }
  192. line = line[1:]
  193. if bytes.HasPrefix(line, []byte(coAuthoredPrefix)) {
  194. // Co-authored-by: Name Name <[email protected]>
  195. line = line[len(coAuthoredPrefix):]
  196. if name, email, ok := strings.Cut(string(line), "<"); ok {
  197. name = strings.TrimSpace(name)
  198. email = strings.Trim(strings.TrimSpace(email), "<>")
  199. if email == "@" {
  200. // GitHub special for users who hide their email.
  201. continue
  202. }
  203. authors.setName(email, name)
  204. authors.addCommit(email, hash)
  205. }
  206. }
  207. default: // hash email name
  208. fields := strings.SplitN(string(line), " ", 3)
  209. if len(fields) != 3 {
  210. continue
  211. }
  212. hash, email, name = fields[0], fields[1], fields[2]
  213. if excludeCommits.has(hash) {
  214. skipCommit = true
  215. continue
  216. }
  217. skipCommit = false
  218. authors.setName(email, name)
  219. authors.addCommit(email, hash)
  220. }
  221. }
  222. }
  223. }
  224. // A simple string set type
  225. type stringSet map[string]struct{}
  226. func stringSetFromStrings(ss []string) stringSet {
  227. s := make(stringSet)
  228. for _, e := range ss {
  229. s.add(e)
  230. }
  231. return s
  232. }
  233. func (s stringSet) add(e string) {
  234. s[e] = struct{}{}
  235. }
  236. func (s stringSet) has(e string) bool {
  237. _, ok := s[e]
  238. return ok
  239. }
  240. // A set of authors
  241. type authorSet struct {
  242. authors []author
  243. emails map[string]int // email to author index
  244. commits map[string]stringSet // email to commit hashes
  245. }
  246. func (a *authorSet) add(author author) {
  247. for _, e := range author.emails {
  248. if idx, ok := a.emails[e]; ok {
  249. emails := append(author.emails, a.authors[idx].emails...)
  250. slices.Sort(emails)
  251. emails = slices.Compact(emails)
  252. a.authors[idx].name = author.name
  253. a.authors[idx].emails = emails
  254. for _, e := range emails {
  255. a.emails[e] = idx
  256. }
  257. return
  258. }
  259. }
  260. for _, e := range author.emails {
  261. a.emails[e] = len(a.authors)
  262. }
  263. a.authors = append(a.authors, author)
  264. }
  265. func (a *authorSet) setName(email, name string) {
  266. idx, ok := a.emails[email]
  267. if !ok {
  268. a.emails[email] = len(a.authors)
  269. a.authors = append(a.authors, author{name: name, emails: []string{email}})
  270. } else if a.authors[idx].name == "" {
  271. a.authors[idx].name = name
  272. }
  273. }
  274. func (a *authorSet) addCommit(email, hash string) {
  275. ss, ok := a.commits[email]
  276. if !ok {
  277. ss = make(stringSet)
  278. a.commits[email] = ss
  279. }
  280. ss.add(hash)
  281. }
  282. func (a *authorSet) filteredAuthors() []author {
  283. authors := make([]author, len(a.authors))
  284. copy(authors, a.authors)
  285. for i, author := range authors {
  286. for _, e := range author.emails {
  287. authors[i].commits += len(a.commits[e])
  288. }
  289. }
  290. authors = slices.DeleteFunc(authors, func(a author) bool {
  291. return a.commits == 0 || authorBotsRe.MatchString(a.name)
  292. })
  293. for i := range authors {
  294. authors[i].log10commits = int(math.Log10(float64(authors[i].commits)))
  295. }
  296. return authors
  297. }