gem-contribute 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/workshop-issue.md +29 -0
  3. data/.github/workflows/auto-merge-kicked-tires.yml +88 -0
  4. data/CHANGELOG.md +24 -0
  5. data/CLAUDE.md +47 -0
  6. data/CONTRIBUTING.md +46 -0
  7. data/KICKED_THE_TIRES.yml +22 -0
  8. data/LICENSE +21 -0
  9. data/MAINTAINER.md +92 -0
  10. data/README.md +89 -0
  11. data/Rakefile +10 -0
  12. data/docs/_config.yml +30 -0
  13. data/docs/adr/0001-just-in-time-auth.md +44 -0
  14. data/docs/adr/0002-bundler-lockfile-parser.md +35 -0
  15. data/docs/adr/0003-issue-tracker-preference.md +33 -0
  16. data/docs/adr/0004-device-flow-auth.md +36 -0
  17. data/docs/adr/0005-render-labels-verbatim.md +46 -0
  18. data/docs/adr/0006-standalone-gem-not-plugin.md +31 -0
  19. data/docs/adr/0007-display-contributing-verbatim.md +39 -0
  20. data/docs/adr/0008-rooibos-tui-framework.md +62 -0
  21. data/docs/adr/0009-top-level-namespace.md +37 -0
  22. data/docs/adr/README.md +21 -0
  23. data/docs/claude-code-prompt.md +40 -0
  24. data/docs/design.md +234 -0
  25. data/docs/index.md +102 -0
  26. data/docs/prep-plan.md +165 -0
  27. data/docs/workshop.md +60 -0
  28. data/exe/gem-contribute +7 -0
  29. data/lib/gem_contribute/auth.rb +161 -0
  30. data/lib/gem_contribute/cache.rb +98 -0
  31. data/lib/gem_contribute/cli/auth.rb +164 -0
  32. data/lib/gem_contribute/cli/config.rb +87 -0
  33. data/lib/gem_contribute/cli/fork_clone_branch.rb +197 -0
  34. data/lib/gem_contribute/cli/issues.rb +123 -0
  35. data/lib/gem_contribute/cli/scan.rb +117 -0
  36. data/lib/gem_contribute/cli/submit.rb +155 -0
  37. data/lib/gem_contribute/cli.rb +104 -0
  38. data/lib/gem_contribute/config.rb +60 -0
  39. data/lib/gem_contribute/errors.rb +32 -0
  40. data/lib/gem_contribute/host_adapter.rb +40 -0
  41. data/lib/gem_contribute/host_adapters/github_adapter.rb +215 -0
  42. data/lib/gem_contribute/locked_gem.rb +26 -0
  43. data/lib/gem_contribute/lockfile_parser.rb +61 -0
  44. data/lib/gem_contribute/project.rb +21 -0
  45. data/lib/gem_contribute/resolver.rb +131 -0
  46. data/lib/gem_contribute/token_store.rb +86 -0
  47. data/lib/gem_contribute/version.rb +5 -0
  48. data/lib/gem_contribute.rb +32 -0
  49. data/script/lint-kicked-tires.rb +76 -0
  50. data/sig/gem_contribute.rbs +3 -0
  51. metadata +114 -0
data/docs/index.md ADDED
@@ -0,0 +1,102 @@
1
+ ---
2
+ title: gem-contribute
3
+ ---
4
+
5
+ # gem-contribute
6
+
7
+ Find contributable issues in the gems your project already depends on.
8
+
9
+ ```sh
10
+ $ gem install gem-contribute
11
+ $ gem-contribute scan
12
+ 44 gems · 42 on github.com · 2 unknown source
13
+
14
+ Top contributable projects (by open `good first issue` count):
15
+ rubocop 4 github.com/rubocop/rubocop
16
+ rspec 1 github.com/rspec/rspec
17
+ reline 1 github.com/ruby/reline
18
+ ...
19
+ ```
20
+
21
+ The premise: the gems in your `Gemfile.lock` are the projects you have the most context on. If you depend on `sidekiq`, you have opinions about Sidekiq. That's a better starting point for open-source contribution than scanning all of GitHub for `good-first-issue` tags and hoping one looks interesting.
22
+
23
+ ## Quick start
24
+
25
+ ```sh
26
+ gem install gem-contribute # one-time install
27
+ gem-contribute auth login # one-time GitHub OAuth (device flow, no token paste)
28
+ gem-contribute scan # see what's worth contributing to
29
+ gem-contribute issues rubocop # drill into one project's issues
30
+ gem-contribute fix rubocop/12345 # fork, clone, branch (~/code/oss/<owner>/<repo>)
31
+ # ... make your change, commit ...
32
+ gem-contribute submit # push, then open the PR compare page in your browser
33
+ ```
34
+
35
+ [Full command reference →](#commands) ・ [Configuration →](#configuration)
36
+
37
+ ## Status
38
+
39
+ - **v0.1**: a CLI with `scan`, `issues`, `auth`, `fix`, `submit`, and `config`. GitHub-only.
40
+ - **Planned**: a Rooibos TUI that does all of the above as a single keyboard-driven session ([issue #2](https://github.com/cdhagmann/gem-contribute/issues/2)).
41
+ - **Workshop project**: built for [Blue Ridge Ruby 2026](https://blueridgeruby.com).
42
+
43
+ ## Commands
44
+
45
+ | Command | What it does |
46
+ |---|---|
47
+ | `gem-contribute scan [path]` | Parse `Gemfile.lock`, resolve each gem to its source repo, rank GitHub-hosted projects by open `good first issue` count. |
48
+ | `gem-contribute issues <gem>` | List the open good-first-issues for one gem with number, title, and URL. |
49
+ | `gem-contribute issues all` | Iterate every github.com gem in the lockfile; print only those with open issues. |
50
+ | `gem-contribute auth login` | Authenticate with GitHub via OAuth device flow (no token paste, no client secret). |
51
+ | `gem-contribute auth status` | Show whether the cached token is still valid. |
52
+ | `gem-contribute auth logout` | Drop the cached token. |
53
+ | `gem-contribute fix <gem>/<n>` | Fork the gem's repo, clone the fork to `<clone_root>/<owner>/<repo>`, branch from default. Alias: `fork-clone-branch`. |
54
+ | `gem-contribute submit` | From inside a clone, push the current branch and open a pre-filled PR compare page in your browser. |
55
+ | `gem-contribute config set <k> <v>` | Persist user preferences. |
56
+ | `gem-contribute config list` | Show current configuration. |
57
+
58
+ Global flags: `--refresh` (clear cache), `--version`, `-h/--help`.
59
+
60
+ ## Configuration
61
+
62
+ User config lives at `~/.config/gem-contribute/config.yml`.
63
+
64
+ | Key | Default | Notes |
65
+ |---|---|---|
66
+ | `clone_root` | `~/code/oss` | Where `fix` clones forks (`<root>/<owner>/<repo>`). |
67
+
68
+ Manage with `gem-contribute config set <key> <value>` rather than editing the YAML by hand.
69
+
70
+ ## Authentication
71
+
72
+ `gem-contribute auth login` uses GitHub's [OAuth device flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow). The flow:
73
+
74
+ 1. The CLI requests a one-time code from GitHub.
75
+ 2. Your terminal prints the code and (on macOS / Linux) copies it to your clipboard and opens [github.com/login/device](https://github.com/login/device) in your browser.
76
+ 3. You paste the code, click Authorize.
77
+ 4. The CLI's pending poll succeeds; the token is cached at `~/.config/gem-contribute/auth.json` (mode 0600).
78
+
79
+ This is the same UX `gh auth login` uses. The OAuth App is `gem-contribute`, registered to the gem's maintainer; users do not need to register their own.
80
+
81
+ Tokens are scoped to `public_repo` only — enough to fork, clone, and read public issues, not enough to touch private repositories. If you ever want to revoke, visit [your authorized apps](https://github.com/settings/applications) and remove `gem-contribute`.
82
+
83
+ ## Design
84
+
85
+ For the architecture overview, see [`design.md`](design.md). For specific decisions and the reasoning behind them, see the [ADRs](adr/).
86
+
87
+ The short version:
88
+
89
+ - **Scan first, auth lazily.** No token needed to read public issue counts.
90
+ - **Abstract the host.** GitHub today, GitLab and others later. The data model is host-agnostic.
91
+ - **Render verbatim.** Don't normalize labels. Don't summarize CONTRIBUTING.md.
92
+ - **No threads.** All async work is structured for Rooibos Commands so the TUI can wrap it without rewrites.
93
+
94
+ ## Contributing
95
+
96
+ The tool is *for* finding contributable projects, so it had better be one. See [`CONTRIBUTING.md`](https://github.com/cdhagmann/gem-contribute/blob/main/CONTRIBUTING.md) and [open issues tagged `good first issue`](https://github.com/cdhagmann/gem-contribute/issues?q=is%3Aopen+label%3A%22good+first+issue%22).
97
+
98
+ If you're attending Blue Ridge Ruby 2026 and arrived here from the workshop, see [`workshop.md`](workshop.md) for the exercises.
99
+
100
+ ## License
101
+
102
+ MIT. See [LICENSE](https://github.com/cdhagmann/gem-contribute/blob/main/LICENSE).
data/docs/prep-plan.md ADDED
@@ -0,0 +1,165 @@
1
+ # Pre-conference prep plan
2
+
3
+ The Blue Ridge Ruby workshop is **April 30 – May 1, 2026**. This document defines what "ready" means and the order of operations to get there.
4
+
5
+ This plan is meant to be executed agentically by Claude Code, with Chris reviewing at stage boundaries. Read [`CLAUDE.md`](../CLAUDE.md), [`docs/design.md`](design.md), and the [`ADRs`](adr/) before starting. They define the architecture and constraints.
6
+
7
+ ## Honest scope estimate
8
+
9
+ The five stages below total **roughly 12–20 hours of focused work**. That's more than three weeknight evenings. The plan is structured so that **each stage produces a usefully-complete artifact** — if you run out of time, you stop at the last finished stage and the workshop still works.
10
+
11
+ Minimum viable workshop = Stages 1, 2, and 4 done. Stage 3 (the TUI) can become the workshop itself if it isn't built ahead.
12
+
13
+ ## Order of work
14
+
15
+ ### Stage 1 — Data pipeline end-to-end
16
+
17
+ **Goal:** A CLI script that reads `Gemfile.lock` from the current directory, resolves source URLs via RubyGems, and prints a summary table — without auth, without the TUI, without the action. Proves the data layer works.
18
+
19
+ **Acceptance:**
20
+
21
+ - [ ] `gem-contribute scan` (or equivalent) prints something like:
22
+ ```
23
+ 47 gems · 44 on github.com · 2 on gitlab.com · 1 unknown source
24
+ Top contributable projects (by open `good first issue` count):
25
+ sidekiq 5 github.com/sidekiq/sidekiq
26
+ standard 3 github.com/standardrb/standard
27
+ ...
28
+ ```
29
+ - [ ] `LockfileParser` wraps `Bundler::LockfileParser` (per ADR-0002), returns `Gem` structs
30
+ - [ ] `Resolver` hits RubyGems v1 API anonymously, prefers `bug_tracker_uri` (per ADR-0003), falls back per the ADR
31
+ - [ ] `HostAdapter` interface defined; `GitHubAdapter` implements the unauthenticated read methods (`issues`, `community_profile`, `file_contents`)
32
+ - [ ] Disk caching at `~/.cache/gem-contribute/` per the design doc
33
+ - [ ] Unit tests for the parser and resolver. VCR cassettes for the adapter. All commit-clean.
34
+ - [ ] `bin/rspec` and `bin/rubocop` pass
35
+
36
+ **Deliberately not in this stage:**
37
+ - No auth code. Anonymous GitHub API only.
38
+ - No TUI. CLI output via plain `puts`.
39
+ - No fork-clone-branch. That's Stage 2.
40
+ - No Rooibos dependency yet.
41
+
42
+ **Stop here and check in with Chris.** Demo the script against `gem-contribute`'s own `Gemfile.lock`. The output should make Chris want to keep going.
43
+
44
+ ### Stage 2 — Auth and the action
45
+
46
+ **Goal:** Add device-flow auth and the fork-clone-branch action. Still no TUI; everything is CLI flags. Proves the auth and action layers work.
47
+
48
+ **Acceptance:**
49
+
50
+ - [ ] `Auth` module implements the OAuth 2.0 Device Authorization Grant against `github.com` per ADR-0004
51
+ - [ ] OAuth App client ID is a public constant in source (no secret); document the registration step in a `MAINTAINER.md` or similar
52
+ - [ ] Token storage at `~/.config/gem-contribute/auth.json`, mode 0600
53
+ - [ ] Polling respects `slow_down` errors and the 15-minute device-code expiry
54
+ - [ ] `gem-contribute auth login` and `gem-contribute auth status` CLI commands work
55
+ - [ ] `GitHubAdapter` gains `fork`, `already_forked?` methods that raise `AuthRequired` if no token
56
+ - [ ] A `fork-clone-branch` CLI subcommand takes a `gem/issue_number` argument, performs the full sequence, prints the local path
57
+ - [ ] Unit tests for the auth state machine (the protocol is deterministic — test it)
58
+ - [ ] Integration test gated on `GEM_CONTRIBUTE_INTEGRATION=1` against a small friendly gem
59
+ - [ ] **Use the tool to open one real PR** — even a typo fix in a README. The proof that the architecture works.
60
+
61
+ **Deliberately not in this stage:**
62
+ - No TUI. The workflow is multiple CLI invocations.
63
+ - No JIT prompting. If unauthenticated, error and tell the user to run `auth login`.
64
+ - Scope is `public_repo` only.
65
+
66
+ **Stop here and check in with Chris.** Demo the full CLI flow end-to-end. If the architecture has problems, this is when they show up.
67
+
68
+ ### Stage 3 — TUI with Rooibos
69
+
70
+ **Goal:** The full TUI per the design doc. Four fragments + auth overlay. JIT auth working through MVU state transitions. This is the v0.1 the workshop attendees see.
71
+
72
+ **Acceptance:**
73
+
74
+ - [ ] `rooibos` pinned to `~> 0.7.0` in the gemspec (verify the pinned version against current rubygems.org before committing)
75
+ - [ ] `ProjectList` fragment: lists gems from the lockfile with issue counts (lazy-loaded via `Command.http`)
76
+ - [ ] `IssueList` fragment: open issues for selected project, labels rendered verbatim per ADR-0005
77
+ - [ ] `IssueDetail` fragment: body, labels, action keys (`f`, `c`, `o`)
78
+ - [ ] `ContributingViewer` fragment: rendered markdown per ADR-0007
79
+ - [ ] `AuthOverlay` fragment: device-flow prompt that fires on `:auth_required`, retries the original action on success
80
+ - [ ] All async work goes through Rooibos Commands. No `Thread.new`, no `Async`.
81
+ - [ ] `Update` tests for every fragment, covering at minimum each key handler and each command-result message
82
+ - [ ] At least one snapshot test for the main flow (project list → issue list → issue detail)
83
+ - [ ] Status bar showing rate limit remaining
84
+ - [ ] `q` quits, `Ctrl+C` quits, `?` shows help overlay (or note help is unimplemented in the README)
85
+ - [ ] `bin/gem-contribute` from any directory with a `Gemfile.lock` launches the TUI
86
+
87
+ **Deliberately not in this stage:**
88
+ - No label normalization (ADR-0005)
89
+ - No CONTRIBUTING parsing (ADR-0007)
90
+ - No private-repo support
91
+ - No `Worker` orchestrator class — fork-clone-branch is a state machine in `Update`
92
+
93
+ **Stop and check in with Chris.** This is the demo for the workshop opening.
94
+
95
+ ### Stage 4 — Workshop issues
96
+
97
+ **Goal:** Twelve good-first-issue tickets that workshop attendees can pick from. The meta-joke: a tool for finding good first issues that itself has good first issues.
98
+
99
+ **Acceptance:**
100
+
101
+ - [ ] Twelve markdown files in `docs/workshop-issues/`, one per issue, using the template at `.github/ISSUE_TEMPLATE/workshop-issue.md`
102
+ - [ ] Each issue is genuinely scoped to ~30 minutes by someone who hasn't seen the codebase
103
+ - [ ] Each issue points at a specific file or module
104
+ - [ ] Each issue has acceptance criteria that are testable
105
+ - [ ] Each issue links to relevant ADRs if a decision constrains the implementation
106
+ - [ ] Mix of difficulty: ~4 trivial (status bar tweak, empty state copy, new keybinding), ~6 moderate (new feature, small refactor, additional adapter method), ~2 stretch (rate-limit handling, accessibility pass)
107
+
108
+ **Suggested topics** (pick the strongest 12, generate more if needed):
109
+
110
+ - Rate-limit indicator in the status bar
111
+ - `homepage_uri` fallback for unresolved gems
112
+ - `r` to refresh the current view
113
+ - CONTRIBUTING preview in the issue detail pane
114
+ - `--version` flag
115
+ - Better empty state when no gems have GitHub URLs
116
+ - Highlight preferred labels (per config) in the issue list
117
+ - `?` help overlay
118
+ - "Authenticated as @user" indicator
119
+ - Sort gems by issue count
120
+ - Skip path/git source gems with a clear status line
121
+ - Confirmation dialog before fork-clone-branch
122
+ - `o` to open the gem's homepage in browser
123
+ - "Last updated" warning for stale-looking gems
124
+
125
+ **Deliberately not in this stage:**
126
+ - Don't create the actual GitHub issues yet. Markdown files in the repo. Chris will create the GitHub issues himself once the repo is public, using these as the source.
127
+
128
+ ### Stage 5 — Workshop tutorial polish
129
+
130
+ **Goal:** Polish `docs/workshop.md` so attendees can follow it end-to-end without help. Add anything attendees need that isn't already in the README.
131
+
132
+ **Acceptance:**
133
+
134
+ - [ ] `docs/workshop.md` covers: pre-arrival setup (Ruby version, Rust toolchain, GitHub account), repo clone, `bundle install`, first-run device flow, the workshop arc
135
+ - [ ] Pre-reading section linking to Rooibos's "Why Rooibos" and Rails-developer guide
136
+ - [ ] Setup troubleshooting section for common build failures (`ratatui_ruby` Rust toolchain, `rooibos` installation issues, GitHub OAuth quirks)
137
+ - [ ] A "first PR template" — a 5-step walkthrough for an attendee who's never opened a PR, using one of the workshop issues as the example
138
+ - [ ] README's Quick Start section mirrors the workshop setup steps so non-attendees have the same path
139
+
140
+ **Deliberately not in this stage:**
141
+ - No video or screencast. Words on a page is fine.
142
+ - No deep Ratatui or Rooibos tutorial — link out to upstream docs.
143
+
144
+ ## Definition of "ready for the workshop"
145
+
146
+ You're ready when, on a fresh laptop:
147
+
148
+ 1. `git clone … && cd gem-contribute && bundle install` succeeds
149
+ 2. `bin/gem-contribute` launches the TUI against a real `Gemfile.lock`
150
+ 3. `f` on an issue completes the fork-clone-branch flow with the device-flow prompt firing
151
+ 4. The workshop issues are visible on the public GitHub repo with the `workshop` label
152
+ 5. `docs/workshop.md` reads cleanly to someone who hasn't seen the project
153
+
154
+ Anything else is a stretch goal.
155
+
156
+ ## What to do if you're behind schedule
157
+
158
+ Cut in this order:
159
+
160
+ 1. **Skip Stage 5 polish.** Workshop README at minimum-viable quality is fine.
161
+ 2. **Cut Stage 4 to 6 issues instead of 12.** Quality matters more than count.
162
+ 3. **Cut Stage 3 fragments.** `ContributingViewer` is the most droppable; users can read CONTRIBUTING in their browser. `AuthOverlay` can fall back to a CLI prompt during `f` action.
163
+ 4. **If Stage 3 doesn't ship at all:** the workshop becomes "let's build the TUI together." This is honestly fine and might be a *better* workshop. Be ready to pivot the framing.
164
+
165
+ Don't cut tests to save time. Tests are how this gets maintained after you're tired.
data/docs/workshop.md ADDED
@@ -0,0 +1,60 @@
1
+ # Workshop — Blue Ridge Ruby 2026
2
+
3
+ 90-minute workshop, then open hacking.
4
+
5
+ ## Premise
6
+
7
+ You depend on dozens of gems. Some of those projects need help. This tool finds the overlap. We built v0.1; you're going to make it better.
8
+
9
+ ## Before the workshop
10
+
11
+ - Ruby 3.2+
12
+ - A GitHub account
13
+ - 5 minutes to clone and `bundle install`
14
+
15
+ ```
16
+ git clone https://github.com/cdhagmann/gem-contribute
17
+ cd gem-contribute
18
+ bundle install
19
+ bin/gem-contribute
20
+ ```
21
+
22
+ If `bundle install` complains about `ratatui_ruby` building a native extension, you need a Rust toolchain. On macOS: `xcode-select --install` is usually enough. On Linux: `sudo apt install build-essential` plus `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`.
23
+
24
+ If you can't get the build working, pair with someone who can. The workshop scales fine with two-person teams.
25
+
26
+ ## Arc
27
+
28
+ **0:00 — 0:15 · Demo and tour**
29
+
30
+ I run the tool against a real Gemfile.lock. We walk through what each pane does, then through the architecture (`docs/design.md`).
31
+
32
+ **0:15 — 0:30 · Architecture overview**
33
+
34
+ The four-stage pipeline: parse → resolve → adapt → render. Where things are, why they're separate, what an adapter looks like. The point is to give you enough mental model to know where your changes go.
35
+
36
+ **0:30 — 1:00 · Exercise: build a feature**
37
+
38
+ Pick one issue from `https://github.com/cdhagmann/gem-contribute/issues?q=label:workshop`. They're scoped to be doable in 30 minutes by someone who's never touched the codebase. Examples:
39
+
40
+ - Add a "rate limit remaining" indicator to the status bar
41
+ - Support `bug_tracker_uri` fallback to `homepage_uri` for older gems
42
+ - Add a `r` keybinding that refreshes the current view
43
+ - Show CONTRIBUTING.md preview in the issue detail pane
44
+
45
+ If you finish, pick another. If you don't finish, that's fine — open a PR with what you have and we'll land it together.
46
+
47
+ **1:00 — 1:30 · Show and tell, plus the contribute-the-tool flow**
48
+
49
+ Anyone who wants to demo their change does. Then I run `gem-contribute` against this repo's own Gemfile.lock and we use the tool to find issues to contribute to *in our actual dependencies*.
50
+
51
+ **1:30 — end of day · Open hacking**
52
+
53
+ Stay if you want. Work on this tool, or — better — use it on a project you depend on. The goal of the rest of the day is at least one merged PR per attendee, somewhere. Doesn't have to be here.
54
+
55
+ ## Ground rules
56
+
57
+ - Ask anyone, including me, anything.
58
+ - "I don't know" is a fine answer to anything; we figure it out together.
59
+ - If you're stuck on setup for more than 15 minutes, raise a hand.
60
+ - This is a hack day. Imperfect contributions are the point.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "gem_contribute"
5
+ require "gem_contribute/cli"
6
+
7
+ exit GemContribute::CLI.run(ARGV)
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module GemContribute
8
+ # OAuth 2.0 Device Authorization Grant against github.com. See ADR-0004.
9
+ #
10
+ # Pure state-machine design:
11
+ #
12
+ # request_device_code(client_id) → DeviceCode | raises AuthError
13
+ # poll(device_code, client_id) → Result (status: :ok | :pending |
14
+ # :slow_down | :expired |
15
+ # :denied | :error)
16
+ #
17
+ # The CLI orchestrates these with sleep-based polling. The future Stage 3
18
+ # TUI wraps the same functions in Rooibos Command.http / Command.wait
19
+ # without changing the protocol. ADR-0008 stays clean because the
20
+ # state-transition functions don't own any I/O orchestration themselves —
21
+ # they're pure request/response.
22
+ module Auth
23
+ DEVICE_CODE_URL = "https://github.com/login/device/code"
24
+ TOKEN_URL = "https://github.com/login/oauth/access_token"
25
+ DEFAULT_SCOPE = "public_repo"
26
+
27
+ # OAuth App Client ID. Public by design — see ADR-0004 and MAINTAINER.md.
28
+ # The sentinel below is intentionally unusable; replace with the real
29
+ # value after walking through MAINTAINER.md's OAuth App registration.
30
+ CLIENT_ID = ENV.fetch("GEM_CONTRIBUTE_CLIENT_ID", "Ov23liZNcwIo17OIVUsv")
31
+
32
+ DeviceCode = Data.define(:device_code, :user_code, :verification_uri, :expires_at, :interval) do
33
+ def expired?(now: Time.now)
34
+ now >= expires_at
35
+ end
36
+
37
+ def with_interval(new_interval)
38
+ self.class.new(
39
+ device_code: device_code,
40
+ user_code: user_code,
41
+ verification_uri: verification_uri,
42
+ expires_at: expires_at,
43
+ interval: new_interval
44
+ )
45
+ end
46
+ end
47
+
48
+ # Result of one polling step.
49
+ #
50
+ # status:
51
+ # :ok — token attached
52
+ # :pending — user hasn't completed yet; poll again at the same interval
53
+ # :slow_down — user hasn't completed yet; back off (caller bumps interval)
54
+ # :expired — device code is past its 15-minute window
55
+ # :denied — user actively rejected
56
+ # :error — anything else; error_message attached
57
+ Result = Data.define(:status, :token, :scope, :error_message)
58
+
59
+ class AuthError < GemContribute::Error
60
+ end
61
+
62
+ module_function
63
+
64
+ # Step 1: request a device code.
65
+ #
66
+ # @return [DeviceCode]
67
+ def request_device_code(client_id, scope: DEFAULT_SCOPE, http: Net::HTTP, clock: -> { Time.now })
68
+ check_client_id!(client_id)
69
+
70
+ response = post_form(DEVICE_CODE_URL, { client_id: client_id, scope: scope }, http: http)
71
+ raise AuthError, "device code request failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
72
+
73
+ body = JSON.parse(response.body)
74
+ raise AuthError, "device code request returned: #{body["error"]}" if body["error"]
75
+
76
+ build_device_code(body, clock: clock)
77
+ end
78
+
79
+ # Step 2: one polling step. Call repeatedly until status != :pending and
80
+ # != :slow_down.
81
+ #
82
+ # @return [Result]
83
+ def poll(device_code, client_id, http: Net::HTTP)
84
+ check_client_id!(client_id)
85
+
86
+ response = post_form(
87
+ TOKEN_URL,
88
+ {
89
+ client_id: client_id,
90
+ device_code: device_code.device_code,
91
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
92
+ },
93
+ http: http
94
+ )
95
+
96
+ build_result(response)
97
+ end
98
+
99
+ # Caller convention: device-flow errors are protocol states, not
100
+ # exceptions. Network / parse errors raise. Returning a Result keeps the
101
+ # state machine pure.
102
+ def build_result(response)
103
+ unless response.is_a?(Net::HTTPSuccess)
104
+ return Result.new(status: :error, token: nil, scope: nil,
105
+ error_message: "HTTP #{response.code}")
106
+ end
107
+
108
+ body = JSON.parse(response.body)
109
+ classify_body(body)
110
+ end
111
+
112
+ def classify_body(body)
113
+ if body["access_token"]
114
+ Result.new(status: :ok, token: body["access_token"], scope: body["scope"], error_message: nil)
115
+ else
116
+ case body["error"]
117
+ when "authorization_pending"
118
+ Result.new(status: :pending, token: nil, scope: nil, error_message: nil)
119
+ when "slow_down"
120
+ Result.new(status: :slow_down, token: nil, scope: nil, error_message: nil)
121
+ when "expired_token"
122
+ Result.new(status: :expired, token: nil, scope: nil, error_message: nil)
123
+ when "access_denied"
124
+ Result.new(status: :denied, token: nil, scope: nil, error_message: nil)
125
+ else
126
+ Result.new(status: :error, token: nil, scope: nil, error_message: body["error"] || "unknown")
127
+ end
128
+ end
129
+ end
130
+
131
+ def check_client_id!(client_id)
132
+ return unless client_id.nil? || client_id.empty? || client_id == "FILL_ME_IN_FROM_MAINTAINER_MD"
133
+
134
+ raise AuthError,
135
+ "GemContribute::Auth::CLIENT_ID is not set. Walk through MAINTAINER.md to register " \
136
+ "an OAuth App, then paste the Client ID into lib/gem_contribute/auth.rb (or set " \
137
+ "GEM_CONTRIBUTE_CLIENT_ID in the environment for testing)."
138
+ end
139
+
140
+ def build_device_code(body, clock:)
141
+ DeviceCode.new(
142
+ device_code: body.fetch("device_code"),
143
+ user_code: body.fetch("user_code"),
144
+ verification_uri: body.fetch("verification_uri"),
145
+ expires_at: clock.call + body.fetch("expires_in"),
146
+ interval: body.fetch("interval")
147
+ )
148
+ end
149
+
150
+ def post_form(url, params, http:)
151
+ uri = URI(url)
152
+ http.start(uri.host, uri.port, use_ssl: true) do |conn|
153
+ request = Net::HTTP::Post.new(uri.request_uri)
154
+ request["Accept"] = "application/json"
155
+ request["User-Agent"] = "gem-contribute/#{GemContribute::VERSION}"
156
+ request.set_form_data(params)
157
+ conn.request(request)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module GemContribute
8
+ # Disk cache at ~/.cache/gem-contribute/<namespace>/<key>.json.
9
+ #
10
+ # Honors XDG_CACHE_HOME so tests (and users with non-default XDG layouts)
11
+ # don't pollute each other.
12
+ #
13
+ # Per docs/design.md the namespaces and TTLs are:
14
+ # gems — RubyGems metadata — 7 days
15
+ # issues — GitHub issue lists — 5 minutes
16
+ # repos — community profile responses — 1 day
17
+ # files — file contents (CONTRIBUTING) — 1 day
18
+ #
19
+ # The cache stores `{stored_at:, payload:}` so the TTL check is local rather
20
+ # than dependent on filesystem mtime (which differs across platforms).
21
+ class Cache
22
+ DEFAULT_NAMESPACE_TTL = {
23
+ "gems" => 7 * 24 * 60 * 60,
24
+ "issues" => 5 * 60,
25
+ "repos" => 24 * 60 * 60,
26
+ "files" => 24 * 60 * 60
27
+ }.freeze
28
+
29
+ attr_reader :root
30
+
31
+ def initialize(root: Cache.default_root, ttl: DEFAULT_NAMESPACE_TTL, clock: -> { Time.now.to_i })
32
+ @root = root
33
+ @ttl = ttl
34
+ @clock = clock
35
+ end
36
+
37
+ # Look up a cached value. Returns the payload Hash or nil.
38
+ # Expired entries are treated as misses but left on disk; the next write
39
+ # overwrites them. (Aggressive deletion costs IO for no real gain.)
40
+ def fetch(namespace, key)
41
+ path = path_for(namespace, key)
42
+ return nil unless File.file?(path)
43
+
44
+ data = read_json(path)
45
+ return nil if data.nil?
46
+ return nil if expired?(namespace, data)
47
+
48
+ data["payload"]
49
+ end
50
+
51
+ # Cache a payload. Returns the payload as given.
52
+ def write(namespace, key, payload)
53
+ path = path_for(namespace, key)
54
+ FileUtils.mkdir_p(File.dirname(path))
55
+
56
+ tmp = "#{path}.tmp"
57
+ File.write(tmp, JSON.generate("stored_at" => @clock.call, "payload" => payload), encoding: "UTF-8")
58
+ File.rename(tmp, path)
59
+ payload
60
+ end
61
+
62
+ # Clear every namespace under the cache root. Powers `--refresh`.
63
+ def clear!
64
+ FileUtils.rm_rf(@root) if File.directory?(@root)
65
+ end
66
+
67
+ def self.default_root
68
+ base = ENV["XDG_CACHE_HOME"] || File.expand_path("~/.cache")
69
+ File.join(base, "gem-contribute")
70
+ end
71
+
72
+ private
73
+
74
+ def path_for(namespace, key)
75
+ File.join(@root, namespace, "#{safe_key(key)}.json")
76
+ end
77
+
78
+ def safe_key(key)
79
+ # Keys can contain slashes (`owner/repo`); hash them so we don't have to
80
+ # mkdir_p arbitrary trees and so collisions across namespaces stay tidy.
81
+ Digest::SHA256.hexdigest(key.to_s)
82
+ end
83
+
84
+ def read_json(path)
85
+ JSON.parse(File.read(path, encoding: "UTF-8"))
86
+ rescue JSON::ParserError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
87
+ nil
88
+ end
89
+
90
+ def expired?(namespace, data)
91
+ ttl = @ttl[namespace] || @ttl[namespace.to_s]
92
+ return false if ttl.nil?
93
+
94
+ stored_at = data["stored_at"].to_i
95
+ (@clock.call - stored_at) > ttl
96
+ end
97
+ end
98
+ end