mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-14 18:18:32 +00:00
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:
1 parent
af420f0db7
commit
ad775adffa
33 files changed
+3282
-179
No files matched your search
+18
-13
@@ -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]) + "…"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}`
|
||||
Reference in new issue
Block a user