mirror of
https://github.com/YuzuZensai/Git-Identity-Audit.git
synced 2026-09-13 10:49:12 +00:00
✨ feat: Initial commit
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,139 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string
|
||||
Email string
|
||||
Field string // "Author", "Committer", or a trailer name like "Co-authored-by"
|
||||
Repo string
|
||||
CommitSHA string
|
||||
Date string
|
||||
PushedByLogin string `json:",omitempty"` // GitHub login that pushed this commit, per the public Events API
|
||||
}
|
||||
|
||||
const (
|
||||
unitSep = "\x1f"
|
||||
recordSep = "\x1e"
|
||||
)
|
||||
|
||||
var logFormat = "%H" + unitSep + "%an" + unitSep + "%ae" + unitSep + "%cn" + unitSep + "%ce" + unitSep + "%aI" + unitSep + "%B" + recordSep
|
||||
|
||||
var trailerPattern = regexp.MustCompile(`(?m)^([A-Za-z][A-Za-z-]*-by):\s*(.+?)\s*<([^>]+)>\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
|
||||
}
|
||||
@@ -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 <helper@example.com>\nSigned-off-by: Author One <author@example.com>")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user