From 290354461422fff8c05ef9ce37ba154641e3f8fc Mon Sep 17 00:00:00 2001 From: "Luke T. Shumaker" Date: Mon, 10 Jun 2024 11:31:53 -0600 Subject: imworkingon: Fuss with forge precedence --- cmd/generate/forge_forgejo.go | 21 ++-- cmd/generate/forge_gerrit.go | 32 ++---- cmd/generate/forge_github.go | 95 ++++------------- cmd/generate/forge_part_git.go | 80 +++++++++++++++ cmd/generate/forge_part_pipermail.go | 192 +++++++++++++++++++++++++++++++++++ cmd/generate/forge_pipermail.go | 192 ----------------------------------- cmd/generate/mailstuff/jwz.md | 4 +- cmd/generate/src_contribs.go | 51 +++++++++- 8 files changed, 360 insertions(+), 307 deletions(-) create mode 100644 cmd/generate/forge_part_git.go create mode 100644 cmd/generate/forge_part_pipermail.go delete mode 100644 cmd/generate/forge_pipermail.go (limited to 'cmd/generate') diff --git a/cmd/generate/forge_forgejo.go b/cmd/generate/forge_forgejo.go index 62234b9..84988f2 100644 --- a/cmd/generate/forge_forgejo.go +++ b/cmd/generate/forge_forgejo.go @@ -15,10 +15,10 @@ type Forgejo struct { var _ Forge = Forgejo{} func (f Forgejo) FetchStatus(urls []string) (string, error) { - for _, u := range urls { + return fetchPerURLStatus(urls, func(u string) (string, error) { m := reForgejoPR.FindStringSubmatch(u) if m == nil || m[1] != f.Authority { - continue + return "", nil } authority := m[1] user := m[2] @@ -49,15 +49,14 @@ func (f Forgejo) FetchStatus(urls []string) (string, error) { } return ret, nil - } - return "", nil + }) } func (f Forgejo) FetchSubmittedAt(urls []string) (time.Time, error) { - for _, u := range urls { + return fetchPerURLSubmittedAt(urls, func(u string) (time.Time, error) { m := reForgejoPR.FindStringSubmatch(u) if m == nil || m[1] != f.Authority { - continue + return time.Time{}, nil } authority := m[1] user := m[2] @@ -73,15 +72,14 @@ func (f Forgejo) FetchSubmittedAt(urls []string) (time.Time, error) { return time.Time{}, err } return obj.CreatedAt, nil - } - return time.Time{}, nil + }) } func (f Forgejo) FetchLastUpdated(urls []string) (time.Time, User, error) { - for _, u := range urls { + return fetchPerURLLastUpdated(urls, func(u string) (time.Time, User, error) { m := reForgejoPR.FindStringSubmatch(u) if m == nil || m[1] != f.Authority { - continue + return time.Time{}, User{}, nil } authority := m[1] user := m[2] @@ -181,6 +179,5 @@ func (f Forgejo) FetchLastUpdated(urls []string) (time.Time, User, error) { } return retUpdatedAt, retUser, nil - } - return time.Time{}, User{}, nil + }) } diff --git a/cmd/generate/forge_gerrit.go b/cmd/generate/forge_gerrit.go index 1e6e073..31f2256 100644 --- a/cmd/generate/forge_gerrit.go +++ b/cmd/generate/forge_gerrit.go @@ -62,15 +62,10 @@ var _ Forge = Gerrit{} var reGoogleGerritCL = regexp.MustCompile(`https://([a-z]+-review\.googlesource\.com)/c/([^?#]+)/\+/([0-9]+)(?:\?[^#]*)?(?:#.*)?$`) func (Gerrit) FetchStatus(urls []string) (string, error) { - for _, u := range urls { - if reGitHubPR.MatchString(u) { - return "", nil - } - } - for _, u := range urls { + return fetchPerURLStatus(urls, func(u string) (string, error) { m := reGoogleGerritCL.FindStringSubmatch(u) if m == nil { - continue + return "", nil } authority := m[1] projectID := m[2] @@ -93,20 +88,15 @@ func (Gerrit) FetchStatus(urls []string) (string, error) { case "ABANDONED": return "closed", nil } - } - return "", nil + return "", nil + }) } func (Gerrit) FetchSubmittedAt(urls []string) (time.Time, error) { - for _, u := range urls { - if reGitHubPR.MatchString(u) { - return time.Time{}, nil - } - } - for _, u := range urls { + return fetchPerURLSubmittedAt(urls, func(u string) (time.Time, error) { m := reGoogleGerritCL.FindStringSubmatch(u) if m == nil { - continue + return time.Time{}, nil } authority := m[1] projectID := m[2] @@ -121,15 +111,14 @@ func (Gerrit) FetchSubmittedAt(urls []string) (time.Time, error) { return time.Time{}, err } return obj.Created.Val, nil - } - return time.Time{}, nil + }) } func (Gerrit) FetchLastUpdated(urls []string) (time.Time, User, error) { - for _, u := range urls { + return fetchPerURLLastUpdated(urls, func(u string) (time.Time, User, error) { m := reGoogleGerritCL.FindStringSubmatch(u) if m == nil { - continue + return time.Time{}, User{}, nil } authority := m[1] projectID := m[2] @@ -165,6 +154,5 @@ func (Gerrit) FetchLastUpdated(urls []string) (time.Time, User, error) { } } return retUpdatedAt, retUser, nil - } - return time.Time{}, User{}, nil + }) } diff --git a/cmd/generate/forge_github.go b/cmd/generate/forge_github.go index d29e3f7..9f475a3 100644 --- a/cmd/generate/forge_github.go +++ b/cmd/generate/forge_github.go @@ -7,10 +7,7 @@ import ( "time" ) -var ( - reGitHubPR = regexp.MustCompile(`^https://github\.com/([^/?#]+)/([^/?#]+)/pull/([0-9]+)(?:\?[^#]*)?(?:#.*)?$`) - reGitHubCommit = regexp.MustCompile(`^https://github\.com/([^/?#]+)/([^/?#]+)/commit/([0-9a-f]+)(?:\?[^#]*)?(?:#.*)?$`) -) +var reGitHubPR = regexp.MustCompile(`^https://github\.com/([^/?#]+)/([^/?#]+)/pull/([0-9]+)(?:\?[^#]*)?(?:#.*)?$`) func githubPagination(i int) url.Values { params := make(url.Values) @@ -23,11 +20,15 @@ type GitHub struct{} var _ Forge = GitHub{} func (GitHub) FetchStatus(urls []string) (string, error) { - // PR for _, u := range urls { + if reGoogleGerritCL.MatchString(u) { + return "", nil + } + } + return fetchPerURLStatus(urls, func(u string) (string, error) { m := reGitHubPR.FindStringSubmatch(u) if m == nil { - continue + return "", nil } user := m[1] repo := m[2] @@ -57,40 +58,14 @@ func (GitHub) FetchStatus(urls []string) (string, error) { } return ret, nil - } - // Commits from a non-PR - var gitURL string - var gitCommits []string - for _, u := range urls { - 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 - } - // Nope - return "", nil + }) } func (GitHub) FetchSubmittedAt(urls []string) (time.Time, error) { - for _, u := range urls { + return fetchPerURLSubmittedAt(urls, func(u string) (time.Time, error) { m := reGitHubPR.FindStringSubmatch(u) if m == nil { - continue + return time.Time{}, nil } user := m[1] repo := m[2] @@ -105,16 +80,19 @@ func (GitHub) FetchSubmittedAt(urls []string) (time.Time, error) { return time.Time{}, err } return obj.CreatedAt, nil - } - return time.Time{}, nil + }) } func (GitHub) FetchLastUpdated(urls []string) (time.Time, User, error) { - // PR for _, u := range urls { + if reGoogleGerritCL.MatchString(u) { + return time.Time{}, User{}, nil + } + } + return fetchPerURLLastUpdated(urls, func(u string) (time.Time, User, error) { m := reGitHubPR.FindStringSubmatch(u) if m == nil { - continue + return time.Time{}, User{}, nil } user := m[1] repo := m[2] @@ -213,42 +191,5 @@ func (GitHub) FetchLastUpdated(urls []string) (time.Time, User, error) { } return retUpdatedAt, retUser, nil - } - // Commits from a non-PR - { - var ret time.Time - for _, u := range urls { - 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, nil, &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 time.Time{}, User{}, nil - } - } - // Nope - return time.Time{}, User{}, nil + }) } diff --git a/cmd/generate/forge_part_git.go b/cmd/generate/forge_part_git.go new file mode 100644 index 0000000..5288286 --- /dev/null +++ b/cmd/generate/forge_part_git.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "regexp" + "time" +) + +var reGitHubCommit = regexp.MustCompile(`^https://github\.com/([^/?#]+)/([^/?#]+)/commit/([0-9a-f]+)(?:\?[^#]*)?(?:#.*)?$`) + +type PartGit struct{} + +var _ Forge = PartGit{} + +func (PartGit) FetchStatus(urls []string) (string, error) { + var gitURL string + var gitCommits []string + for _, u := range urls { + 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 { + return "", nil + } + ret := statusMerged + tag, err := getGitTagThatContainsAll(gitURL, gitCommits...) + if err != nil { + return "", err + } + if tag != "" { + ret = fmt.Sprintf(statusReleasedFmt, tag) + } + return ret, nil +} + +func (PartGit) FetchSubmittedAt(urls []string) (time.Time, error) { + return time.Time{}, nil +} + +func (PartGit) FetchLastUpdated(urls []string) (time.Time, User, error) { + var ret time.Time + for _, u := range urls { + 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, nil, &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 time.Time{}, User{}, nil + } + return time.Time{}, User{}, nil +} diff --git a/cmd/generate/forge_part_pipermail.go b/cmd/generate/forge_part_pipermail.go new file mode 100644 index 0000000..af6a009 --- /dev/null +++ b/cmd/generate/forge_part_pipermail.go @@ -0,0 +1,192 @@ +package main + +import ( + "compress/gzip" + "errors" + "fmt" + "net/mail" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" + + "git.lukeshu.com/www/cmd/generate/mailstuff" +) + +var ( + rePiperMailMessage = regexp.MustCompile(`^(https?://.*/pipermail/.*/)([0-4]{4}-(?:January|February|March|April|May|June|July|August|September|October|November|December))/([0-9]+)\.html$`) + rePiperMailDate = regexp.MustCompile(`^\s*([^<]+)\s*$`) + rePiperMailReply = regexp.MustCompile(`^\s*\s*$`) +) + +type PartPiperMail struct{} + +var _ Forge = PartPiperMail{} + +func (PartPiperMail) FetchStatus(urls []string) (string, error) { + return "", nil +} + +func (PartPiperMail) FetchSubmittedAt(urls []string) (time.Time, error) { + for _, u := range urls { + if !rePiperMailMessage.MatchString(u) { + continue + } + htmlStr, err := httpGet(u, nil) + 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{}, nil +} + +func (PartPiperMail) nextMonth(ym string) string { + yStr, mStr, ok := strings.Cut(ym, "-") + if !ok { + panic(fmt.Errorf("invalid year-month: %q", ym)) + } + switch mStr { + case "January": + return yStr + "-February" + case "February": + return yStr + "-March" + case "March": + return yStr + "-April" + case "April": + return yStr + "-May" + case "May": + return yStr + "-June" + case "June": + return yStr + "-July" + case "July": + return yStr + "-August" + case "August": + return yStr + "-September" + case "September": + return yStr + "-October" + case "October": + return yStr + "-November" + case "November": + return yStr + "-December" + case "December": + y, _ := strconv.Atoi(yStr) + return fmt.Sprintf("%d-January", y+1) + default: + panic(fmt.Errorf("invalid year-month: %q", ym)) + } +} + +func (p PartPiperMail) threadLen(thread *mailstuff.ThreadedMessage) int { + if thread == nil { + return 0 + } + + ret := 0 + if thread.Message != nil { + ret++ + } + for child := range thread.Children { + ret += p.threadLen(child) + } + return ret +} + +func (p PartPiperMail) FetchLastUpdated(urls []string) (time.Time, User, error) { + for _, u := range urls { + m := rePiperMailMessage.FindStringSubmatch(u) + if m == nil { + continue + } + uBase := m[1] + uYM := m[2] + //uInt := m[3] + + htmlStr, err := httpGet(u, nil) + if err != nil { + return time.Time{}, User{}, fmt.Errorf("could not fetch message: %w", err) + } + var msgid mailstuff.MessageID + for _, line := range strings.Split(htmlStr, "\n") { + if m := rePiperMailReply.FindStringSubmatch(line); m != nil { + ru, err := url.Parse(m[1]) + if err != nil { + continue + } + if msgid = mailstuff.MessageID(ru.Query().Get("In-Reply-To")); msgid != "" { + break + } + } + } + if msgid == "" { + continue + } + + var thread *mailstuff.ThreadedMessage + for ym, mbox := uYM, []*mail.Message(nil); true; ym = p.nextMonth(ym) { + lenBefore := p.threadLen(thread) + + mboxGzStr, err := httpGet(uBase+ym+".txt.gz", nil) + if err != nil { + if ym == uYM || !errors.Is(err, os.ErrNotExist) { + return time.Time{}, User{}, fmt.Errorf("could not fetch mbox for %s: %w", ym, err) + } + break + } + gzReader, err := gzip.NewReader(strings.NewReader(mboxGzStr)) + if err != nil { + return time.Time{}, User{}, fmt.Errorf("could not read mbox gz: %w", err) + } + _mbox, err := mailstuff.ReadMBox(gzReader) + if err != nil { + gzReader.Close() + return time.Time{}, User{}, fmt.Errorf("could not parse mbox: %w", err) + } + if err := gzReader.Close(); err != nil { + return time.Time{}, User{}, fmt.Errorf("close gz: %w", err) + } + mbox = append(mbox, _mbox...) + _, messages := mailstuff.ThreadMessages(mbox) + thread = messages[msgid] + + if p.threadLen(thread) == lenBefore { + break + } + } + if thread == nil { + continue + } + + var retTime time.Time + var retUser User + + var walk func(*mailstuff.ThreadedMessage) + walk = func(msg *mailstuff.ThreadedMessage) { + date, dateErr := msg.Header.Date() + froms, fromErr := msg.Header.AddressList("From") + if dateErr == nil && fromErr == nil && len(froms) > 0 && (retTime.IsZero() || date.After(retTime)) { + retTime = date + retUser.Name = froms[0].Name + if retUser.Name == "" { + retUser.Name = froms[0].Address + } + retUser.URL = "mailto:" + froms[0].Address + } + for child := range msg.Children { + walk(child) + } + } + walk(thread) + + if !retTime.IsZero() { + return retTime, retUser, nil + } + } + return time.Time{}, User{}, nil +} diff --git a/cmd/generate/forge_pipermail.go b/cmd/generate/forge_pipermail.go deleted file mode 100644 index ccc58f0..0000000 --- a/cmd/generate/forge_pipermail.go +++ /dev/null @@ -1,192 +0,0 @@ -package main - -import ( - "compress/gzip" - "errors" - "fmt" - "net/mail" - "net/url" - "os" - "regexp" - "strconv" - "strings" - "time" - - "git.lukeshu.com/www/cmd/generate/mailstuff" -) - -var ( - rePiperMailMessage = regexp.MustCompile(`^(https?://.*/pipermail/.*/)([0-4]{4}-(?:January|February|March|April|May|June|July|August|September|October|November|December))/([0-9]+)\.html$`) - rePiperMailDate = regexp.MustCompile(`^\s*([^<]+)\s*$`) - rePiperMailReply = regexp.MustCompile(`^\s*\s*$`) -) - -type PiperMail struct{} - -var _ Forge = PiperMail{} - -func (PiperMail) FetchStatus(urls []string) (string, error) { - return "", nil -} - -func (PiperMail) FetchSubmittedAt(urls []string) (time.Time, error) { - for _, u := range urls { - if !rePiperMailMessage.MatchString(u) { - continue - } - htmlStr, err := httpGet(u, nil) - 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{}, nil -} - -func (PiperMail) nextMonth(ym string) string { - yStr, mStr, ok := strings.Cut(ym, "-") - if !ok { - panic(fmt.Errorf("invalid year-month: %q", ym)) - } - switch mStr { - case "January": - return yStr + "-February" - case "February": - return yStr + "-March" - case "March": - return yStr + "-April" - case "April": - return yStr + "-May" - case "May": - return yStr + "-June" - case "June": - return yStr + "-July" - case "July": - return yStr + "-August" - case "August": - return yStr + "-September" - case "September": - return yStr + "-October" - case "October": - return yStr + "-November" - case "November": - return yStr + "-December" - case "December": - y, _ := strconv.Atoi(yStr) - return fmt.Sprintf("%d-January", y+1) - default: - panic(fmt.Errorf("invalid year-month: %q", ym)) - } -} - -func (p PiperMail) threadLen(thread *mailstuff.ThreadedMessage) int { - if thread == nil { - return 0 - } - - ret := 0 - if thread.Message != nil { - ret++ - } - for child := range thread.Children { - ret += p.threadLen(child) - } - return ret -} - -func (p PiperMail) FetchLastUpdated(urls []string) (time.Time, User, error) { - for _, u := range urls { - m := rePiperMailMessage.FindStringSubmatch(u) - if m == nil { - continue - } - uBase := m[1] - uYM := m[2] - //uInt := m[3] - - htmlStr, err := httpGet(u, nil) - if err != nil { - return time.Time{}, User{}, fmt.Errorf("could not fetch message: %w", err) - } - var msgid mailstuff.MessageID - for _, line := range strings.Split(htmlStr, "\n") { - if m := rePiperMailReply.FindStringSubmatch(line); m != nil { - ru, err := url.Parse(m[1]) - if err != nil { - continue - } - if msgid = mailstuff.MessageID(ru.Query().Get("In-Reply-To")); msgid != "" { - break - } - } - } - if msgid == "" { - continue - } - - var thread *mailstuff.ThreadedMessage - for ym, mbox := uYM, []*mail.Message(nil); true; ym = p.nextMonth(ym) { - lenBefore := p.threadLen(thread) - - mboxGzStr, err := httpGet(uBase+ym+".txt.gz", nil) - if err != nil { - if (ym == uYM || !errors.Is(err, os.ErrNotExist)) { - return time.Time{}, User{}, fmt.Errorf("could not fetch mbox for %s: %w", ym, err) - } - break - } - gzReader, err := gzip.NewReader(strings.NewReader(mboxGzStr)) - if err != nil { - return time.Time{}, User{}, fmt.Errorf("could not read mbox gz: %w", err) - } - _mbox, err := mailstuff.ReadMBox(gzReader) - if err != nil { - gzReader.Close() - return time.Time{}, User{}, fmt.Errorf("could not parse mbox: %w", err) - } - if err := gzReader.Close(); err != nil { - return time.Time{}, User{}, fmt.Errorf("close gz: %w", err) - } - mbox = append(mbox, _mbox...) - _, messages := mailstuff.ThreadMessages(mbox) - thread = messages[msgid] - - if p.threadLen(thread) == lenBefore { - break - } - } - if thread == nil { - continue - } - - var retTime time.Time - var retUser User - - var walk func(*mailstuff.ThreadedMessage) - walk = func(msg *mailstuff.ThreadedMessage) { - date, dateErr := msg.Header.Date() - froms, fromErr := msg.Header.AddressList("From") - if dateErr == nil && fromErr == nil && len(froms) > 0 && (retTime.IsZero() || date.After(retTime)) { - retTime = date - retUser.Name = froms[0].Name - if retUser.Name == "" { - retUser.Name = froms[0].Address - } - retUser.URL = "mailto:" + froms[0].Address - } - for child := range msg.Children { - walk(child) - } - } - walk(thread) - - if !retTime.IsZero() { - return retTime, retUser, nil - } - } - return time.Time{}, User{}, nil -} diff --git a/cmd/generate/mailstuff/jwz.md b/cmd/generate/mailstuff/jwz.md index 54f0a45..91e03f5 100644 --- a/cmd/generate/mailstuff/jwz.md +++ b/cmd/generate/mailstuff/jwz.md @@ -11,11 +11,11 @@ feedback valuable. You write that the algorithm in RFC 5256 is merely a "restating" of your algorithm, but I noticed 3 (minor) differences: - + 1. In your step 1.C, the RFC says to check whether this would create a loop, and if it would to skip creating the link; your version only says to perform this check in step 1.B. - + 2. The RFC says to sort the messages by date between your steps 4 and 5; that is: when grouping by subject, containers in the root set should be processed in date-order (you do not specify an order), diff --git a/cmd/generate/src_contribs.go b/cmd/generate/src_contribs.go index 39bc04b..5694156 100644 --- a/cmd/generate/src_contribs.go +++ b/cmd/generate/src_contribs.go @@ -108,15 +108,31 @@ type Forge interface { } var forges = []Forge{ + // precedence only matters for .FetchStatus. + // highest precedence - Gerrit{}, // must be higher than GitHub because of golang + Gerrit{}, GitHub{}, GitLab{}, Forgejo{"codeberg.org"}, - PiperMail{}, + PartPiperMail{}, + PartGit{}, // lowest precedence } +func fetchPerURLStatus(urls []string, perURL func(string) (string, error)) (string, error) { + for _, u := range urls { + status, err := perURL(u) + if err != nil { + return "", err + } + if status != "" { + return status, nil + } + } + return "", nil +} + func (c Contribution) fetchStatus() (string, error) { for _, forge := range forges { status, err := forge.FetchStatus(c.URLs) @@ -130,6 +146,20 @@ func (c Contribution) fetchStatus() (string, error) { return "", fmt.Errorf("idk how to get status for %q", c.URLs[0]) } +func fetchPerURLSubmittedAt(urls []string, perURL func(string) (time.Time, error)) (time.Time, error) { + var ret time.Time + for _, u := range urls { + submittedAt, err := perURL(u) + if err != nil { + return time.Time{}, err + } + if !submittedAt.IsZero() && (ret.IsZero() || submittedAt.Before(ret)) { + ret = submittedAt + } + } + return ret, nil +} + func (c Contribution) fetchSubmittedAt() (time.Time, error) { var ret time.Time for _, forge := range forges { @@ -155,6 +185,23 @@ func withinOneSecond(a, b time.Time) bool { return d <= time.Second } +func fetchPerURLLastUpdated(urls []string, perURL func(string) (time.Time, User, error)) (time.Time, User, error) { + var ret struct { + time.Time + User + } + for _, u := range urls { + updatedAt, updatedBy, err := perURL(u) + if err != nil { + return time.Time{}, User{}, err + } + if !updatedAt.IsZero() && (ret.Time.IsZero() || updatedAt.After(ret.Time)) { + ret.Time, ret.User = updatedAt, updatedBy + } + } + return ret.Time, ret.User, nil +} + func (c Contribution) fetchLastUpdated() (time.Time, User, error) { var ret struct { time.Time -- cgit v1.2.3-2-g168b