Skip to content

Commit 73ea356

Browse files
committed
Merge branch 'main' into production
2 parents 31eb434 + 14a4d8a commit 73ea356

41 files changed

Lines changed: 4496 additions & 636 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/wl/cmd_resolve_github.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"os"
10+
"regexp"
11+
"sort"
12+
"time"
13+
14+
"github.com/gastownhall/wasteland/internal/commons"
15+
"github.com/gastownhall/wasteland/internal/githubcache"
16+
"github.com/gastownhall/wasteland/internal/pile"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
// Overridable package vars so tests can inject fakes without touching
21+
// environment or disk.
22+
var (
23+
loadGitHubCache = githubcache.Load
24+
newGitHubResolver = githubcache.NewResolver
25+
newCommonsReaderForCmd = func() pile.RowQuerier { return pile.NewCommonsReader() }
26+
resolveNow = func() time.Time { return time.Now().UTC() }
27+
)
28+
29+
// prEvidenceRegex mirrors the canonical PR-URL regex used elsewhere.
30+
var prEvidenceRegex = regexp.MustCompile(`^https?://github\.com/([^/?#\s]+)/([^/?#\s]+)/pull/(\d+)(?:$|[/?#])`)
31+
32+
func newResolveGitHubCmd(stdout, stderr io.Writer) *cobra.Command {
33+
cmd := &cobra.Command{
34+
Use: "resolve-github [handle]",
35+
Short: "Resolve a rig handle to its GitHub username via stamp PR authorship",
36+
Long: `Populate the local GitHub handle cache by inspecting stamp evidence URLs
37+
in hop/wl-commons and calling the GitHub REST API for PR authorship.
38+
39+
Requires GITHUB_TOKEN (a fine-grained PAT with public_repo read).
40+
41+
Examples:
42+
wl resolve-github alice # Resolve one handle (always re-resolves)
43+
wl resolve-github --all # Resolve every observed handle, skipping cached
44+
wl resolve-github --all --refresh # Force re-resolution of cached entries`,
45+
Args: func(cmd *cobra.Command, args []string) error {
46+
all, _ := cmd.Flags().GetBool("all")
47+
if all {
48+
if len(args) != 0 {
49+
return fmt.Errorf("--all takes no positional arguments")
50+
}
51+
return nil
52+
}
53+
if len(args) != 1 {
54+
return fmt.Errorf("provide exactly one handle or use --all")
55+
}
56+
return nil
57+
},
58+
RunE: func(cmd *cobra.Command, args []string) error {
59+
all, _ := cmd.Flags().GetBool("all")
60+
refresh, _ := cmd.Flags().GetBool("refresh")
61+
if all {
62+
return runResolveGitHubAll(cmd.Context(), stdout, stderr, refresh)
63+
}
64+
return runResolveGitHubOne(cmd.Context(), stdout, stderr, args[0])
65+
},
66+
}
67+
68+
cmd.Flags().Bool("all", false, "Resolve all handles observed in hop/wl-commons stamps")
69+
cmd.Flags().Bool("refresh", false, "Re-resolve entries even if already cached")
70+
return cmd
71+
}
72+
73+
// runResolveGitHubOne resolves a single handle and writes its cache entry.
74+
func runResolveGitHubOne(ctx context.Context, stdout, stderr io.Writer, handle string) error {
75+
if ctx == nil {
76+
ctx = context.Background()
77+
}
78+
cache, err := loadGitHubCache()
79+
if err != nil {
80+
return fmt.Errorf("resolve-github: loading cache: %w", err)
81+
}
82+
reader := newCommonsReaderForCmd()
83+
resolver := newGitHubResolver()
84+
85+
outcome, err := resolveHandle(ctx, cache, reader, resolver, handle)
86+
if err != nil {
87+
return writeResolverError(stderr, handle, err)
88+
}
89+
switch outcome.kind {
90+
case outcomeResolved:
91+
fmt.Fprintf(stdout, "Resolved %s \u2192 %s (via %s).\n",
92+
handle, outcome.login, outcome.label)
93+
case outcomeTriedAndFailed:
94+
fmt.Fprintf(stdout, "No resolvable PR URL for %q \u2014 marked tried-and-failed.\n", handle)
95+
}
96+
return nil
97+
}
98+
99+
// runResolveGitHubAll iterates every distinct stamp subject and resolves
100+
// those that are absent, tried-and-failed, or being refreshed.
101+
func runResolveGitHubAll(ctx context.Context, stdout, stderr io.Writer, refresh bool) error {
102+
if ctx == nil {
103+
ctx = context.Background()
104+
}
105+
cache, err := loadGitHubCache()
106+
if err != nil {
107+
return fmt.Errorf("resolve-github: loading cache: %w", err)
108+
}
109+
reader := newCommonsReaderForCmd()
110+
resolver := newGitHubResolver()
111+
112+
subjects, err := listStampSubjects(reader)
113+
if err != nil {
114+
return fmt.Errorf("resolve-github: listing subjects: %w", err)
115+
}
116+
117+
var resolved, skipped, triedFailed, errored int
118+
for _, handle := range subjects {
119+
existing, ok := cache.Get(handle)
120+
if ok && existing.GitHub != "" && !refresh {
121+
fmt.Fprintf(stdout, "Skipped %s (already cached as %s).\n", handle, existing.GitHub)
122+
skipped++
123+
continue
124+
}
125+
outcome, err := resolveHandle(ctx, cache, reader, resolver, handle)
126+
if err != nil {
127+
// A missing token will fail every subsequent call too —
128+
// bail fast with a single clear message.
129+
if errors.Is(err, githubcache.ErrNoToken) {
130+
fmt.Fprintln(stderr, "resolve-github: GITHUB_TOKEN is not set \u2014 set a fine-grained PAT with public_repo read to continue.")
131+
return errExit
132+
}
133+
fmt.Fprintf(stderr, "resolve-github: %s: %v\n", handle, err)
134+
errored++
135+
continue
136+
}
137+
switch outcome.kind {
138+
case outcomeResolved:
139+
fmt.Fprintf(stdout, "Resolved %s \u2192 %s (via %s).\n",
140+
handle, outcome.login, outcome.label)
141+
resolved++
142+
case outcomeTriedAndFailed:
143+
fmt.Fprintf(stdout, "No resolvable PR URL for %q \u2014 marked tried-and-failed.\n", handle)
144+
triedFailed++
145+
}
146+
}
147+
148+
fmt.Fprintf(stdout,
149+
"Resolved %d, skipped %d (already cached), tried-and-failed %d, errored %d.\n",
150+
resolved, skipped, triedFailed, errored)
151+
// Non-zero exit when one or more handles errored so cron / CI can
152+
// distinguish "batch succeeded" from "batch limped through failures".
153+
if errored > 0 {
154+
return errExit
155+
}
156+
return nil
157+
}
158+
159+
type outcomeKind int
160+
161+
const (
162+
outcomeResolved outcomeKind = iota
163+
outcomeTriedAndFailed
164+
)
165+
166+
type resolveOutcome struct {
167+
kind outcomeKind
168+
login string
169+
label string // e.g. "owner/repo#N"
170+
}
171+
172+
// resolveHandle runs the single-handle resolution logic used by both
173+
// single-handle and --all flows. It writes to the cache on success or
174+
// tried-and-failed outcomes and returns an error only when the caller
175+
// should surface it (resolver failure, commons query failure, cache
176+
// write failure).
177+
func resolveHandle(ctx context.Context, cache githubcache.Cache, reader pile.RowQuerier, resolver githubcache.Resolver, handle string) (resolveOutcome, error) {
178+
prURL, label, err := findFirstPRURL(reader, handle)
179+
if err != nil {
180+
return resolveOutcome{}, err
181+
}
182+
now := resolveNow().Format(time.RFC3339)
183+
if prURL == "" {
184+
if putErr := cache.Put(handle, githubcache.Entry{ResolvedAt: now}); putErr != nil {
185+
return resolveOutcome{}, fmt.Errorf("writing cache: %w", putErr)
186+
}
187+
return resolveOutcome{kind: outcomeTriedAndFailed}, nil
188+
}
189+
login, err := resolver.ResolvePRAuthor(ctx, prURL)
190+
if err != nil {
191+
return resolveOutcome{}, err
192+
}
193+
entry := githubcache.Entry{
194+
GitHub: login,
195+
SourcePR: prURL,
196+
ResolvedAt: now,
197+
}
198+
if putErr := cache.Put(handle, entry); putErr != nil {
199+
return resolveOutcome{}, fmt.Errorf("writing cache: %w", putErr)
200+
}
201+
return resolveOutcome{kind: outcomeResolved, login: login, label: label}, nil
202+
}
203+
204+
// findFirstPRURL returns the most recent evidence URL for the handle
205+
// that parses as a GitHub PR URL, along with its owner/repo#N label.
206+
// The SQL LIKE prefilter uses explicit http:// / https:// prefixes so
207+
// a near-miss scheme (e.g. "httpss://") doesn't consume a LIMIT slot
208+
// and hide older valid PRs. The Go regex is still the authoritative
209+
// check for strict URL shape.
210+
func findFirstPRURL(reader pile.RowQuerier, handle string) (string, string, error) {
211+
sql := fmt.Sprintf(
212+
`SELECT c.evidence FROM stamps s `+
213+
`LEFT JOIN completions c ON s.context_id = c.id `+
214+
`WHERE s.subject = '%s' AND (c.evidence LIKE 'https://github.com/%%/pull/%%' OR c.evidence LIKE 'http://github.com/%%/pull/%%') `+
215+
`ORDER BY s.created_at DESC, s.id DESC LIMIT 10`,
216+
commons.EscapeSQL(handle))
217+
rows, err := reader.QueryRows(sql)
218+
if err != nil {
219+
return "", "", fmt.Errorf("querying stamps: %w", err)
220+
}
221+
for _, row := range rows {
222+
raw, _ := row["evidence"].(string)
223+
if raw == "" {
224+
continue
225+
}
226+
if m := prEvidenceRegex.FindStringSubmatch(raw); m != nil {
227+
return raw, fmt.Sprintf("%s/%s#%s", m[1], m[2], m[3]), nil
228+
}
229+
}
230+
return "", "", nil
231+
}
232+
233+
// listStampSubjects returns the distinct, sorted set of handles that
234+
// appear as subjects of any stamp in hop/wl-commons.
235+
func listStampSubjects(reader pile.RowQuerier) ([]string, error) {
236+
rows, err := reader.QueryRows(`SELECT DISTINCT subject FROM stamps`)
237+
if err != nil {
238+
return nil, err
239+
}
240+
seen := make(map[string]struct{}, len(rows))
241+
for _, row := range rows {
242+
s, _ := row["subject"].(string)
243+
if s == "" {
244+
continue
245+
}
246+
seen[s] = struct{}{}
247+
}
248+
subjects := make([]string, 0, len(seen))
249+
for s := range seen {
250+
subjects = append(subjects, s)
251+
}
252+
sort.Strings(subjects)
253+
return subjects, nil
254+
}
255+
256+
// PrimeGitHubCacheAsync fires off a background `resolve-github --all`
257+
// equivalent so a freshly deployed `wl serve` (especially Railway, where
258+
// the cache file starts empty after a deploy onto a fresh persistent
259+
// volume) self-populates without an ops step. Set WL_SKIP_CACHE_PRIME=1
260+
// to disable — handy in local dev to avoid burning GitHub API quota.
261+
//
262+
// Runs in a goroutine. All errors are logged and swallowed. Idempotent:
263+
// subsequent runs skip handles already cached (no --refresh).
264+
func PrimeGitHubCacheAsync() {
265+
if os.Getenv("WL_SKIP_CACHE_PRIME") == "1" {
266+
slog.Info("stamp_cache: startup prime skipped (WL_SKIP_CACHE_PRIME=1)")
267+
return
268+
}
269+
go func() {
270+
defer func() {
271+
if r := recover(); r != nil {
272+
slog.Error("stamp_cache: startup prime panic; swallowing", "panic", r)
273+
}
274+
}()
275+
slog.Info("stamp_cache: startup prime started")
276+
start := time.Now()
277+
// runResolveGitHubAll honors cache state (skips resolved,
278+
// retries tried-and-failed) so repeat runs are cheap. Discard
279+
// stdout chatter; send stderr to slog-shaped lines via a tiny
280+
// adapter so operators see errors in the structured log.
281+
err := runResolveGitHubAll(context.Background(), io.Discard, slogWriter{level: slog.LevelWarn}, false)
282+
elapsed := time.Since(start).Round(time.Second)
283+
if err != nil {
284+
slog.Warn("stamp_cache: startup prime completed with errors",
285+
"error", err, "elapsed", elapsed)
286+
return
287+
}
288+
slog.Info("stamp_cache: startup prime complete", "elapsed", elapsed)
289+
}()
290+
}
291+
292+
// slogWriter adapts io.Writer so stderr output from the batch resolver
293+
// lands in the structured log rather than on a detached stream.
294+
type slogWriter struct {
295+
level slog.Level
296+
}
297+
298+
func (w slogWriter) Write(p []byte) (int, error) {
299+
msg := string(p)
300+
// Trim trailing newline that fmt.Fprintln adds so the log line
301+
// isn't doubled.
302+
if n := len(msg); n > 0 && msg[n-1] == '\n' {
303+
msg = msg[:n-1]
304+
}
305+
slog.Log(context.Background(), w.level, msg)
306+
return len(p), nil
307+
}
308+
309+
// writeResolverError prints an operator-friendly stderr message and
310+
// returns errExit so the cobra runner surfaces a non-zero exit without
311+
// a duplicate "wl:" prefix.
312+
func writeResolverError(stderr io.Writer, handle string, err error) error {
313+
if errors.Is(err, githubcache.ErrNoToken) {
314+
fmt.Fprintln(stderr, "resolve-github: GITHUB_TOKEN is not set \u2014 set a fine-grained PAT with public_repo read to continue.")
315+
return errExit
316+
}
317+
fmt.Fprintf(stderr, "resolve-github: %s: %v\n", handle, err)
318+
return errExit
319+
}

0 commit comments

Comments
 (0)