From 1f616240dbd0297d4c7d21169e5ce25f4d27bb95 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Mon, 13 Jul 2026 03:41:40 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Initial=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 70 ++++++++++++ .gitignore | 20 ++++ LICENSE | 21 ++++ README.md | 33 ++++++ go.mod | 16 +++ go.sum | 14 +++ internal/github/github.go | 132 +++++++++++++++++++++ internal/report/report.go | 89 +++++++++++++++ internal/report/template.html | Bin 0 -> 12617 bytes internal/scanner/scanner.go | 139 ++++++++++++++++++++++ internal/scanner/scanner_test.go | 86 ++++++++++++++ main.go | 190 +++++++++++++++++++++++++++++++ 12 files changed, 810 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/github/github.go create mode 100644 internal/report/report.go create mode 100644 internal/report/template.html create mode 100644 internal/scanner/scanner.go create mode 100644 internal/scanner/scanner_test.go create mode 100644 main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2f22c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*.*.*"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: "1.26.4" + - run: go build ./... + - run: go vet ./... + - run: go test ./... + + build: + needs: test + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: "1.26.4" + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + ext="" + if [ "$GOOS" = "windows" ]; then ext=".exe"; fi + mkdir -p dist + go build -o "dist/git-identity-audit-${GOOS}-${GOARCH}${ext}" . + - uses: actions/upload-artifact@v5 + with: + name: git-identity-audit-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/* + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v6 + with: + path: dist + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82585df --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Build artifacts +/git-identity-audit +*.exe +*.test +*.out + +# Generated reports +git-identity-audit-*.html + +# Go tooling +/vendor/ + +# Editor/OS +.DS_Store +.idea/ +.vscode/ + +# Output +*.html +!template.html diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc37922 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yuzu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..993bfe1 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# 🔍 Git Identity Audit + +Scans every repository a GitHub personal access token can see and extracts +every contributor name/email identity found in commit metadata and +commit-message trailers (`Co-authored-by`, `Signed-off-by`, etc.), then +writes an interactive HTML report. + +## Usage + +```sh +go run . [-token ] [-output ] [-concurrency ] +``` + +- `-token` - GitHub personal access token. Falls back to the `GITHUB_TOKEN` + env var, then an interactive prompt. +- `-output` - path to write the HTML report (default + `git-identity-audit-.html`). +- `-concurrency` - max number of repos to clone/scan in parallel (default `10`). + +The token needs read access to whichever repos you want scanned (owned, +collaborator, and organization-member repos are all included). + +## Build + +```sh +go build ./... +``` + +## Releases + +Prebuilt binaries for linux/darwin/windows (amd64+arm64) are published to +[GitHub Releases](https://github.com/YuzuZensai/Git-Identity-Audit/releases) +whenever a tag is pushed: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c48fa32 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/YuzuZensai/Git-Identity-Audit + +go 1.26.4 + +require ( + github.com/vbauerster/mpb/v8 v8.12.1 + golang.org/x/term v0.45.0 +) + +require ( + github.com/VividCortex/ewma v1.2.0 // indirect + github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..db68da0 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/vbauerster/mpb/v8 v8.12.1 h1:pyj3yQ2ZGQJgUXm4h17QpR+eERaNz5OQ1ftPSEE/sMM= +github.com/vbauerster/mpb/v8 v8.12.1/go.mod h1:XLXRfStkw/6i5k0aQltijDHT1Z93fD1DVwmIdcFUp6k= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..eee8812 --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,132 @@ +package github + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +const apiBase = "https://api.github.com" + +type Client struct { + token string + http *http.Client +} + +func NewClient(token string) *Client { + return &Client{ + token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +type Repo struct { + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + Private bool `json:"private"` +} + +type Event struct { + Type string `json:"type"` + CreatedAt string `json:"created_at"` + Actor struct { + Login string `json:"login"` + } `json:"actor"` + Repo struct { + Name string `json:"name"` + } `json:"repo"` + Payload struct { + Ref string `json:"ref"` + Head string `json:"head"` + Before string `json:"before"` + } `json:"payload"` +} + +func (c *Client) CurrentUserLogin() (string, error) { + var user struct { + Login string `json:"login"` + } + if _, err := c.get(apiBase+"/user", &user); err != nil { + return "", err + } + return user.Login, nil +} + +func (c *Client) PublicEvents(login string) ([]Event, error) { + var all []Event + url := apiBase + "/users/" + login + "/events/public?per_page=100" + for url != "" { + var page []Event + next, err := c.get(url, &page) + if err != nil { + return nil, err + } + all = append(all, page...) + url = next + } + return all, nil +} + +func (c *Client) ListAccessibleRepos() ([]Repo, error) { + var all []Repo + url := apiBase + "/user/repos?affiliation=owner,collaborator,organization_member&per_page=100" + for url != "" { + var page []Repo + next, err := c.get(url, &page) + if err != nil { + return nil, err + } + all = append(all, page...) + url = next + } + return all, nil +} + +func (c *Client) get(url string, out any) (string, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden && resp.Header.Get("X-RateLimit-Remaining") == "0" { + reset := resp.Header.Get("X-RateLimit-Reset") + return "", fmt.Errorf("rate limited by GitHub API, resets at unix time %s", reset) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API request to %s failed: %s", url, resp.Status) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return "", fmt.Errorf("decoding response from %s: %w", url, err) + } + + return parseNextLink(resp.Header.Get("Link")), nil +} + +func parseNextLink(header string) string { + if header == "" { + return "" + } + for _, part := range strings.Split(header, ",") { + segments := strings.Split(part, ";") + if len(segments) < 2 { + continue + } + if strings.TrimSpace(segments[1]) == `rel="next"` { + url := strings.TrimSpace(segments[0]) + return strings.Trim(url, "<>") + } + } + return "" +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..d20cb7e --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,89 @@ +package report + +import ( + _ "embed" + "encoding/json" + "fmt" + "html/template" + "os" + "sort" + "time" + + "github.com/YuzuZensai/Git-Identity-Audit/internal/github" + "github.com/YuzuZensai/Git-Identity-Audit/internal/scanner" +) + +//go:embed template.html +var templateHTML string + +var htmlTemplate = template.Must(template.New("report").Parse(templateHTML)) + +func WriteHTML(path string, entries []scanner.Entry, events []github.Event) error { + dataJSON, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("marshaling entries: %w", err) + } + eventsJSON, err := json.Marshal(events) + if err != nil { + return fmt.Errorf("marshaling events: %w", err) + } + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("creating %s: %w", path, err) + } + defer f.Close() + + return htmlTemplate.Execute(f, struct { + DataJSON template.JS + EventsJSON template.JS + GeneratedAt string + }{ + DataJSON: template.JS(dataJSON), + EventsJSON: template.JS(eventsJSON), + GeneratedAt: time.Now().Format(time.RFC1123), + }) +} + +type summaryRow struct { + Name string + Email string + Count int + RepoCounts map[string]int +} + +func PrintSummary(entries []scanner.Entry) { + rows := map[string]*summaryRow{} + for _, e := range entries { + key := e.Name + "\x00" + e.Email + row, ok := rows[key] + if !ok { + row = &summaryRow{Name: e.Name, Email: e.Email, RepoCounts: map[string]int{}} + rows[key] = row + } + row.Count++ + row.RepoCounts[e.Repo]++ + } + + sorted := make([]*summaryRow, 0, len(rows)) + for _, r := range rows { + sorted = append(sorted, r) + } + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Count > sorted[j].Count }) + + fmt.Printf("\n%d unique identities found:\n\n", len(sorted)) + for i, r := range sorted { + repoNames := make([]string, 0, len(r.RepoCounts)) + for repo := range r.RepoCounts { + repoNames = append(repoNames, repo) + } + sort.Strings(repoNames) + + fmt.Printf("%3d. %s <%s>\n", i+1, r.Name, r.Email) + fmt.Printf(" %d commits across %d repo(s):\n", r.Count, len(repoNames)) + for _, repo := range repoNames { + fmt.Printf(" - %s (%d)\n", repo, r.RepoCounts[repo]) + } + fmt.Println() + } +} diff --git a/internal/report/template.html b/internal/report/template.html new file mode 100644 index 0000000000000000000000000000000000000000..764e2de13dfa1d650b58e385c957d0ad4c80fd2f GIT binary patch literal 12617 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]+)>\s*$`) + +func Clone(repoFullName, repoURL, token string) (dir string, cleanup func(), err error) { + tmpDir, err := os.MkdirTemp("", "git-identity-audit-*") + if err != nil { + return "", nil, fmt.Errorf("creating temp dir: %w", err) + } + cleanup = func() { os.RemoveAll(tmpDir) } + + authURL := injectToken(repoURL, token) + + cmd := exec.Command("git", "clone", "--bare", "--filter=blob:none", "--quiet", authURL, tmpDir) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + cleanup() + return "", nil, fmt.Errorf("cloning %s: %w: %s", repoFullName, err, stderr.String()) + } + + return tmpDir, cleanup, nil +} + +func CountCommits(gitDir string) (int, error) { + cmd := exec.Command("git", "--git-dir", gitDir, "rev-list", "--all", "--count") + out, err := cmd.Output() + if err != nil { + return 0, fmt.Errorf("counting commits: %w", err) + } + n, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, fmt.Errorf("parsing commit count: %w", err) + } + return n, nil +} + +func StreamLog(gitDir, repoFullName string, onCommit func(entries []Entry)) error { + cmd := exec.Command("git", "--git-dir", gitDir, "log", "--all", "--format="+logFormat) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("opening log stream: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting log stream: %w", err) + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + scanner.Split(splitOnRecordSep) + + for scanner.Scan() { + record := strings.Trim(scanner.Text(), "\n") + if record == "" { + continue + } + onCommit(parseRecord(record, repoFullName)) + } + scanErr := scanner.Err() + + waitErr := cmd.Wait() + if waitErr != nil { + return fmt.Errorf("reading log for %s: %w: %s", repoFullName, waitErr, stderr.String()) + } + if scanErr != nil { + return fmt.Errorf("reading log for %s: %w", repoFullName, scanErr) + } + return nil +} + +func splitOnRecordSep(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, recordSep[0]); i >= 0 { + return i + 1, data[:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil +} + +func injectToken(repoURL, token string) string { + const prefix = "https://" + if !strings.HasPrefix(repoURL, prefix) { + return repoURL + } + return prefix + "x-access-token:" + token + "@" + strings.TrimPrefix(repoURL, prefix) +} + +func parseRecord(record, repoFullName string) []Entry { + fields := strings.SplitN(record, unitSep, 7) + if len(fields) < 7 { + return nil + } + sha, authorName, authorEmail, committerName, committerEmail, date, body := fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], fields[6] + + entries := []Entry{ + {Name: authorName, Email: authorEmail, Field: "Author", Repo: repoFullName, CommitSHA: sha, Date: date}, + {Name: committerName, Email: committerEmail, Field: "Committer", Repo: repoFullName, CommitSHA: sha, Date: date}, + } + for _, match := range trailerPattern.FindAllStringSubmatch(body, -1) { + entries = append(entries, Entry{Name: match[2], Email: match[3], Field: match[1], Repo: repoFullName, CommitSHA: sha, Date: date}) + } + return entries +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..04a1fcb --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,86 @@ +package scanner + +import ( + "os" + "os/exec" + "path/filepath" + "sort" + "testing" +) + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +} + +func TestScanExtractsAuthorCommitterAndTrailers(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init", "-q") + runGit(t, dir, "config", "user.email", "author@example.com") + runGit(t, dir, "config", "user.name", "Author One") + + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "file.txt") + runGit(t, dir, "commit", "-q", "-m", + "Initial commit\n\nCo-authored-by: Helper Two \nSigned-off-by: Author One ") + + runGit(t, dir, "-c", "user.name=Committer Three", "-c", "user.email=committer@example.com", + "commit", "-q", "--allow-empty", "-m", "second commit") + + gitDir, cleanup, err := Clone("smoke/test", "file://"+dir, "") + if err != nil { + t.Fatalf("Clone failed: %v", err) + } + defer cleanup() + + count, err := CountCommits(gitDir) + if err != nil { + t.Fatalf("CountCommits failed: %v", err) + } + if count != 2 { + t.Errorf("expected 2 commits, got %d", count) + } + + var entries []Entry + commitsSeen := 0 + if err := StreamLog(gitDir, "smoke/test", func(e []Entry) { + commitsSeen++ + entries = append(entries, e...) + }); err != nil { + t.Fatalf("StreamLog failed: %v", err) + } + if commitsSeen != 2 { + t.Errorf("expected onCommit called 2 times, got %d", commitsSeen) + } + + got := map[string]bool{} + for _, e := range entries { + got[e.Field+"|"+e.Name+"|"+e.Email] = true + } + + want := []string{ + "Author|Author One|author@example.com", + "Committer|Author One|author@example.com", + "Co-authored-by|Helper Two|helper@example.com", + "Signed-off-by|Author One|author@example.com", + "Author|Author One|author@example.com", + "Committer|Committer Three|committer@example.com", + } + for _, w := range want { + if !got[w] { + all := make([]string, 0, len(got)) + for k := range got { + all = append(all, k) + } + sort.Strings(all) + t.Errorf("expected entry %q not found; got: %v", w, all) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..eadac5e --- /dev/null +++ b/main.go @@ -0,0 +1,190 @@ +package main + +import ( + "flag" + "fmt" + "math" + "os" + "strings" + "sync" + "time" + + "github.com/YuzuZensai/Git-Identity-Audit/internal/github" + "github.com/YuzuZensai/Git-Identity-Audit/internal/report" + "github.com/YuzuZensai/Git-Identity-Audit/internal/scanner" + "github.com/vbauerster/mpb/v8" + "github.com/vbauerster/mpb/v8/decor" + "golang.org/x/term" +) + +var overallBarStyle = mpb.BarStyle().Lbound("[").Filler("=").Tip(">").Padding("-").Rbound("]") + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run() error { + tokenFlag := flag.String("token", "", "GitHub personal access token (prefer GITHUB_TOKEN env var or the interactive prompt instead)") + output := flag.String("output", fmt.Sprintf("git-identity-audit-%d.html", time.Now().Unix()), "path to write the HTML report") + concurrency := flag.Int("concurrency", 10, "max number of repos to clone/scan in parallel") + flag.Parse() + + token, err := resolveToken(*tokenFlag) + if err != nil { + return err + } + + client := github.NewClient(token) + + fmt.Println("Discovering accessible repositories...") + repos, err := client.ListAccessibleRepos() + if err != nil { + return fmt.Errorf("listing repositories: %w", err) + } + fmt.Printf("Found %d repositories.\n", len(repos)) + + entries, scanned, failed, warnings := scanAll(repos, token, *concurrency) + for _, w := range warnings { + fmt.Fprintln(os.Stderr, w) + } + fmt.Printf("Scanned %d/%d repos successfully (%d failed).\n", scanned, len(repos), failed) + + events, err := fetchEvents(client) + if err != nil { + fmt.Fprintln(os.Stderr, "warning: skipping GitHub events correlation:", err) + } + correlateEvents(entries, events) + + if err := report.WriteHTML(*output, entries, events); err != nil { + return fmt.Errorf("writing HTML report: %w", err) + } + fmt.Printf("Wrote %d identity entries to %s\n", len(entries), *output) + + report.PrintSummary(entries) + return nil +} + +func resolveToken(flagToken string) (string, error) { + if flagToken != "" { + return flagToken, nil + } + if envToken := os.Getenv("GITHUB_TOKEN"); envToken != "" { + return envToken, nil + } + fmt.Print("GitHub PAT: ") + tokenBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading token: %w", err) + } + token := strings.TrimSpace(string(tokenBytes)) + if token == "" { + return "", fmt.Errorf("no token provided") + } + return token, nil +} + +func fetchEvents(client *github.Client) ([]github.Event, error) { + login, err := client.CurrentUserLogin() + if err != nil { + return nil, fmt.Errorf("resolving token owner: %w", err) + } + events, err := client.PublicEvents(login) + if err != nil { + return nil, fmt.Errorf("fetching public events for %s: %w", login, err) + } + return events, nil +} + +func correlateEvents(entries []scanner.Entry, events []github.Event) { + heads := make(map[string]string, len(events)) + for _, e := range events { + if e.Type != "PushEvent" || e.Payload.Head == "" { + continue + } + heads[e.Repo.Name+"@"+e.Payload.Head] = e.Actor.Login + } + for i := range entries { + if entries[i].Field != "Committer" { + continue + } + if login, ok := heads[entries[i].Repo+"@"+entries[i].CommitSHA]; ok { + entries[i].PushedByLogin = login + } + } +} + +func scanAll(repos []github.Repo, token string, concurrency int) ([]scanner.Entry, int, int, []string) { + var ( + mu sync.Mutex + entries []scanner.Entry + scanned int + failed int + warnings []string + wg sync.WaitGroup + sem = make(chan struct{}, concurrency) + ) + + progress := mpb.New(mpb.WithWidth(50)) + overallBar := progress.New(int64(len(repos)), overallBarStyle, + mpb.BarPriority(math.MaxInt), + mpb.PrependDecorators(decor.Name("total repos", decor.WC{W: 14})), + mpb.AppendDecorators(decor.CountersNoUnit("%d / %d"), decor.Name(" "), decor.Percentage(decor.WC{W: 5})), + ) + + for _, repo := range repos { + repo := repo + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + defer overallBar.Increment() + + err := scanRepo(progress, repo, token, &mu, &entries) + + mu.Lock() + if err != nil { + failed++ + warnings = append(warnings, fmt.Sprintf("warning: skipping %s: %v", repo.FullName, err)) + } else { + scanned++ + } + mu.Unlock() + }() + } + wg.Wait() + progress.Wait() + + return entries, scanned, failed, warnings +} + +func scanRepo(progress *mpb.Progress, repo github.Repo, token string, mu *sync.Mutex, entries *[]scanner.Entry) error { + gitDir, cleanup, err := scanner.Clone(repo.FullName, repo.CloneURL, token) + if err != nil { + return err + } + defer cleanup() + + count, err := scanner.CountCommits(gitDir) + if err != nil || count == 0 { + count = 1 + } + + repoBar := progress.New(int64(count), overallBarStyle, + mpb.PrependDecorators(decor.Name(repo.FullName, decor.WCSyncSpaceR)), + mpb.AppendDecorators(decor.CountersNoUnit("%d / %d commits")), + ) + + err = scanner.StreamLog(gitDir, repo.FullName, func(e []scanner.Entry) { + mu.Lock() + *entries = append(*entries, e...) + mu.Unlock() + repoBar.Increment() + }) + repoBar.SetCurrent(int64(count)) + return err +}