From 4d1e6c8d9afc2f0cf3d54bd243f6b1f2e972fc8b Mon Sep 17 00:00:00 2001 From: zhravan Date: Wed, 17 Sep 2025 04:29:02 +0530 Subject: [PATCH 1/3] docs: OSS starter docs --- .github/ISSUE_TEMPLATE/bug_report.md | 32 +++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 +++++++ .github/PULL_REQUEST_TEMPLATE.md | 17 +++++ CODE_OF_CONDUCT.md | 27 ++++++++ CONTRIBUTING.md | 80 +++++++++++++++++++++++ README.md | 5 ++ SECURITY.md | 13 ++++ 7 files changed, 197 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..80c2435 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[Bug] " +labels: bug +assignees: '' +--- + +### Describe the bug + +A clear and concise description of what the bug is. + +### To Reproduce + +Steps to reproduce the behavior: + +1. Command(s) run +2. Output + +### Expected behavior + +What you expected to happen. + +### Environment + +- OS: +- Go version: +- GoLearn version: + +### Additional context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..cc6459a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +### Problem + +What problem does this feature solve? + +### Proposal + +Describe the solution you'd like. + +### Alternatives + +Describe alternatives you've considered. + +### Additional context + +Add any other context or screenshots. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fc89ebb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## Summary + +Describe the change and its motivation. + +## Checklist + +- [ ] Tests pass: `make verify` or `golearn verify ` +- [ ] Docs updated (README/CONTRIBUTING) if needed +- [ ] No large new dependencies + +## Screenshots / Output (if CLI UX) + +Paste before/after where helpful. + +## Related issues + +Fixes # diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..23db016 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,27 @@ +# Code of Conduct + +This project follows the Contributor Covenant Code of Conduct. + +- For maintainers and contributors: please act with empathy and respect. +- Harassment and discrimination are not tolerated. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. + +## Our Standards + +- Use welcoming and inclusive language +- Be respectful of differing viewpoints and experiences +- Gracefully accept constructive criticism +- Focus on what is best for the community + +## Enforcement + +Report unacceptable behavior to the maintainers at: . + +Project maintainers are responsible for clarifying and enforcing standards of acceptable behavior and will take appropriate and fair corrective action. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8261aba --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing to GoLearn + +Thanks for your interest in contributing! This project aims to be a friendly, welcoming place to learn and teach Go. + +## Ways to contribute + +- Report bugs and propose enhancements +- Improve exercises, hints, and documentation +- Add new exercises or solutions (see below) +- Improve the CLI UX and accessibility + +## Development setup + +```bash +# Clone your fork +git clone https://github.com//golearn +cd golearn + +# Build the CLI +make build # or: go build ./cmd/golearn + +# Run locally +./bin/golearn help + +# Initialize embedded exercises into a workspace +mkdir -p /tmp/golearn-ws && cd /tmp/golearn-ws +/path/to/bin/golearn init +``` + +## Running tests + +This repository embeds template tests. To run tests for an exercise from the CLI repo: + +```bash +# From repo root +make verify NAME=01_hello # or: go run ./cmd/golearn verify 01_hello +``` + +## Code style + +- Go 1.22+ +- Match existing formatting; run `gofmt` and `go vet` +- Prefer clear, readable code; add concise comments for non-obvious logic +- Avoid adding heavy deps; keep the binary small and portable + +## Git workflow + +1. Create a feature branch +2. Make focused commits with descriptive messages +3. Ensure `make build` and `make verify` pass +4. Open a PR with a clear title and description (screenshots welcome) + +## Adding or updating exercises + +- Edit templates under `internal/exercises/templates/` +- Keep tests self-contained in the exercise folder +- Provide hints in `internal/exercises/catalog.yaml` +- Do not add solution code into templates; see solutions below + +## Adding solutions + +- Place solutions in `internal/exercises/solutions/` +- Only include implementation files; do not include tests +- Validate locally: + +```bash +# Validate embedded solution against embedded tests +./bin/golearn verify --solution +``` + +## Docs and UX + +- Update `README.md` if you add a new command or flag +- Keep output accessible; follow `internal/cli/theme` guidance + +## License + +- Code is MIT. Non-code lesson content and artwork are under CC BY 3.0 (see CONTENT_LICENSE). + +Please also read the Code of Conduct. diff --git a/README.md b/README.md index 158aa79..f50ec18 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ golearn progress # Rich TUI with ASCII progress bar and checklist golearn watch # Watches ./exercises and re-runs tests per edited exercise ``` +### Contributing +- See [CONTRIBUTING.md](./CONTRIBUTING.md) +- Please follow our [Code of Conduct](./CODE_OF_CONDUCT.md) +- Security issues: see [SECURITY.md](./SECURITY.md) + Need commands? ```bash diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c20b998 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +We take security seriously. Please report vulnerabilities responsibly. + +## Supported versions +We generally support the latest minor release of the CLI. + +## Reporting a vulnerability +- Email: security@example.com +- Please include steps to reproduce and any PoC code if available +- Do not open public issues for security problems + +We will acknowledge your report within 72 hours and keep you updated. From 0cd15bf3b922e259c386202135b2eaff3fbd74d0 Mon Sep 17 00:00:00 2001 From: zhravan Date: Wed, 17 Sep 2025 05:06:07 +0530 Subject: [PATCH 2/3] feat(gamification): leaderboard gamification for fun --- README.md | 27 ++- internal/cli/cli.go | 10 ++ internal/cli/commands.go | 360 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+), 3 deletions(-) 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..3ee29fc 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,362 @@ 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 { + for i, r := range rows { + section.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, r.User, 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:] +} From 121d473aaeee3468bdc6d888e421d20b59d243ca Mon Sep 17 00:00:00 2001 From: zhravan Date: Wed, 17 Sep 2025 07:29:39 +0530 Subject: [PATCH 3/3] feat(gamification): tabular preview of the progress --- internal/cli/commands.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 3ee29fc..5fadb54 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -776,8 +776,12 @@ func updateLeaderboardReadme(repoDir string, progressDir string) error { if len(rows) == 0 { section.WriteString("No completions yet. Be the first!\n") } else { - for i, r := range rows { - section.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, r.User, r.Timestamp)) + 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")