diff --git a/README.md b/README.md index f50ec18..fce0104 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,22 @@
Rustlings‑style Go exercises in a tiny CLI.

+
+ +
+ ### Why this exists This project is my attempt to learn Go by building as I learn, making the journey more engaging while exploring the language in practice. I'm sharing the exercises and tooling so others can learn alongside me. +### At a glance + +- Simple CLI to list and verify exercises +- Helpful hints and solution links when you're stuck +- Watch mode to auto-run tests on changes +- Progress dashboard with a visual bar and checklists +- Publish your progress to GitHub and appear on the README leaderboard + ### Install (Go 1.22+) ```bash @@ -35,9 +47,14 @@ golearn progress # Rich TUI with ASCII progress bar and checklist # Auto-verify on change (watch mode) golearn watch # Watches ./exercises and re-runs tests per edited exercise + +# Publish your progress (appears on README leaderboard) +golearn publish --dry-run +golearn publish --user ``` ### Contributing + - See [CONTRIBUTING.md](./CONTRIBUTING.md) - Please follow our [Code of Conduct](./CODE_OF_CONDUCT.md) - Security issues: see [SECURITY.md](./SECURITY.md) @@ -72,6 +89,10 @@ make watch - Non-code lesson content and any included gopher artwork: [CC BY 3.0](./CONTENT_LICENSE). - Inspired by rustlings and Go by Example. [NOTICE](./NOTICE) for attributions. -
- -
+## Leaderboard + +The following users have completed all exercises (ascending by completion time): + + +No completions yet. Be the first! + diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8238351..05500f5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -19,6 +19,7 @@ Usage: golearn solution [name] Show solution flow (hint-first; or link) golearn watch Watch files and re-run tests on change golearn progress Show progress + golearn publish [options] Publish your progress to upstream as a PR golearn reset [name] Reset exercise to starter state golearn init [repo] [dir] Initialize workspace: clone exercises repo or copy built-in templates golearn help Show this help @@ -27,6 +28,12 @@ Global options: --no-color Disable ANSI colors (honors NO_COLOR) --theme= Theme: default | high-contrast | monochrome --screen-reader, --sr Optimize for screen readers; avoid screen clears + +Publish options (env overrides in parentheses): + --repo= Upstream repo to contribute to (GOLEARN_PUBLISH_REPO) + --user= Your GitHub username (GOLEARN_PUBLISH_USER) + --branch= Branch to create for the PR + --dry-run Print JSON snapshot instead of creating a PR ` } @@ -72,6 +79,9 @@ func Execute(args []string) error { return runWatch() case "progress": return runProgress() + case "publish": + // pass through remaining args to the publish handler + return runPublish(args[1:]) case "reset": var name string if len(args) > 1 { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 2b4d40b..5fadb54 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "os" "os/exec" "os/signal" @@ -463,3 +464,366 @@ func progressBarWidth() int { } return 60 } + +// runPublish collects local progress and attempts to create a PR against the upstream repo +// adding/refreshing a JSON snapshot under contrib/leaderboard/progress/.json. +// It prefers the GitHub CLI if available and the user is authenticated. Otherwise, it +// prints the snapshot and manual steps. +func runPublish(args []string) error { + repoURL, userName, branchName, dryRun := parsePublishFlags(args) + if repoURL == "" { + repoURL = strings.TrimSpace(os.Getenv("GOLEARN_PUBLISH_REPO")) + if repoURL == "" { + repoURL = "https://github.com/zhravan/golearn" + } + } + if userName == "" { + userName = strings.TrimSpace(os.Getenv("GOLEARN_PUBLISH_USER")) + } + if branchName == "" { + branchName = fmt.Sprintf("progress/%s-%s", sanitizeForFile(userName), time.Now().Format("20060102-150405")) + } + + // Build snapshot + snap, err := buildProgressSnapshot(userName) + if err != nil { + return err + } + + if dryRun { + b, _ := json.MarshalIndent(snap, "", " ") + fmt.Println(string(b)) + fmt.Println(theme.Muted("Dry-run: not creating a PR.")) + return nil + } + + // Prefer GitHub CLI if present + if _, lookErr := exec.LookPath("gh"); lookErr != nil { + fmt.Println(theme.Muted("GitHub CLI not found. Printing snapshot and manual steps.")) + return printManualPublishInstructions(repoURL, snap) + } + + // Ensure auth + if err := exec.Command("gh", "auth", "status").Run(); err != nil { + fmt.Println(theme.Muted("GitHub CLI not authenticated. Run 'gh auth login' first.")) + return printManualPublishInstructions(repoURL, snap) + } + + // If username is still empty, derive from gh + if strings.TrimSpace(userName) == "" { + out, err := exec.Command("gh", "api", "user", "-q", ".login").Output() + if err == nil { + userName = strings.TrimSpace(string(out)) + } + } + + owner, name := parseRepoOwnerAndName(repoURL) + if owner == "" || name == "" { + return fmt.Errorf("unable to parse repo URL: %s", repoURL) + } + + // Ensure fork exists (no-op if already exists) + _ = exec.Command("gh", "repo", "fork", fmt.Sprintf("%s/%s", owner, name), "--clone=false", "--remote=false").Run() + + // Work in a temporary clone of upstream + tmpDir, err := ioutil.TempDir("", "golearn-publish-") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + cloneCmd := exec.Command("git", "clone", repoURL, ".") + cloneCmd.Dir = tmpDir + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + + // Create branch + if err := runCmd(tmpDir, "git", "checkout", "-b", branchName); err != nil { + return err + } + + // Write snapshot file + progressDir := filepath.Join(tmpDir, "contrib", "leaderboard", "progress") + if err := os.MkdirAll(progressDir, 0o755); err != nil { + return err + } + fileName := sanitizeForFile(userName) + if fileName == "" { + fileName = "anonymous" + } + filePath := filepath.Join(progressDir, fileName+".json") + b, _ := json.MarshalIndent(snap, "", " ") + if err := os.WriteFile(filePath, b, 0o644); err != nil { + return err + } + + // Update README leaderboard section + if err := updateLeaderboardReadme(tmpDir, progressDir); err != nil { + fmt.Printf("Warning: could not update README leaderboard: %v\n", err) + } + + if err := runCmd(tmpDir, "git", "add", filePath); err != nil { + return err + } + _ = runCmd(tmpDir, "git", "add", filepath.Join(tmpDir, "README.md")) + msg := fmt.Sprintf("chore(leaderboard): add progress for %s (%d/%d)", snap.User, snap.CompletedCount, snap.TotalCount) + if err := runCmd(tmpDir, "git", "commit", "-m", msg); err != nil { + return err + } + + // Add fork remote and push + forkURL := fmt.Sprintf("https://github.com/%s/%s.git", userName, name) + _ = runCmd(tmpDir, "git", "remote", "remove", "fork") + if err := runCmd(tmpDir, "git", "remote", "add", "fork", forkURL); err != nil { + return err + } + if err := runCmd(tmpDir, "git", "push", "-u", "fork", branchName+":"+branchName); err != nil { + return err + } + + // Create PR against upstream + prTitle := fmt.Sprintf("Add progress for %s (%d/%d)", snap.User, snap.CompletedCount, snap.TotalCount) + prBody := "Automated progress publish from golearn CLI. This adds/updates your progress snapshot for the leaderboard." + prCmd := exec.Command("gh", "pr", "create", + "--repo", fmt.Sprintf("%s/%s", owner, name), + "--head", fmt.Sprintf("%s:%s", userName, branchName), + "--base", "main", + "--title", prTitle, + "--body", prBody, + ) + prCmd.Dir = tmpDir + prCmd.Stdout = os.Stdout + prCmd.Stderr = os.Stderr + if err := prCmd.Run(); err != nil { + fmt.Println(theme.Muted("Could not auto-create PR. You may need to open it manually.")) + fmt.Printf("Branch pushed to %s:%s\n", forkURL, branchName) + fmt.Printf("Open a PR against %s/%s with head %s:%s\n", owner, name, userName, branchName) + return nil + } + + return nil +} + +func parsePublishFlags(args []string) (repo, user, branch string, dry bool) { + for _, a := range args { + if strings.HasPrefix(a, "--repo=") { + repo = strings.TrimSpace(strings.TrimPrefix(a, "--repo=")) + continue + } + if strings.HasPrefix(a, "--user=") { + user = strings.TrimSpace(strings.TrimPrefix(a, "--user=")) + continue + } + if strings.HasPrefix(a, "--branch=") { + branch = strings.TrimSpace(strings.TrimPrefix(a, "--branch=")) + continue + } + if a == "--dry-run" || a == "--dry" { + dry = true + continue + } + } + return +} + +type progressSnapshot struct { + User string `json:"user"` + CompletedCount int `json:"completed_count"` + TotalCount int `json:"total_count"` + Percent int `json:"percent"` + CompletedSlugs []string `json:"completed_slugs"` + Timestamp string `json:"timestamp"` +} + +func buildProgressSnapshot(user string) (progressSnapshot, error) { + catalog, err := exercises.ListAll() + if err != nil { + return progressSnapshot{}, err + } + var all []exercises.Exercise + all = append(all, catalog.Concepts...) + all = append(all, catalog.Projects...) + sort.Slice(all, func(i, j int) bool { return all[i].Slug < all[j].Slug }) + + var completed []string + for _, ex := range all { + ok, _ := progress.IsCompleted(ex.Slug) + if ok { + completed = append(completed, ex.Slug) + } + } + percent := 0 + if len(all) > 0 { + percent = int(float64(len(completed)) / float64(len(all)) * 100) + } + return progressSnapshot{ + User: user, + CompletedCount: len(completed), + TotalCount: len(all), + Percent: percent, + CompletedSlugs: completed, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +func sanitizeForFile(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return "" + } + // replace non-alphanumeric with '-' + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} + +func parseRepoOwnerAndName(repoURL string) (owner, name string) { + url := strings.TrimSpace(repoURL) + url = strings.TrimSuffix(url, ".git") + if strings.Contains(url, "github.com") { + // handle https and ssh + if strings.HasPrefix(url, "git@") { + // git@github.com:owner/name(.git) + parts := strings.SplitN(url, ":", 2) + if len(parts) == 2 { + rest := parts[1] + segs := strings.Split(strings.TrimPrefix(rest, "/"), "/") + if len(segs) >= 2 { + return segs[0], segs[1] + } + } + } else { + // https://github.com/owner/name + idx := strings.Index(url, "github.com/") + if idx >= 0 { + rest := url[idx+len("github.com/"):] + segs := strings.Split(strings.TrimPrefix(rest, "/"), "/") + if len(segs) >= 2 { + return segs[0], segs[1] + } + } + } + } + return "", "" +} + +func runCmd(dir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func printManualPublishInstructions(repoURL string, snap progressSnapshot) error { + b, _ := json.MarshalIndent(snap, "", " ") + fmt.Println(string(b)) + fmt.Println() + fmt.Println(theme.Muted("Manual steps to publish your progress:")) + fmt.Println("1) Fork the repository if not already: https://github.com/zhravan/golearn") + fmt.Println("2) Clone upstream, create a branch, add the JSON under contrib/leaderboard/progress/.json, commit, push to your fork, and open a PR.") + fmt.Println(" Repo:", repoURL) + return nil +} + +type leaderRow struct { + User string + Timestamp string +} + +func updateLeaderboardReadme(repoDir string, progressDir string) error { + entries, err := os.ReadDir(progressDir) + if err != nil { + return err + } + var rows []leaderRow + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + b, err := os.ReadFile(filepath.Join(progressDir, e.Name())) + if err != nil { + continue + } + var ps progressSnapshot + if err := json.Unmarshal(b, &ps); err != nil { + continue + } + if ps.TotalCount > 0 && ps.CompletedCount == ps.TotalCount { + rows = append(rows, leaderRow{User: ps.User, Timestamp: ps.Timestamp}) + } + } + // sort by timestamp ascending, then username + sort.Slice(rows, func(i, j int) bool { + if rows[i].Timestamp == rows[j].Timestamp { + return rows[i].User < rows[j].User + } + return rows[i].Timestamp < rows[j].Timestamp + }) + + // Build dynamic content only within markers + var section strings.Builder + section.WriteString("\n") + if len(rows) == 0 { + section.WriteString("No completions yet. Be the first!\n") + } else { + section.WriteString("| Image | Username | Date |\n") + section.WriteString("|---|---|---|\n") + for _, r := range rows { + avatar := fmt.Sprintf("https://github.com/%s.png?size=64", r.User) + profile := fmt.Sprintf("https://github.com/%s", r.User) + section.WriteString(fmt.Sprintf("| ![%s](%s) | [%s](%s) | %s |\n", r.User, avatar, r.User, profile, r.Timestamp)) + } + } + section.WriteString("\n") + + readmePath := filepath.Join(repoDir, "README.md") + old, err := os.ReadFile(readmePath) + if err != nil { + // If README missing, create a new README with header and section + var full strings.Builder + full.WriteString("## Leaderboard\n\n") + full.WriteString("The following users have completed all exercises (ascending by completion time):\n\n") + full.WriteString(section.String()) + return os.WriteFile(readmePath, []byte(full.String()), 0o644) + } + + // If markers are present, replace content between them; else append full section at end + updated := replaceBetweenMarkers(string(old), "", "", section.String()) + if updated == string(old) { + // markers not found; append header + section + var full strings.Builder + full.WriteString(string(old)) + if !strings.HasSuffix(string(old), "\n") { + full.WriteString("\n") + } + full.WriteString("\n## Leaderboard\n\n") + full.WriteString("The following users have completed all exercises (ascending by completion time):\n\n") + full.WriteString(section.String()) + updated = full.String() + } + return os.WriteFile(readmePath, []byte(updated), 0o644) +} + +func replaceBetweenMarkers(orig string, startMarker string, endMarker string, replacement string) string { + startIdx := strings.Index(orig, startMarker) + endIdx := strings.Index(orig, endMarker) + if startIdx == -1 || endIdx == -1 || endIdx < startIdx { + // append section at end + if strings.HasSuffix(orig, "\n") { + return orig + "\n" + replacement + } + return orig + "\n\n" + replacement + } + endIdx += len(endMarker) + return orig[:startIdx] + replacement + orig[endIdx:] +}