aboutsummaryrefslogtreecommitdiff
path: root/script/authors.go
blob: f8e84dd7100708db0e41c8b2a0966a90bcde18e6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
// Copyright (C) 2015 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.

//go:build ignore
// +build ignore

// Generates the list of contributors in gui/index.html based on contents of
// AUTHORS.

package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"math"
	"os"
	"os/exec"
	"regexp"
	"sort"
	"strings"
)

const htmlFile = "gui/default/syncthing/core/aboutModalView.html"

var (
	nicknameRe        = regexp.MustCompile(`\(([^\s]*)\)`)
	emailRe           = regexp.MustCompile(`<([^\s]*)>`)
	authorBotsRegexps = []string{
		`\[bot\]`,
		`Syncthing.*Automation`,
	}
)

var authorBotsRe = regexp.MustCompile(strings.Join(authorBotsRegexps, "|"))

const authorsHeader = `# This is the official list of Syncthing authors for copyright purposes.
#
# THIS FILE IS MOSTLY AUTO GENERATED. IF YOU'VE MADE A COMMIT TO THE
# REPOSITORY YOU WILL BE ADDED HERE AUTOMATICALLY WITHOUT THE NEED FOR
# ANY MANUAL ACTION.
#
# That said, you are welcome to correct your name or add a nickname / GitHub
# user name as appropriate. The format is:
#
#    Name Name Name (nickname) <email1@example.com> <email2@example.com>
#
# The in-GUI authors list is periodically automatically updated from the
# contents of this file.
#
`

type author struct {
	name         string
	nickname     string
	emails       []string
	commits      int
	log10commits int
}

func main() {
	// Read authors from the AUTHORS file
	authors := getAuthors()

	// Grab the set of thus known email addresses
	listed := make(stringSet)
	names := make(map[string]int)
	for i, a := range authors {
		names[a.name] = i
		for _, e := range a.emails {
			listed.add(e)
		}
	}

	// Grab the set of all known authors based on the git log, and add any
	// missing ones to the authors list.
	all := allAuthors()
	for email, name := range all {
		if listed.has(email) {
			continue
		}

		if _, ok := names[name]; ok && name != "" {
			// We found a match on name
			authors[names[name]].emails = append(authors[names[name]].emails, email)
			listed.add(email)
			continue
		}

		authors = append(authors, author{
			name:   name,
			emails: []string{email},
		})
		names[name] = len(authors) - 1
		listed.add(email)
	}

	// Write author names in GUI about modal

	getContributions(authors)
	sort.Sort(byContributions(authors))

	var lines []string
	for _, author := range authors {
		if authorBotsRe.MatchString(author.name) {
			// Only humans are eligible, pending future legislation to the
			// contrary.
			continue
		}
		lines = append(lines, author.name)
	}
	replacement := strings.Join(lines, ", ")

	authorsRe := regexp.MustCompile(`(?s)id="contributor-list">.*?</div>`)
	bs := readAll(htmlFile)
	bs = authorsRe.ReplaceAll(bs, []byte("id=\"contributor-list\">\n"+replacement+"\n          </div>"))

	if err := os.WriteFile(htmlFile, bs, 0644); err != nil {
		log.Fatal(err)
	}

	// Write AUTHORS file

	sort.Sort(byName(authors))

	out, err := os.Create("AUTHORS")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Fprintf(out, "%s\n", authorsHeader)
	for _, author := range authors {
		fmt.Fprintf(out, "%s", author.name)
		if author.nickname != "" {
			fmt.Fprintf(out, " (%s)", author.nickname)
		}
		for _, email := range author.emails {
			fmt.Fprintf(out, " <%s>", email)
		}
		fmt.Fprintf(out, "\n")
	}
	out.Close()
}

func getAuthors() []author {
	bs := readAll("AUTHORS")
	lines := strings.Split(string(bs), "\n")
	var authors []author

	for _, line := range lines {
		if len(line) == 0 || line[0] == '#' {
			continue
		}

		fields := strings.Fields(line)
		var author author
		for _, field := range fields {
			if m := nicknameRe.FindStringSubmatch(field); len(m) > 1 {
				author.nickname = m[1]
			} else if m := emailRe.FindStringSubmatch(field); len(m) > 1 {
				author.emails = append(author.emails, m[1])
			} else {
				if author.name == "" {
					author.name = field
				} else {
					author.name = author.name + " " + field
				}
			}
		}

		authors = append(authors, author)
	}
	return authors
}

func readAll(path string) []byte {
	fd, err := os.Open(path)
	if err != nil {
		log.Fatal(err)
	}
	defer fd.Close()

	bs, err := io.ReadAll(fd)
	if err != nil {
		log.Fatal(err)
	}

	return bs
}

// Add number of commits per author to the author list.
func getContributions(authors []author) {
	buf := new(bytes.Buffer)
	cmd := exec.Command("git", "log", "--pretty=format:%ae")
	cmd.Stdout = buf
	err := cmd.Run()
	if err != nil {
		log.Fatal(err)
	}

next:
	for _, line := range strings.Split(buf.String(), "\n") {
		for i := range authors {
			for _, email := range authors[i].emails {
				if email == line {
					authors[i].commits++
					continue next
				}
			}
		}
	}

	for i := range authors {
		authors[i].log10commits = int(math.Log10(float64(authors[i].commits + 1)))
	}
}

// list of commits that we don't include in our author file; because they
// are legacy things that don't affect code, are committed with incorrect
// address, or for other reasons.
var excludeCommits = stringSetFromStrings([]string{
	"a9339d0627fff439879d157c75077f02c9fac61b",
	"254c63763a3ad42fd82259f1767db526cff94a14",
	"32a76901a91ff0f663db6f0830e0aedec946e4d0",
	"bc7639b0ffcea52b2197efb1c0bb68b338d1c915",
	"9bdcadf6345aba3a939e9e58d85b89dbe9d44bc9",
	"b933e9666abdfcd22919dd458c930d944e1e1b7f",
	"b84d960a81c1282a79e2b9477558de4f1af6faae",
})

// allAuthors returns the set of authors in the git commit log, except those
// in excluded commits.
func allAuthors() map[string]string {
	// Format is hash, email, name, newline, body. The body is indented with
	// one space, to differentiate from the hash lines.
	args := append([]string{"log", "--format=%H %ae %an%n%w(,1,1)%b"})
	cmd := exec.Command("git", args...)
	bs, err := cmd.Output()
	if err != nil {
		log.Fatal("git:", err)
	}

	coAuthoredPrefix := "Co-authored-by: "
	names := make(map[string]string)
	skipCommit := false
	for _, line := range bytes.Split(bs, []byte{'\n'}) {
		if len(line) == 0 {
			continue
		}

		switch line[0] {
		case ' ':
			// Look for Co-authored-by: lines in the commit body.
			if skipCommit {
				continue
			}

			line = line[1:]
			if bytes.HasPrefix(line, []byte(coAuthoredPrefix)) {
				// Co-authored-by: Name Name <email@example.com>
				line = line[len(coAuthoredPrefix):]
				if name, email, ok := strings.Cut(string(line), "<"); ok {
					name = strings.TrimSpace(name)
					email = strings.Trim(strings.TrimSpace(email), "<>")
					if email == "@" {
						// GitHub special for users who hide their email.
						continue
					}
					if names[email] == "" {
						names[email] = name
					}
				}
			}

		default: // hash email name
			fields := strings.SplitN(string(line), " ", 3)
			if len(fields) != 3 {
				continue
			}
			hash, email, name := fields[0], fields[1], fields[2]

			if excludeCommits.has(hash) {
				skipCommit = true
				continue
			}
			skipCommit = false

			if names[email] == "" {
				names[email] = name
			}
		}
	}

	return names
}

type byContributions []author

func (l byContributions) Len() int { return len(l) }

// Sort first by log10(commits), then by name. This means that we first get
// an alphabetic list of people with >= 1000 commits, then a list of people
// with >= 100 commits, and so on.
func (l byContributions) Less(a, b int) bool {
	if l[a].log10commits != l[b].log10commits {
		return l[a].log10commits > l[b].log10commits
	}
	return l[a].name < l[b].name
}

func (l byContributions) Swap(a, b int) { l[a], l[b] = l[b], l[a] }

type byName []author

func (l byName) Len() int { return len(l) }

func (l byName) Less(a, b int) bool {
	aname := strings.ToLower(l[a].name)
	bname := strings.ToLower(l[b].name)
	return aname < bname
}

func (l byName) Swap(a, b int) { l[a], l[b] = l[b], l[a] }

// A simple string set type

type stringSet map[string]struct{}

func stringSetFromStrings(ss []string) stringSet {
	s := make(stringSet)
	for _, e := range ss {
		s.add(e)
	}
	return s
}

func (s stringSet) add(e string) {
	s[e] = struct{}{}
}

func (s stringSet) has(e string) bool {
	_, ok := s[e]
	return ok
}

func (s stringSet) except(other stringSet) stringSet {
	diff := make(stringSet)
	for e := range s {
		if !other.has(e) {
			diff.add(e)
		}
	}
	return diff
}