package main import ( "fmt" "net/url" "os" "regexp" "strings" "time" "sigs.k8s.io/yaml" ) type User struct { Name string `json:"name"` URL string `json:"url"` } type Contribution struct { URLs []string `json:"urls"` Tags []string `json:"tags"` SponsoredBy string `json:"sponsored-by"` Desc string `json:"desc"` SubmittedAt time.Time `json:"submitted-at"` LastUpdatedAt time.Time `json:"last-updated-at"` LastUpdatedBy User `json:"last-updated-by"` Status string `json:"status"` StatusClass string `json:"-"` } func ReadContribs(filename string) ([]Contribution, error) { bs, err := os.ReadFile(filename) if err != nil { return nil, fmt.Errorf("contribs: %q: %w", filename, err) } var ret []Contribution if err := yaml.UnmarshalStrict(bs, &ret); err != nil { return nil, fmt.Errorf("contribs: %q: %w", filename, err) } for i := range ret { contrib := ret[i] if err := contrib.Fill(); err != nil { return nil, fmt.Errorf("contribs: %q: %w", filename, err) } ret[i] = contrib } return ret, nil } func (c *Contribution) Fill() error { var err error if c.SubmittedAt.IsZero() { c.SubmittedAt, err = c.fetchSubmittedAt() if err != nil { return err } } if c.LastUpdatedAt.IsZero() { c.LastUpdatedAt, c.LastUpdatedBy, err = c.fetchLastUpdated() if err != nil { return err } } if c.Status == "" { c.Status, err = c.fetchStatus() if err != nil { return err } } c.StatusClass, err = classifyStatus(c.Status) if err != nil { return err } for _, u := range c.URLs { if m := reGoLangGerritCL.FindStringSubmatch(u); m != nil { c.URLs = append(c.URLs, "https://golang.org/cl/"+m[1]) } } return nil } func classifyStatus(status string) (string, error) { switch { case strings.Contains(status, "released") || strings.Contains(status, "deployed"): return "released", nil case strings.Contains(status, "merged"): return "merged", nil case strings.Contains(status, "open"): return "open", nil case strings.Contains(status, "closed") || strings.Contains(status, "locked"): return "closed", nil default: return "", fmt.Errorf("unrecognized status string: %q", status) } } var ( reGoLangGerritCL = regexp.MustCompile(`https://go-review\.googlesource\.com/c/[^/?#]+/\+/([0-9]+)(?:\?[^#]*)?(?:#.*)?$`) reGitHubPR = regexp.MustCompile(`^https://github\.com/([^/?#]+)/([^/?#]+)/pull/([0-9]+)(?:\?[^#]*)?(?:#.*)?$`) reGitHubCommit = regexp.MustCompile(`^https://github\.com/([^/?#]+)/([^/?#]+)/commit/([0-9a-f]+)(?:\?[^#]*)?(?:#.*)?$`) reGitLabMR = regexp.MustCompile(`^https://([^/]+)/([^?#]+)/-/merge_requests/([0-9]+)(?:\?[^#]*)?(?:#.*)?$`) rePiperMailDate = regexp.MustCompile(`^\s*([^<]+)\s*$`) ) const ( statusOpen = "open" statusMerged = "merged, not yet in a release" statusReleasedFmt = "merged, released in %s" ) func (c Contribution) fetchStatus() (string, error) { if m := reGitHubPR.FindStringSubmatch(c.URLs[0]); m != nil { user := m[1] repo := m[2] prnum := m[3] urlStr := "https://api.github.com/repos/" + user + "/" + repo + "/pulls/" + prnum var obj struct { // State values are "open" and "closed". State string `json:"state"` Merged bool `json:"merged"` MergeCommitSha string `json:"merge_commit_sha"` } if err := httpGetJSON(urlStr, &obj); err != nil { return "", err } ret := obj.State if obj.Merged { ret = statusMerged tag, err := getGitTagThatContainsAll("https://github.com/"+user+"/"+repo, obj.MergeCommitSha) if err != nil { return "", err } if tag != "" { ret = fmt.Sprintf(statusReleasedFmt, tag) } } return ret, nil } if m := reGitLabMR.FindStringSubmatch(c.URLs[0]); m != nil { authority := m[1] projectID := m[2] mrnum := m[3] urlStr := "https://" + authority + "/api/v4/projects/" + url.QueryEscape(projectID) + "/merge_requests/" + mrnum var obj struct { // State values are "opened", "closed", "locked", and "merged". State string `json:"state"` MergeCommitSha string `json:"merge_commit_sha"` SquashCommitSha string `json:"squash_commit_sha"` } if err := httpGetJSON(urlStr, &obj); err != nil { return "", err } ret := obj.State if ret == "opened" { ret = statusOpen } if ret == "merged" { ret = statusMerged var mergeCommit string if obj.MergeCommitSha != "" { mergeCommit = obj.MergeCommitSha } if obj.SquashCommitSha != "" { mergeCommit = obj.SquashCommitSha } if mergeCommit != "" { tag, err := getGitTagThatContainsAll("https://"+authority+"/"+projectID+".git", mergeCommit) if err != nil { return "", err } if tag != "" { ret = fmt.Sprintf(statusReleasedFmt, tag) } } } return ret, nil } if len(c.URLs) > 1 { var gitURL string var gitCommits []string for _, u := range c.URLs[1:] { if m := reGitHubCommit.FindStringSubmatch(u); m != nil { user := m[1] repo := m[2] hash := m[3] gitURL = "https://github.com/" + user + "/" + repo gitCommits = append(gitCommits, hash) } } if len(gitCommits) > 0 { ret := statusMerged tag, err := getGitTagThatContainsAll(gitURL, gitCommits...) if err != nil { return "", err } if tag != "" { ret = fmt.Sprintf(statusReleasedFmt, tag) } return ret, nil } } return "", fmt.Errorf("idk how to get status for %q", c.URLs[0]) } func (c Contribution) fetchSubmittedAt() (time.Time, error) { if m := reGitHubPR.FindStringSubmatch(c.URLs[0]); m != nil { user := m[1] repo := m[2] prnum := m[3] urlStr := "https://api.github.com/repos/" + user + "/" + repo + "/pulls/" + prnum var obj struct { CreatedAt time.Time `json:"created_at"` } if err := httpGetJSON(urlStr, &obj); err != nil { return time.Time{}, err } return obj.CreatedAt, nil } if m := reGitLabMR.FindStringSubmatch(c.URLs[0]); m != nil { authority := m[1] projectID := m[2] mrnum := m[3] urlStr := "https://" + authority + "/api/v4/projects/" + url.QueryEscape(projectID) + "/merge_requests/" + mrnum var obj struct { CreatedAt time.Time `json:"created_at"` } if err := httpGetJSON(urlStr, &obj); err != nil { return time.Time{}, err } return obj.CreatedAt, nil } if strings.Contains(c.URLs[0], "/pipermail/") { htmlStr, err := httpGet(c.URLs[0]) if err != nil { return time.Time{}, err } for _, line := range strings.Split(htmlStr, "\n") { if m := rePiperMailDate.FindStringSubmatch(line); m != nil { return time.Parse(time.UnixDate, m[1]) } } } return time.Time{}, fmt.Errorf("idk how to get created timestamp for %q", c.URLs[0]) } func (c Contribution) fetchLastUpdated() (time.Time, User, error) { if m := reGitHubPR.FindStringSubmatch(c.URLs[0]); m != nil { user := m[1] repo := m[2] prnum := m[3] var obj struct { UpdatedAt time.Time `json:"updated_at"` MergedAt time.Time `json:"merged_at"` MergedBy struct { Login string `json:"login"` HTMLURL string `json:"html_url"` } `json:"merged_by"` } if err := httpGetJSON("https://api.github.com/repos/"+user+"/"+repo+"/pulls/"+prnum, &obj); err != nil { return time.Time{}, User{}, err } retUpdatedAt := obj.UpdatedAt var retUser User if obj.MergedAt == retUpdatedAt { retUser.Name = obj.MergedBy.Login retUser.URL = obj.MergedBy.HTMLURL } if retUser == (User{}) { // "normal" comments var comments []struct { UpdatedAt time.Time `json:"updated_at"` User struct { Login string `json:"login"` HTMLURL string `json:"html_url"` } `json:"user"` } if err := httpGetJSON("https://api.github.com/repos/"+user+"/"+repo+"/issues/"+prnum+"/comments", &comments); err != nil { return time.Time{}, User{}, err } for _, comment := range comments { if comment.UpdatedAt == retUpdatedAt || comment.UpdatedAt.Add(1*time.Second) == retUpdatedAt { retUser.Name = comment.User.Login retUser.URL = comment.User.HTMLURL break } } } if retUser == (User{}) { // comments on a specific part of the diff var reviewComments []struct { UpdatedAt time.Time `json:"updated_at"` User struct { Login string `json:"login"` HTMLURL string `json:"html_url"` } `json:"user"` } if err := httpGetJSON("https://api.github.com/repos/"+user+"/"+repo+"/pulls/"+prnum+"/comments", &reviewComments); err != nil { return time.Time{}, User{}, err } for _, comment := range reviewComments { if comment.UpdatedAt == retUpdatedAt { retUser.Name = comment.User.Login retUser.URL = comment.User.HTMLURL break } } } if retUser == (User{}) { var events []struct { CreatedAt time.Time `json:"created_at"` Actor struct { Login string `json:"login"` HTMLURL string `json:"html_url"` } `json:"actor"` } if err := httpGetJSON("https://api.github.com/repos/"+user+"/"+repo+"/issues/"+prnum+"/events", &events); err != nil { return time.Time{}, User{}, err } for _, event := range events { if event.CreatedAt == retUpdatedAt { retUser.Name = event.Actor.Login retUser.URL = event.Actor.HTMLURL break } } } return retUpdatedAt, retUser, nil } if m := reGitLabMR.FindStringSubmatch(c.URLs[0]); m != nil { authority := m[1] projectID := m[2] mrnum := m[3] urlStr := "https://" + authority + "/api/v4/projects/" + url.QueryEscape(projectID) + "/merge_requests/" + mrnum var obj struct { UpdatedAt time.Time `json:"updated_at"` } if err := httpGetJSON(urlStr, &obj); err != nil { return time.Time{}, User{}, err } return obj.UpdatedAt, User{}, nil } var ret time.Time if len(c.URLs) > 1 { for _, u := range c.URLs[1:] { if m := reGitHubCommit.FindStringSubmatch(u); m != nil { user := m[1] repo := m[2] hash := m[3] urlStr := "https://api.github.com/repos/" + user + "/" + repo + "/commits/" + hash var obj struct { Commit struct { Author struct { Date time.Time `json:"date"` } `json:"author"` Committer struct { Date time.Time `json:"date"` } `json:"committer"` } `json:"commit"` } if err := httpGetJSON(urlStr, &obj); err != nil { return time.Time{}, User{}, err } if obj.Commit.Author.Date.After(ret) { ret = obj.Commit.Author.Date } if obj.Commit.Committer.Date.After(ret) { ret = obj.Commit.Committer.Date } } } } if !ret.IsZero() { return ret, User{}, nil } return time.Time{}, User{}, nil //fmt.Errorf("idk how to get updated timestamp for %q", c.URLs[0]) }