feat(web): add web UI and Coolify compose deployment

`ghglance -serve :8080` runs a web UI from the same binary: submit a
username, a background job renders every card in every theme, and
/u/<user> shows them again with a theme picker and embed URLs.

- Cards are stored on disk with atomic symlink publishing and deleted
  after -retention (default 24h); -cooldown limits token-less regeneration.
- Token-less jobs use a public-only server token with private and org
  scope forced off; a submitter's own token is used for that job only and
  never stored or logged. The form links to GitHub's new-token page with
  the needed scopes pre-ticked.
- The CLI and web share one fetch helper; CLI output is unchanged.
- compose.yml deploys to Coolify with a data volume and /healthz check.
This commit is contained in:
tiennm99 committed 2026-10-07 11:08:07 +07:00
1 parent af420f0db7
commit ad775adffa
33 files changed
+3282 -179

No files matched your search

+18 -13
View File
@@ -58,15 +58,21 @@ const maxRateLimitSleep = 5 * time.Minute
// caller's overall budget expires. On a primary-rate-limit 403, honors
// Retry-After / X-RateLimit-Reset once before retrying.
func (c *Client) query(ctx context.Context, q string, vars map[string]any, out any) error {
_, err := c.queryHeader(ctx, q, vars, out)
return err
}
// queryHeader is query that also returns the successful response's headers.
func (c *Client) queryHeader(ctx context.Context, q string, vars map[string]any, out any) (http.Header, error) {
body, err := json.Marshal(gqlRequest{Query: q, Variables: vars})
if err != nil {
return fmt.Errorf("marshal request: %w", err)
return nil, fmt.Errorf("marshal request: %w", err)
}
for attempt := 0; attempt < 2; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "ghglance")
@@ -76,51 +82,51 @@ func (c *Client) query(ctx context.Context, q string, vars map[string]any, out a
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
return nil, fmt.Errorf("http: %w", err)
}
raw, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("read body: %w", err)
return nil, fmt.Errorf("read body: %w", err)
}
if rateLimited(resp) && attempt == 0 {
wait := rateLimitWait(resp)
if wait > maxRateLimitSleep {
return fmt.Errorf("http %d: rate limit resets in %s (>%s max wait)", resp.StatusCode, wait, maxRateLimitSleep)
return nil, fmt.Errorf("http %d: rate limit resets in %s (>%s max wait)", resp.StatusCode, wait, maxRateLimitSleep)
}
fmt.Fprintf(os.Stderr, "warn: rate-limited, sleeping %s before retry\n", wait.Round(time.Second))
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
return nil, ctx.Err()
}
continue
}
if resp.StatusCode >= 400 {
return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500))
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500))
}
var r gqlResponse
if err := json.Unmarshal(raw, &r); err != nil {
return fmt.Errorf("decode body: %w", err)
return nil, fmt.Errorf("decode body: %w", err)
}
if len(r.Errors) > 0 {
msgs := make([]string, 0, len(r.Errors))
for _, e := range r.Errors {
msgs = append(msgs, e.Message)
}
return fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
return nil, fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
}
if out != nil {
if err := json.Unmarshal(r.Data, out); err != nil {
return fmt.Errorf("decode data: %w", err)
return nil, fmt.Errorf("decode data: %w", err)
}
}
return nil
return resp.Header, nil
}
return fmt.Errorf("http: exceeded retry attempts")
return nil, fmt.Errorf("http: exceeded retry attempts")
}
// rateLimited returns true when the response indicates a GitHub primary or
@@ -176,4 +182,3 @@ func truncate(b []byte, n int) string {
}
return string(b[:cut]) + "…"
}
+166
View File
@@ -0,0 +1,166 @@
package github
import (
"context"
"fmt"
"strings"
"time"
)
// CollectConfig tunes Collect beyond the repo filters in FetchOptions.
type CollectConfig struct {
Options FetchOptions
// Location buckets commit timestamps for the productive-time cards and
// names the UTC offset in their titles. Nil means UTC.
Location *time.Location
// WeekStart is the first heatmap row and weekday bar.
WeekStart time.Weekday
// TopRepos caps the seed repos probed for commit history (0 = unlimited).
TopRepos int
// CommitsPerRepo caps commits sampled per seed repo (0 = every commit).
CommitsPerRepo int
// Warnf reports non-fatal fetch failures; partial data still renders.
// Nil discards them.
Warnf func(format string, args ...any)
// Strict turns those stage failures into an error, for callers that
// would rather keep an older complete result than publish a partial one.
Strict bool
}
// Collect runs the full fetch sequence every card needs: FetchProfile, then
// FetchContributionsAllTime (which yields the seed repos), then
// FetchProductive over those seeds. Only the profile fetch is fatal unless
// cfg.Strict is set; otherwise the later stages warn and leave their
// aggregates partially filled.
func Collect(ctx context.Context, c *Client, login string, cfg CollectConfig) (*Profile, error) {
warnf := cfg.Warnf
if warnf == nil {
warnf = func(string, ...any) {}
}
loc := cfg.Location
if loc == nil {
loc = time.UTC
}
profile, err := c.FetchProfile(ctx, login, cfg.Options)
if err != nil {
return nil, fmt.Errorf("fetch profile: %w", err)
}
profile.UTCOffsetLabel = UTCOffsetLabel(loc)
profile.WeekStart = cfg.WeekStart
// Year-loop fetch populates SeedRepos from commitContributionsByRepository
// plus the all-time contribution calendar; must precede FetchProductive so
// commit-history probes land on repos where the user actually committed.
if len(profile.ContributionYears) > 0 {
if err := c.FetchContributionsAllTime(ctx, profile, cfg.Options); err != nil {
if cfg.Strict {
return nil, fmt.Errorf("fetch all-time contributions: %w", err)
}
warnf("all-time contributions fetch: %v", err)
}
}
if profile.ID != "" && len(profile.SeedRepos) > 0 {
repos := profile.SeedRepos
if cfg.TopRepos > 0 && len(repos) > cfg.TopRepos {
repos = repos[:cfg.TopRepos]
}
if err := c.FetchProductive(ctx, profile, repos, loc, cfg.CommitsPerRepo); err != nil {
if cfg.Strict {
return nil, fmt.Errorf("fetch commit history: %w", err)
}
warnf("productive-time + commits-per-language fetch: %v", err)
}
}
return profile, nil
}
// TokenInfo describes the account behind a token and how far it reaches.
type TokenInfo struct {
Login string
// CanReadPrivate is true when the token can read private repositories.
// GitHub then counts contributions to those repos in every total and
// calendar it returns, not only in the repo lists.
CanReadPrivate bool
}
// TokenInfo identifies the client's token. A classic token is private-capable
// when its X-OAuth-Scopes include "repo"; any token is when it can list a
// private repository the viewer owns, collaborates on or reaches through an
// org (the only signal a fine-grained token gives).
func (c *Client) TokenInfo(ctx context.Context) (TokenInfo, error) {
var resp struct {
Viewer struct {
Login string `json:"login"`
Repositories struct {
TotalCount int `json:"totalCount"`
} `json:"repositories"`
} `json:"viewer"`
}
h, err := c.queryHeader(ctx, viewerQuery, nil, &resp)
if err != nil {
return TokenInfo{}, err
}
return TokenInfo{
Login: resp.Viewer.Login,
CanReadPrivate: resp.Viewer.Repositories.TotalCount > 0 || scopesIncludeRepo(h.Get("X-OAuth-Scopes")),
}, nil
}
// scopesIncludeRepo reports whether a classic token's scope list grants
// full repo access, which covers every private repo its owner can see.
func scopesIncludeRepo(scopes string) bool {
for _, s := range strings.Split(scopes, ",") {
if strings.TrimSpace(s) == "repo" {
return true
}
}
return false
}
// UTCOffsetLabel formats the location's current offset from UTC compactly:
//
// integer hours → "UTC+7" (no ".00" padding — 3 chars shorter than
// the old "UTC+7.00" format, keeps the
// productive-time title at 15 px)
// half-hour zone → "UTC+5:30" (India)
// quarter-hour → "UTC+5:45" (Nepal)
// negative zone → "UTC-3" / "UTC-3:30"
func UTCOffsetLabel(loc *time.Location) string {
_, offsetSec := time.Now().In(loc).Zone()
sign := "+"
if offsetSec < 0 {
sign = "-"
offsetSec = -offsetSec
}
hours := offsetSec / 3600
minutes := (offsetSec % 3600) / 60
if minutes == 0 {
return fmt.Sprintf("UTC%s%d", sign, hours)
}
return fmt.Sprintf("UTC%s%d:%02d", sign, hours, minutes)
}
// ParseWeekday maps a case-insensitive English weekday name (full or 3-letter)
// to time.Weekday. Empty input → Sunday so a blank action input still works.
func ParseWeekday(s string) (time.Weekday, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "sun", "sunday":
return time.Sunday, nil
case "mon", "monday":
return time.Monday, nil
case "tue", "tuesday":
return time.Tuesday, nil
case "wed", "wednesday":
return time.Wednesday, nil
case "thu", "thursday":
return time.Thursday, nil
case "fri", "friday":
return time.Friday, nil
case "sat", "saturday":
return time.Saturday, nil
default:
return time.Sunday, fmt.Errorf("unknown start-of-week %q", s)
}
}
+80
View File
@@ -0,0 +1,80 @@
package github
import (
"strings"
"testing"
"time"
)
// TestParseWeekday covers the common case-insensitive inputs and confirms
// an unknown value errors (the CLI then falls back to Sunday with a warn).
func TestParseWeekday(t *testing.T) {
ok := []struct {
in string
want time.Weekday
}{
{"", time.Sunday},
{"sunday", time.Sunday},
{"SUNDAY", time.Sunday},
{"Sun", time.Sunday},
{" monday ", time.Monday},
{"Mon", time.Monday},
{"saturday", time.Saturday},
}
for _, c := range ok {
got, err := ParseWeekday(c.in)
if err != nil {
t.Errorf("ParseWeekday(%q) err=%v", c.in, err)
}
if got != c.want {
t.Errorf("ParseWeekday(%q)=%v want %v", c.in, got, c.want)
}
}
if _, err := ParseWeekday("moonday"); err == nil {
t.Error("ParseWeekday(moonday): want error, got nil")
}
}
// TestUTCOffsetLabel checks the compact format: integer hours drop the
// minutes suffix, non-zero offsets render as `UTC±H:MM`.
func TestUTCOffsetLabel(t *testing.T) {
cases := []struct {
zone string
want string // must appear in the label; exact value varies by DST
}{
{"UTC", "UTC+0"},
{"Asia/Saigon", "UTC+7"},
{"Asia/Kolkata", "UTC+5:30"}, // half-hour zone
{"Asia/Kathmandu", "UTC+5:45"}, // quarter-hour zone
}
for _, tc := range cases {
loc, err := time.LoadLocation(tc.zone)
if err != nil {
t.Skipf("%s unavailable: %v", tc.zone, err)
}
got := UTCOffsetLabel(loc)
if !strings.Contains(got, tc.want) {
t.Errorf("UTCOffsetLabel(%q) = %q, want prefix %q", tc.zone, got, tc.want)
}
}
}
// TestScopesIncludeRepo checks that only the full "repo" scope marks a
// classic token as private-capable.
func TestScopesIncludeRepo(t *testing.T) {
cases := map[string]bool{
"": false,
"read:user": false,
"read:user, public_repo": false,
"repo:status, read:user": false,
"read:user, repo": true,
"repo": true,
"gist,repo,workflow": true,
"read:org, read:user, repo": true,
}
for in, want := range cases {
if got := scopesIncludeRepo(in); got != want {
t.Errorf("scopesIncludeRepo(%q) = %v, want %v", in, got, want)
}
}
}
+8 -8
View File
@@ -22,14 +22,14 @@ type Profile struct {
RepoCount int
// Totals for the stats card.
TotalStars int
TotalForks int
TotalCommits int // last year, from contributionsCollection
TotalCommitsAllTime int // sum across contributionYears
TotalPRs int
TotalIssues int
TotalReviews int
TotalContributedTo int
TotalStars int
TotalForks int
TotalCommits int // last year, from contributionsCollection
TotalCommitsAllTime int // sum across contributionYears
TotalPRs int
TotalIssues int
TotalReviews int
TotalContributedTo int
TotalContributionsLastYear int // contributionCalendar.totalContributions + restrictedContributionsCount (last year)
// Count of owned repos grouped by primary language, sorted desc by Value.
+12
View File
@@ -128,3 +128,15 @@ query($login: String!, $from: DateTime!, $to: DateTime!) {
}
}
}`
// viewerQuery names the account behind the token and probes whether the
// token can read any private repository, so the web server can refuse to
// publish data a token sees beyond the public view.
const viewerQuery = `query {
viewer {
login
repositories(privacy: PRIVATE, ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER], first: 1) {
totalCount
}
}
}`