refactor: modernise to Go 1.26 — iterators, slices, maps
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 23s

- Add StatusIter, DirtyReposIter, AheadReposIter on git.Service
- Add StatusIter, PushMultipleIter on git package functions
- Add ListOrgReposIter, ListUserReposIter on forge/gitea clients
- Add ListPullRequestsIter on forge/gitea clients
- Add extractIACRPapersIter, extractPostsIter iterators
- Replace manual map-key-sort with slices.Sorted(maps.Keys(...))
- Modernise range-over-int in excavate_test.go

Co-Authored-By: Gemini <noreply@google.com>
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-23 06:23:32 +00:00
parent 3e56a022de
commit a315c3894e
11 changed files with 310 additions and 57 deletions

View file

@ -3,6 +3,7 @@ package collect
import (
"context"
"fmt"
"iter"
"net/http"
"path/filepath"
"strings"
@ -172,26 +173,38 @@ func (b *BitcoinTalkCollector) fetchPage(ctx context.Context, pageURL string) ([
// It looks for the common BitcoinTalk post structure using div.post elements.
func extractPosts(doc *html.Node) []btPost {
var posts []btPost
var walk func(*html.Node)
for p := range extractPostsIter(doc) {
posts = append(posts, p)
}
return posts
}
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "div" {
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, "post") {
post := parsePost(n)
if post.Content != "" {
posts = append(posts, post)
// extractPostsIter returns an iterator over post data extracted from a parsed HTML document.
func extractPostsIter(doc *html.Node) iter.Seq[btPost] {
return func(yield func(btPost) bool) {
var walk func(*html.Node) bool
walk = func(n *html.Node) bool {
if n.Type == html.ElementNode && n.Data == "div" {
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, "post") {
post := parsePost(n)
if post.Content != "" {
if !yield(post) {
return false
}
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if !walk(c) {
return false
}
}
return true
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
walk(doc)
}
walk(doc)
return posts
}
// parsePost extracts author, date, and content from a post div.

View file

@ -26,7 +26,7 @@ func (m *mockCollector) Collect(ctx context.Context, cfg *Config) (*Result, erro
}
result := &Result{Source: m.name, Items: m.items}
for i := 0; i < m.items; i++ {
for i := range m.items {
result.Files = append(result.Files, fmt.Sprintf("/output/%s/%d.md", m.name, i))
}

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/xml"
"fmt"
"iter"
"net/http"
"net/url"
"path/filepath"
@ -289,26 +290,38 @@ func arxivEntryToPaper(entry arxivEntry) paper {
// extractIACRPapers extracts paper metadata from an IACR search results page.
func extractIACRPapers(doc *html.Node) []paper {
var papers []paper
var walk func(*html.Node)
for p := range extractIACRPapersIter(doc) {
papers = append(papers, p)
}
return papers
}
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "div" {
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, "paperentry") {
ppr := parseIACREntry(n)
if ppr.Title != "" {
papers = append(papers, ppr)
// extractIACRPapersIter returns an iterator over paper metadata extracted from an IACR search results page.
func extractIACRPapersIter(doc *html.Node) iter.Seq[paper] {
return func(yield func(paper) bool) {
var walk func(*html.Node) bool
walk = func(n *html.Node) bool {
if n.Type == html.ElementNode && n.Data == "div" {
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, "paperentry") {
ppr := parseIACREntry(n)
if ppr.Title != "" {
if !yield(ppr) {
return false
}
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if !walk(c) {
return false
}
}
return true
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
walk(doc)
}
walk(doc)
return papers
}
// parseIACREntry extracts paper data from an IACR paper entry div.

View file

@ -4,8 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"path/filepath"
"sort"
"slices"
"strings"
core "forge.lthn.ai/core/go/pkg/framework/core"
@ -301,12 +302,7 @@ func jsonToMarkdown(content string) (string, error) {
func jsonValueToMarkdown(b *strings.Builder, data any, depth int) {
switch v := data.(type) {
case map[string]any:
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, key := range slices.Sorted(maps.Keys(v)) {
val := v[key]
indent := strings.Repeat(" ", depth)
switch child := val.(type) {

View file

@ -1,6 +1,8 @@
package forge
import (
"iter"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"forge.lthn.ai/core/go/pkg/log"
@ -123,6 +125,40 @@ func (c *Client) ListPullRequests(owner, repo string, state string) ([]*forgejo.
return all, nil
}
// ListPullRequestsIter returns an iterator over pull requests for the given repository.
func (c *Client) ListPullRequestsIter(owner, repo string, state string) iter.Seq2[*forgejo.PullRequest, error] {
st := forgejo.StateOpen
switch state {
case "closed":
st = forgejo.StateClosed
case "all":
st = forgejo.StateAll
}
return func(yield func(*forgejo.PullRequest, error) bool) {
page := 1
for {
prs, resp, err := c.api.ListRepoPullRequests(owner, repo, forgejo.ListPullRequestsOptions{
ListOptions: forgejo.ListOptions{Page: page, PageSize: 50},
State: st,
})
if err != nil {
yield(nil, log.E("forge.ListPullRequests", "failed to list pull requests", err))
return
}
for _, pr := range prs {
if !yield(pr, nil) {
return
}
}
if resp == nil || page >= resp.LastPage {
break
}
page++
}
}
}
// GetPullRequest returns a single pull request by number.
func (c *Client) GetPullRequest(owner, repo string, number int64) (*forgejo.PullRequest, error) {
pr, _, err := c.api.GetPullRequest(owner, repo, number)

View file

@ -1,6 +1,8 @@
package forge
import (
"iter"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"forge.lthn.ai/core/go/pkg/log"
@ -30,6 +32,31 @@ func (c *Client) ListOrgRepos(org string) ([]*forgejo.Repository, error) {
return all, nil
}
// ListOrgReposIter returns an iterator over repositories for the given organisation.
func (c *Client) ListOrgReposIter(org string) iter.Seq2[*forgejo.Repository, error] {
return func(yield func(*forgejo.Repository, error) bool) {
page := 1
for {
repos, resp, err := c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{
ListOptions: forgejo.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
yield(nil, log.E("forge.ListOrgRepos", "failed to list org repos", err))
return
}
for _, repo := range repos {
if !yield(repo, nil) {
return
}
}
if resp == nil || page >= resp.LastPage {
break
}
page++
}
}
}
// ListUserRepos returns all repositories for the authenticated user.
func (c *Client) ListUserRepos() ([]*forgejo.Repository, error) {
var all []*forgejo.Repository
@ -54,6 +81,31 @@ func (c *Client) ListUserRepos() ([]*forgejo.Repository, error) {
return all, nil
}
// ListUserReposIter returns an iterator over repositories for the authenticated user.
func (c *Client) ListUserReposIter() iter.Seq2[*forgejo.Repository, error] {
return func(yield func(*forgejo.Repository, error) bool) {
page := 1
for {
repos, resp, err := c.api.ListMyRepos(forgejo.ListReposOptions{
ListOptions: forgejo.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
yield(nil, log.E("forge.ListUserRepos", "failed to list user repos", err))
return
}
for _, repo := range repos {
if !yield(repo, nil) {
return
}
}
if resp == nil || page >= resp.LastPage {
break
}
page++
}
}
}
// GetRepo returns a single repository by owner and name.
func (c *Client) GetRepo(owner, name string) (*forgejo.Repository, error) {
repo, _, err := c.api.GetRepo(owner, name)

View file

@ -5,8 +5,10 @@ import (
"bytes"
"context"
"io"
"iter"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"sync"
@ -69,6 +71,18 @@ func Status(ctx context.Context, opts StatusOptions) []RepoStatus {
return results
}
// StatusIter returns an iterator over git status for multiple repositories.
func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] {
return func(yield func(RepoStatus) bool) {
results := Status(ctx, opts)
for _, r := range results {
if !yield(r) {
return
}
}
}
}
// getStatus gets the git status for a single repository.
func getStatus(ctx context.Context, path, name string) RepoStatus {
status := RepoStatus{
@ -197,30 +211,35 @@ type PushResult struct {
// PushMultiple pushes multiple repositories sequentially.
// Sequential because SSH passphrase prompts need user interaction.
func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult {
results := make([]PushResult, len(paths))
return slices.Collect(PushMultipleIter(ctx, paths, names))
}
for i, path := range paths {
name := names[path]
if name == "" {
name = path
// PushMultipleIter returns an iterator that pushes repositories sequentially and yields results.
func PushMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PushResult] {
return func(yield func(PushResult) bool) {
for _, path := range paths {
name := names[path]
if name == "" {
name = path
}
result := PushResult{
Name: name,
Path: path,
}
err := Push(ctx, path)
if err != nil {
result.Error = err
} else {
result.Success = true
}
if !yield(result) {
return
}
}
result := PushResult{
Name: name,
Path: path,
}
err := Push(ctx, path)
if err != nil {
result.Error = err
} else {
result.Success = true
}
results[i] = result
}
return results
}
// gitCommand runs a git command and returns stdout.

View file

@ -2,6 +2,8 @@ package git
import (
"context"
"iter"
"slices"
"forge.lthn.ai/core/go/pkg/framework"
)
@ -103,6 +105,11 @@ func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, er
// Status returns last status result.
func (s *Service) Status() []RepoStatus { return s.lastStatus }
// StatusIter returns an iterator over last status result.
func (s *Service) StatusIter() iter.Seq[RepoStatus] {
return slices.Values(s.lastStatus)
}
// DirtyRepos returns repos with uncommitted changes.
func (s *Service) DirtyRepos() []RepoStatus {
var dirty []RepoStatus
@ -114,6 +121,19 @@ func (s *Service) DirtyRepos() []RepoStatus {
return dirty
}
// DirtyReposIter returns an iterator over repos with uncommitted changes.
func (s *Service) DirtyReposIter() iter.Seq[RepoStatus] {
return func(yield func(RepoStatus) bool) {
for _, st := range s.lastStatus {
if st.Error == nil && st.IsDirty() {
if !yield(st) {
return
}
}
}
}
}
// AheadRepos returns repos with unpushed commits.
func (s *Service) AheadRepos() []RepoStatus {
var ahead []RepoStatus
@ -124,3 +144,16 @@ func (s *Service) AheadRepos() []RepoStatus {
}
return ahead
}
// AheadReposIter returns an iterator over repos with unpushed commits.
func (s *Service) AheadReposIter() iter.Seq[RepoStatus] {
return func(yield func(RepoStatus) bool) {
for _, st := range s.lastStatus {
if st.Error == nil && st.HasUnpushed() {
if !yield(st) {
return
}
}
}
}
}

View file

@ -1,6 +1,8 @@
package gitea
import (
"iter"
"code.gitea.io/sdk/gitea"
"forge.lthn.ai/core/go/pkg/log"
@ -98,6 +100,40 @@ func (c *Client) ListPullRequests(owner, repo string, state string) ([]*gitea.Pu
return all, nil
}
// ListPullRequestsIter returns an iterator over pull requests for the given repository.
func (c *Client) ListPullRequestsIter(owner, repo string, state string) iter.Seq2[*gitea.PullRequest, error] {
st := gitea.StateOpen
switch state {
case "closed":
st = gitea.StateClosed
case "all":
st = gitea.StateAll
}
return func(yield func(*gitea.PullRequest, error) bool) {
page := 1
for {
prs, resp, err := c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
State: st,
})
if err != nil {
yield(nil, log.E("gitea.ListPullRequests", "failed to list pull requests", err))
return
}
for _, pr := range prs {
if !yield(pr, nil) {
return
}
}
if resp == nil || page >= resp.LastPage {
break
}
page++
}
}
}
// GetPullRequest returns a single pull request by number.
func (c *Client) GetPullRequest(owner, repo string, number int64) (*gitea.PullRequest, error) {
pr, _, err := c.api.GetPullRequest(owner, repo, number)

View file

@ -1,6 +1,8 @@
package gitea
import (
"iter"
"code.gitea.io/sdk/gitea"
"forge.lthn.ai/core/go/pkg/log"
@ -30,6 +32,31 @@ func (c *Client) ListOrgRepos(org string) ([]*gitea.Repository, error) {
return all, nil
}
// ListOrgReposIter returns an iterator over repositories for the given organisation.
func (c *Client) ListOrgReposIter(org string) iter.Seq2[*gitea.Repository, error] {
return func(yield func(*gitea.Repository, error) bool) {
page := 1
for {
repos, resp, err := c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
yield(nil, log.E("gitea.ListOrgRepos", "failed to list org repos", err))
return
}
for _, repo := range repos {
if !yield(repo, nil) {
return
}
}
if resp == nil || page >= resp.LastPage {
break
}
page++
}
}
}
// ListUserRepos returns all repositories for the authenticated user.
func (c *Client) ListUserRepos() ([]*gitea.Repository, error) {
var all []*gitea.Repository
@ -54,6 +81,31 @@ func (c *Client) ListUserRepos() ([]*gitea.Repository, error) {
return all, nil
}
// ListUserReposIter returns an iterator over repositories for the authenticated user.
func (c *Client) ListUserReposIter() iter.Seq2[*gitea.Repository, error] {
return func(yield func(*gitea.Repository, error) bool) {
page := 1
for {
repos, resp, err := c.api.ListMyRepos(gitea.ListReposOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
yield(nil, log.E("gitea.ListUserRepos", "failed to list user repos", err))
return
}
for _, repo := range repos {
if !yield(repo, nil) {
return
}
}
if resp == nil || page >= resp.LastPage {
break
}
page++
}
}
}
// GetRepo returns a single repository by owner and name.
func (c *Client) GetRepo(owner, name string) (*gitea.Repository, error) {
repo, _, err := c.api.GetRepo(owner, name)

3
go.sum
View file

@ -3,8 +3,11 @@ code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzY
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
forge.lthn.ai/core/cli v0.0.1 h1:nqpc4Tv8a4H/ERei+/71DVQxkCFU8HPFJP4120qPXgk=
forge.lthn.ai/core/cli v0.0.1/go.mod h1:xa3Nqw3sUtYYJ1k+1jYul18tgs6sBevCUsGsHJI1hHA=
forge.lthn.ai/core/go v0.0.1 h1:ubk4nmkA3treOUNgPS28wKd1jB6cUlEQUV7jDdGa3zM=
forge.lthn.ai/core/go v0.0.1/go.mod h1:59YsnuMaAGQUxIhX68oK2/HnhQJEPWL1iEZhDTrNCbY=
forge.lthn.ai/core/go-crypt v0.0.1 h1:fmFc2SJ/VOXDRjkcYoLWfL7lI4HfPJeVS/Na6zHHcvw=
forge.lthn.ai/core/go-crypt v0.0.1/go.mod h1:/j/rUN2ZMV7x1B5BYxH3QdwkgZg0HNBw5XuyFZeyxBY=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=