gem-contribute 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/.gem_release.yml +1 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +22 -0
- data/CHANGELOG.md +15 -0
- data/CODE_OF_CONDUCT.md +86 -0
- data/CONTRIBUTING.md +4 -11
- data/README.md +8 -7
- data/docs/adr/0008-rooibos-tui-framework.md +1 -1
- data/docs/adr/0010-charm-ruby-tui-framework.md +84 -0
- data/docs/adr/README.md +2 -1
- data/docs/ideas.md +1 -0
- data/docs/index.md +1 -1
- data/docs/talk/README.md +45 -0
- data/docs/talk/index.html +4165 -0
- data/docs/talk/lightning.md +425 -0
- data/docs/talk/lightning.pdf +0 -0
- data/lib/gem_contribute/cli/config.rb +6 -4
- data/lib/gem_contribute/cli/fork_clone_branch.rb +7 -0
- data/lib/gem_contribute/cli/init.rb +83 -0
- data/lib/gem_contribute/cli/issues.rb +12 -8
- data/lib/gem_contribute/cli/rate_limit_footer.rb +32 -0
- data/lib/gem_contribute/cli/scan.rb +1 -0
- data/lib/gem_contribute/cli.rb +4 -0
- data/lib/gem_contribute/config.rb +1 -3
- data/lib/gem_contribute/version.rb +1 -1
- metadata +12 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
---
|
|
2
|
+
marp: true
|
|
3
|
+
theme: default
|
|
4
|
+
paginate: true
|
|
5
|
+
size: 16:9
|
|
6
|
+
header: 'Blue Ridge Ruby 2026 · gem-contribute'
|
|
7
|
+
footer: ' '
|
|
8
|
+
style: |
|
|
9
|
+
/* Blue Ridge Ruby 2026 palette, pulled from the conference mark. */
|
|
10
|
+
:root {
|
|
11
|
+
--brr-light: #a8cce4; /* pale ridge blue */
|
|
12
|
+
--brr-ruby: #c2272e; /* ruby red */
|
|
13
|
+
--brr-blue: #2872b4; /* mid blue */
|
|
14
|
+
--brr-navy: #0e2854; /* deep navy */
|
|
15
|
+
--brr-cream: #fbf9f5; /* slide background */
|
|
16
|
+
--brr-ink: #16213a; /* body text */
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
section {
|
|
20
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif;
|
|
21
|
+
background: var(--brr-cream);
|
|
22
|
+
color: var(--brr-ink);
|
|
23
|
+
padding: 60px 80px;
|
|
24
|
+
position: relative;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Header strap with the conference identifier on every content slide.
|
|
28
|
+
Uses Marp's `header:` directive so it survives PDF export (unlike
|
|
29
|
+
::before/::after `content:` properties, which Marp drops). */
|
|
30
|
+
header {
|
|
31
|
+
position: absolute;
|
|
32
|
+
top: 28px; right: 80px;
|
|
33
|
+
left: auto;
|
|
34
|
+
width: auto;
|
|
35
|
+
margin: 0;
|
|
36
|
+
font-size: 0.65em;
|
|
37
|
+
letter-spacing: 0.08em;
|
|
38
|
+
text-transform: uppercase;
|
|
39
|
+
color: var(--brr-blue);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Footer is empty content but its element provides the gradient bar at
|
|
43
|
+
the bottom of every slide. The four-color stripe is the conference
|
|
44
|
+
palette in order. */
|
|
45
|
+
footer {
|
|
46
|
+
position: absolute;
|
|
47
|
+
left: 0; right: 0; bottom: 0;
|
|
48
|
+
margin: 0;
|
|
49
|
+
padding: 0;
|
|
50
|
+
height: 6px;
|
|
51
|
+
width: 100%;
|
|
52
|
+
color: transparent;
|
|
53
|
+
font-size: 0;
|
|
54
|
+
background: linear-gradient(
|
|
55
|
+
to right,
|
|
56
|
+
var(--brr-light) 0% 25%,
|
|
57
|
+
var(--brr-ruby) 25% 50%,
|
|
58
|
+
var(--brr-blue) 50% 75%,
|
|
59
|
+
var(--brr-navy) 75% 100%
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
h1, h2, h3 {
|
|
64
|
+
color: var(--brr-navy);
|
|
65
|
+
font-weight: 700;
|
|
66
|
+
letter-spacing: -0.01em;
|
|
67
|
+
}
|
|
68
|
+
h1 { font-size: 2.4em; }
|
|
69
|
+
h2 { font-size: 1.8em; }
|
|
70
|
+
|
|
71
|
+
a { color: var(--brr-blue); text-decoration: underline; }
|
|
72
|
+
strong { color: var(--brr-ruby); }
|
|
73
|
+
|
|
74
|
+
code, pre { font-family: "JetBrains Mono", "Fira Code", Menlo, monospace; }
|
|
75
|
+
code {
|
|
76
|
+
background: rgba(40, 114, 180, 0.10);
|
|
77
|
+
color: var(--brr-navy);
|
|
78
|
+
padding: 2px 6px;
|
|
79
|
+
border-radius: 3px;
|
|
80
|
+
}
|
|
81
|
+
pre {
|
|
82
|
+
font-size: 0.78em;
|
|
83
|
+
background: var(--brr-navy);
|
|
84
|
+
color: #f0f4fa;
|
|
85
|
+
padding: 18px 22px;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
border-left: 4px solid var(--brr-ruby);
|
|
88
|
+
}
|
|
89
|
+
pre code {
|
|
90
|
+
background: transparent;
|
|
91
|
+
color: inherit;
|
|
92
|
+
padding: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
blockquote {
|
|
96
|
+
font-size: 1.5em;
|
|
97
|
+
border-left: 6px solid var(--brr-ruby);
|
|
98
|
+
padding: 8px 0 8px 28px;
|
|
99
|
+
margin-left: 0;
|
|
100
|
+
color: var(--brr-navy);
|
|
101
|
+
font-style: normal;
|
|
102
|
+
font-weight: 500;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
table { border-collapse: collapse; }
|
|
106
|
+
th { background: var(--brr-navy); color: white; padding: 10px 16px; }
|
|
107
|
+
td { padding: 8px 16px; border-bottom: 1px solid var(--brr-light); }
|
|
108
|
+
tr:last-child td { border-bottom: none; }
|
|
109
|
+
|
|
110
|
+
.small { font-size: 0.7em; color: #5a6a82; }
|
|
111
|
+
.big { font-size: 1.6em; }
|
|
112
|
+
|
|
113
|
+
/* Page numbers, themed. */
|
|
114
|
+
section::part(pagination) { color: var(--brr-blue); }
|
|
115
|
+
|
|
116
|
+
/* Title and closing slides invert: navy background, light type.
|
|
117
|
+
All overrides below were chosen to clear WCAG AA (4.5:1) for normal
|
|
118
|
+
text and 3:1 for large text against the navy background. */
|
|
119
|
+
section.title {
|
|
120
|
+
background: var(--brr-navy);
|
|
121
|
+
color: white;
|
|
122
|
+
text-align: center;
|
|
123
|
+
padding-top: 18%;
|
|
124
|
+
}
|
|
125
|
+
/* On title and closing slides we hide the header strap (no `Blue Ridge
|
|
126
|
+
Ruby 2026 · gem-contribute` repeating over the title) but keep the
|
|
127
|
+
gradient bar — it visually anchors every slide identically. */
|
|
128
|
+
section.title header { display: none; }
|
|
129
|
+
section.title h1 {
|
|
130
|
+
color: white;
|
|
131
|
+
font-size: 3.2em;
|
|
132
|
+
margin-bottom: 0.1em;
|
|
133
|
+
}
|
|
134
|
+
section.title h2 {
|
|
135
|
+
color: var(--brr-light);
|
|
136
|
+
font-weight: 400;
|
|
137
|
+
font-size: 1.4em;
|
|
138
|
+
margin-top: 0;
|
|
139
|
+
}
|
|
140
|
+
/* Brighter ruby (#ff5b62 ≈ 5.0:1 on navy) so the URL pops without
|
|
141
|
+
dropping below WCAG AA. The original --brr-ruby is fine on cream
|
|
142
|
+
but fails on navy. */
|
|
143
|
+
section.title strong { color: #ff5b62; }
|
|
144
|
+
section.title a { color: var(--brr-light); }
|
|
145
|
+
/* Inline code on title slides: invert to white-on-translucent-white
|
|
146
|
+
(≈ 12:1 on navy) — the cream-on-cream default is invisible here. */
|
|
147
|
+
section.title code {
|
|
148
|
+
background: rgba(255, 255, 255, 0.18);
|
|
149
|
+
color: white;
|
|
150
|
+
}
|
|
151
|
+
section.title pre {
|
|
152
|
+
background: rgba(255, 255, 255, 0.08);
|
|
153
|
+
color: white;
|
|
154
|
+
border-left-color: #ff5b62;
|
|
155
|
+
}
|
|
156
|
+
section.title pre code {
|
|
157
|
+
background: transparent;
|
|
158
|
+
color: inherit;
|
|
159
|
+
}
|
|
160
|
+
/* Disclosure / fine-print on title slides — clears AA at ~6.5:1. */
|
|
161
|
+
section.title .small { color: #b9cde0; }
|
|
162
|
+
/* Blockquotes on title slides: white text, no left border (clashes
|
|
163
|
+
with center alignment), constrained width so lines wrap naturally. */
|
|
164
|
+
section.title blockquote {
|
|
165
|
+
color: white;
|
|
166
|
+
border-left: none;
|
|
167
|
+
text-align: center;
|
|
168
|
+
padding: 8px 0;
|
|
169
|
+
margin: 0 auto;
|
|
170
|
+
max-width: 70%;
|
|
171
|
+
font-size: 1.4em;
|
|
172
|
+
}
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
<!-- _class: title -->
|
|
176
|
+
|
|
177
|
+
<!-- Speaker note (~10s): Title slide. Smile, take a breath, let
|
|
178
|
+
the room settle. "I'm Chris Hagmann. I want to talk about
|
|
179
|
+
building tools that don't exist yet — using a small one I
|
|
180
|
+
made this week as the example." -->
|
|
181
|
+
|
|
182
|
+
# gem-contribute
|
|
183
|
+
|
|
184
|
+
## Building what you cannot find
|
|
185
|
+
|
|
186
|
+
Chris Hagmann · Blue Ridge Ruby 2026
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
<!-- Speaker note (~15s): Hold the slide. Don't read it aloud —
|
|
191
|
+
let them read it themselves. Then, quietly: "That's been me
|
|
192
|
+
for years. I suspect it's been some of you." Beat. Move on.
|
|
193
|
+
Make it personal, not a claim about the whole room. -->
|
|
194
|
+
|
|
195
|
+
> I wanted to contribute back.
|
|
196
|
+
>
|
|
197
|
+
> I never figured out where to start.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
<!-- Speaker note (~25s): "I volunteered yesterday to help with
|
|
202
|
+
Hack Day tomorrow. When I said yes, I needed a list of good
|
|
203
|
+
Ruby projects with approachable issues to point people at.
|
|
204
|
+
That list didn't exist." Pause on "didn't exist."
|
|
205
|
+
Then: "So I built one. And along the way I noticed something
|
|
206
|
+
that I think generalizes." That's the bridge to the next
|
|
207
|
+
slide. -->
|
|
208
|
+
|
|
209
|
+
## A small problem
|
|
210
|
+
|
|
211
|
+
I volunteered to help with **Hack Day** tomorrow.
|
|
212
|
+
|
|
213
|
+
<br>
|
|
214
|
+
|
|
215
|
+
I needed a list of **good Ruby projects** to point people at.
|
|
216
|
+
|
|
217
|
+
<br>
|
|
218
|
+
|
|
219
|
+
That list didn't exist.
|
|
220
|
+
|
|
221
|
+
<br>
|
|
222
|
+
|
|
223
|
+
> So I built one.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
<!-- Speaker note (~20s): "There ARE resources. Four of them, all
|
|
228
|
+
fine, all sparse." Don't read the URLs. The point is the
|
|
229
|
+
pattern, not the list. "They're sparse for the same reason:
|
|
230
|
+
they need a maintainer to opt their project in. Most
|
|
231
|
+
maintainers never do."
|
|
232
|
+
Then the turn: "The signal I needed was a different kind of
|
|
233
|
+
opt-in. Mine." Land on "mine." -->
|
|
234
|
+
|
|
235
|
+
## What's out there
|
|
236
|
+
|
|
237
|
+
- **goodfirstissue.dev** · opt-in registry · sparse
|
|
238
|
+
- **goodfirstissues.com** · opt-in registry · sparse
|
|
239
|
+
- **github.com/topics/good-first-issue** · opt-in topic · sparse
|
|
240
|
+
- **forgoodfirstissue.github.com** · curated · narrow
|
|
241
|
+
|
|
242
|
+
<br>
|
|
243
|
+
|
|
244
|
+
These all rely on **maintainer opt-in** — and most maintainers never do.
|
|
245
|
+
|
|
246
|
+
<br>
|
|
247
|
+
|
|
248
|
+
The signal I needed was a different kind of opt-in: **mine.**
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
<!-- Speaker note (~25s): "Bundler shipped this insight years
|
|
253
|
+
ago. `bundle fund` reads your Gemfile.lock to answer one
|
|
254
|
+
question: where should my dollars go? It's the right index
|
|
255
|
+
for the question." Beat. "Same index, different question:
|
|
256
|
+
where should my hours go?"
|
|
257
|
+
Land hard on the slogan. This is the meme of the talk. -->
|
|
258
|
+
|
|
259
|
+
## `bundle fund` for time
|
|
260
|
+
|
|
261
|
+
`bundle fund` reads your `Gemfile.lock` to answer
|
|
262
|
+
*"where should my **dollars** go?"*
|
|
263
|
+
|
|
264
|
+
<br>
|
|
265
|
+
|
|
266
|
+
`gem-contribute` reads the same file to answer
|
|
267
|
+
*"where should my **hours** go?"*
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
<!-- Speaker note (~20s): "Two hundred and sixteen gems in this
|
|
272
|
+
project. Two hundred and sixteen maintainers I've already
|
|
273
|
+
bet on. Two hundred and sixteen codebases I have at least
|
|
274
|
+
a little context on."
|
|
275
|
+
"That's already a curated list. I just had to use it." -->
|
|
276
|
+
|
|
277
|
+
## The insight was already on disk
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
$ bundle list | wc -l
|
|
281
|
+
216
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
<br>
|
|
285
|
+
|
|
286
|
+
Your `Gemfile.lock` is already curated.
|
|
287
|
+
|
|
288
|
+
- ~216 maintainers you've **already bet on**
|
|
289
|
+
- The OSS code you have **the most context on**
|
|
290
|
+
- A vote of confidence with versions attached
|
|
291
|
+
|
|
292
|
+
<br>
|
|
293
|
+
|
|
294
|
+
Start *there*, not on GitHub.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
<!-- Speaker note (~45s): Let them read the output for ~10s
|
|
299
|
+
before talking. Then walk down the list:
|
|
300
|
+
"Sorbet has fifty open good-first-issues. Fifty."
|
|
301
|
+
"RSpec OpenAPI, five. Packwerk, four. Rubocop, four."
|
|
302
|
+
Point at gem-contribute on row three. Smile.
|
|
303
|
+
"And the tool itself, four. It found itself. We'll come
|
|
304
|
+
back to that." That's the meta-joke; don't oversell it. -->
|
|
305
|
+
|
|
306
|
+
## So I built it
|
|
307
|
+
|
|
308
|
+
```
|
|
309
|
+
$ gem-contribute scan
|
|
310
|
+
Scanning Gemfile.lock (234 gems)...
|
|
311
|
+
234 gems · 230 on github.com · 1 on gitlab.com · 3 unknown source
|
|
312
|
+
|
|
313
|
+
Top contributable projects (by open `good first issue` count):
|
|
314
|
+
sorbet-runtime 50 github.com/sorbet/sorbet
|
|
315
|
+
rspec-openapi 5 github.com/exoego/rspec-openapi
|
|
316
|
+
gem-contribute 4 github.com/cdhagmann/gem-contribute
|
|
317
|
+
packwerk 4 github.com/Shopify/packwerk
|
|
318
|
+
rubocop 4 github.com/rubocop/rubocop
|
|
319
|
+
gitlab 3 github.com/NARKOZ/gitlab
|
|
320
|
+
pundit 2 github.com/varvet/pundit
|
|
321
|
+
...
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
<!-- (Drill-in slide cut for time. Mention in patter:
|
|
325
|
+
"You can list the issues for any of these.") -->
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
<!-- Speaker note (~40s): Pre-frame: "You pick an issue. One
|
|
330
|
+
command does the rest."
|
|
331
|
+
Read silence ~8s. Then narrate: "It forks the repo to your
|
|
332
|
+
account. Clones the fork locally. Adds the upstream remote.
|
|
333
|
+
Creates a branch named after the issue."
|
|
334
|
+
Pause. "All the git ceremony, gone." -->
|
|
335
|
+
|
|
336
|
+
## Fix it
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
$ gem-contribute fix rubocop/14102
|
|
340
|
+
Forking rubocop/rubocop → cdhagmann/rubocop...
|
|
341
|
+
Cloning into ~/code/oss/rubocop/rubocop...
|
|
342
|
+
Forked, cloned, and branched.
|
|
343
|
+
path: ~/code/oss/rubocop/rubocop
|
|
344
|
+
branch: gem-contribute/issue-14102
|
|
345
|
+
upstream: github.com/rubocop/rubocop
|
|
346
|
+
fork: github.com/cdhagmann/rubocop
|
|
347
|
+
|
|
348
|
+
Next: cd ~/code/oss/rubocop/rubocop && make your changes,
|
|
349
|
+
then `gem-contribute submit`.
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
<!-- Speaker note (~35s): "You write the fix. You commit it.
|
|
355
|
+
You run submit." Beat for the URL to render.
|
|
356
|
+
"It pushes your branch. It opens the compare URL with the
|
|
357
|
+
title, the body, and `Closes #14102` already filled in."
|
|
358
|
+
Bottom-line it: "Browser opens. PR is pre-filled. Review.
|
|
359
|
+
Click Create." -->
|
|
360
|
+
|
|
361
|
+
## Submit it
|
|
362
|
+
|
|
363
|
+
```
|
|
364
|
+
$ gem-contribute submit
|
|
365
|
+
Pushing gem-contribute/issue-14102 to origin...
|
|
366
|
+
Opened browser to:
|
|
367
|
+
https://github.com/rubocop/rubocop/compare/cdhagmann:gem-contribute/issue-14102
|
|
368
|
+
?expand=1&title=Fix+%2314102%3A+Allow+Lint%2FVoid+...&body=Closes+%2314102.
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
<br>
|
|
372
|
+
|
|
373
|
+
Browser opens. PR is pre-filled. Review. Click **Create**.
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
<!-- Speaker note (~20s): The bridge from demo to lesson. Slow
|
|
378
|
+
down here. The tool is the example, not the lesson.
|
|
379
|
+
"I'm doing this for gems. But the pattern works wherever
|
|
380
|
+
you look. The things you depend on are the things you should
|
|
381
|
+
give back to. Once you see that, you start noticing it
|
|
382
|
+
everywhere." Pause. Move on. -->
|
|
383
|
+
|
|
384
|
+
## The pattern generalizes
|
|
385
|
+
|
|
386
|
+
> The things you depend on
|
|
387
|
+
> are the things you should give back to.
|
|
388
|
+
>
|
|
389
|
+
> Once you see that, you start noticing it everywhere.
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
<!-- _class: title -->
|
|
394
|
+
|
|
395
|
+
<!-- Speaker note (~15s): The takeaway slide. Read it slowly,
|
|
396
|
+
one beat per line. Don't editorialize. The audience should
|
|
397
|
+
leave the room with this in their head — not the install
|
|
398
|
+
command, not the gem name, this. -->
|
|
399
|
+
|
|
400
|
+
> Build for yourself first.
|
|
401
|
+
>
|
|
402
|
+
> Help where you can.
|
|
403
|
+
>
|
|
404
|
+
> The rest is bonus.
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
<!-- _class: title -->
|
|
409
|
+
|
|
410
|
+
<!-- Speaker note (~10s): "Thanks. The gem installs today. I'll
|
|
411
|
+
be at Hack Day tomorrow if you want to try it on your
|
|
412
|
+
laptop. Find me." Don't run over. Step back from the mic. -->
|
|
413
|
+
|
|
414
|
+
## Thanks
|
|
415
|
+
|
|
416
|
+
`gem install gem-contribute`
|
|
417
|
+
**cdhagmann.com/gem-contribute**
|
|
418
|
+
|
|
419
|
+
<br>
|
|
420
|
+
|
|
421
|
+
Find me at **Hack Day** tomorrow — I'll watch it work on your laptop.
|
|
422
|
+
|
|
423
|
+
<br>
|
|
424
|
+
|
|
425
|
+
<span class="small">AI-assisted; ADRs explain why.</span>
|
|
Binary file
|
|
@@ -18,8 +18,9 @@ module GemContribute
|
|
|
18
18
|
list Print all configured values.
|
|
19
19
|
|
|
20
20
|
Keys:
|
|
21
|
-
clone_root Directory where forks are cloned
|
|
22
|
-
|
|
21
|
+
clone_root Directory where forks are cloned. Set with
|
|
22
|
+
`gem-contribute init` (interactive) or
|
|
23
|
+
`gem-contribute config set clone_root <path>`.
|
|
23
24
|
USAGE
|
|
24
25
|
|
|
25
26
|
def initialize(stdout: $stdout, stderr: $stderr, config: GemContribute::Config.new)
|
|
@@ -73,13 +74,14 @@ module GemContribute
|
|
|
73
74
|
return 1
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
@stdout.puts @config.to_h.fetch(key, "(not set
|
|
77
|
+
@stdout.puts @config.to_h.fetch(key, "(not set; run `gem-contribute init`)")
|
|
77
78
|
0
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
def list
|
|
81
82
|
@stdout.puts "Configuration (#{GemContribute::Config.default_path}):"
|
|
82
|
-
|
|
83
|
+
display = @config.clone_root || "(not set; run `gem-contribute init`)"
|
|
84
|
+
@stdout.puts " clone_root = #{display}"
|
|
83
85
|
0
|
|
84
86
|
end
|
|
85
87
|
end
|
|
@@ -48,6 +48,8 @@ module GemContribute
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def run(argv)
|
|
51
|
+
return missing_clone_root if @clone_root.nil?
|
|
52
|
+
|
|
51
53
|
target = argv.shift
|
|
52
54
|
return print_usage_error if target.nil? || !target.include?("/")
|
|
53
55
|
|
|
@@ -69,6 +71,11 @@ module GemContribute
|
|
|
69
71
|
|
|
70
72
|
private
|
|
71
73
|
|
|
74
|
+
def missing_clone_root
|
|
75
|
+
@stderr.puts "clone_root is not configured. Run `gem-contribute init` first."
|
|
76
|
+
1
|
|
77
|
+
end
|
|
78
|
+
|
|
72
79
|
def print_usage_error
|
|
73
80
|
@stderr.puts "Usage: gem-contribute fork-clone-branch <gem>/<issue#>"
|
|
74
81
|
2
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# `gem-contribute init` — interactive one-time setup. Writes the user's
|
|
6
|
+
# `clone_root` to ~/.config/gem-contribute/config.yml and, if no GitHub
|
|
7
|
+
# token is cached, offers to run `auth login`.
|
|
8
|
+
#
|
|
9
|
+
# Without init, `fix` errors with a hint to run init. The point is to
|
|
10
|
+
# avoid creating directories or assuming auth without explicit consent.
|
|
11
|
+
class Init
|
|
12
|
+
USAGE = <<~USAGE
|
|
13
|
+
Usage: gem-contribute init
|
|
14
|
+
|
|
15
|
+
Interactively set the directory where forks are cloned (clone_root),
|
|
16
|
+
then offer to authenticate with GitHub if you haven't already.
|
|
17
|
+
Re-run any time to change.
|
|
18
|
+
USAGE
|
|
19
|
+
|
|
20
|
+
DEFAULT_SUGGESTION = "~/code/oss"
|
|
21
|
+
AUTH_HOST = "github.com"
|
|
22
|
+
|
|
23
|
+
def initialize(stdout: $stdout, stderr: $stderr,
|
|
24
|
+
config: GemContribute::Config.new,
|
|
25
|
+
store: GemContribute::TokenStore.new,
|
|
26
|
+
auth: nil,
|
|
27
|
+
gets: -> { $stdin.gets })
|
|
28
|
+
@stdout = stdout
|
|
29
|
+
@stderr = stderr
|
|
30
|
+
@config = config
|
|
31
|
+
@store = store
|
|
32
|
+
@auth = auth || GemContribute::CLI::Auth.new(stdout: stdout, stderr: stderr, store: store)
|
|
33
|
+
@gets = gets
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run(argv)
|
|
37
|
+
return print_usage if %w[help -h --help].include?(argv.first)
|
|
38
|
+
|
|
39
|
+
prompt_clone_root
|
|
40
|
+
maybe_authenticate
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def prompt_clone_root
|
|
47
|
+
current = @config.to_h["clone_root"]
|
|
48
|
+
default = current || DEFAULT_SUGGESTION
|
|
49
|
+
|
|
50
|
+
@stdout.print "Where should I clone repos? [#{default}]: "
|
|
51
|
+
@stdout.flush
|
|
52
|
+
input = @gets.call.to_s.chomp.strip
|
|
53
|
+
chosen = input.empty? ? default : input
|
|
54
|
+
|
|
55
|
+
@config.set("clone_root", chosen)
|
|
56
|
+
@stdout.puts "Clone root set to #{File.expand_path(chosen)}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def maybe_authenticate
|
|
60
|
+
if @store.token_for(AUTH_HOST)
|
|
61
|
+
@stdout.puts "GitHub: already authenticated."
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@stdout.print "Authenticate with GitHub now? [Y/n]: "
|
|
66
|
+
@stdout.flush
|
|
67
|
+
answer = @gets.call.to_s.chomp.strip.downcase
|
|
68
|
+
|
|
69
|
+
if %w[n no].include?(answer)
|
|
70
|
+
@stdout.puts "Skipping auth. Run `gem-contribute auth login` when you're ready."
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@auth.run(["login"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def print_usage
|
|
78
|
+
@stdout.puts USAGE
|
|
79
|
+
0
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -27,14 +27,18 @@ module GemContribute
|
|
|
27
27
|
target = argv.shift
|
|
28
28
|
return print_usage if target.nil?
|
|
29
29
|
|
|
30
|
-
if target == "all"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
status = if target == "all"
|
|
31
|
+
run_all
|
|
32
|
+
else
|
|
33
|
+
project = resolve_or_fail(target)
|
|
34
|
+
if project.nil?
|
|
35
|
+
1
|
|
36
|
+
else
|
|
37
|
+
list_issues(project)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
RateLimitFooter.print(adapter: @adapter, stdout: @stdout)
|
|
41
|
+
status
|
|
38
42
|
rescue AdapterError => e
|
|
39
43
|
@stderr.puts "gem-contribute: #{e.message}"
|
|
40
44
|
1
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# Prints a one-line GitHub rate-limit footer after `scan` or `issues`
|
|
6
|
+
# finishes its main output, when the adapter has rate-limit data.
|
|
7
|
+
#
|
|
8
|
+
# Format: "GitHub rate limit: 4,587 / 5,000 remaining · resets at 14:32 UTC"
|
|
9
|
+
#
|
|
10
|
+
# When `adapter.rate_limit` is nil (e.g. every call was served from cache),
|
|
11
|
+
# nothing is printed — see #4 acceptance criteria.
|
|
12
|
+
module RateLimitFooter
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param adapter [GemContribute::HostAdapters::GitHubAdapter]
|
|
16
|
+
# @param stdout [IO]
|
|
17
|
+
def print(adapter:, stdout: $stdout)
|
|
18
|
+
rate_limit = adapter.respond_to?(:rate_limit) ? adapter.rate_limit : nil
|
|
19
|
+
return if rate_limit.nil?
|
|
20
|
+
|
|
21
|
+
remaining = format_with_separators(rate_limit.remaining)
|
|
22
|
+
limit = format_with_separators(rate_limit.limit)
|
|
23
|
+
reset = rate_limit.reset_at.utc.strftime("%H:%M")
|
|
24
|
+
stdout.puts "GitHub rate limit: #{remaining} / #{limit} remaining · resets at #{reset} UTC"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def format_with_separators(integer)
|
|
28
|
+
integer.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -40,6 +40,7 @@ module GemContribute
|
|
|
40
40
|
# self-injection is intentionally additive, not part of the count.
|
|
41
41
|
print_summary(tally_hosts(projects), gems.size)
|
|
42
42
|
scan_github_projects(projects)
|
|
43
|
+
RateLimitFooter.print(adapter: @adapter, stdout: @stdout)
|
|
43
44
|
0
|
|
44
45
|
rescue LockfileNotFound => e
|
|
45
46
|
@stderr.puts "gem-contribute: #{e.message}"
|
data/lib/gem_contribute/cli.rb
CHANGED
|
@@ -7,14 +7,17 @@ module GemContribute
|
|
|
7
7
|
autoload :Scan, "gem_contribute/cli/scan"
|
|
8
8
|
autoload :Auth, "gem_contribute/cli/auth"
|
|
9
9
|
autoload :Config, "gem_contribute/cli/config"
|
|
10
|
+
autoload :Init, "gem_contribute/cli/init"
|
|
10
11
|
autoload :Issues, "gem_contribute/cli/issues"
|
|
11
12
|
autoload :ForkCloneBranch, "gem_contribute/cli/fork_clone_branch"
|
|
12
13
|
autoload :Git, "gem_contribute/cli/fork_clone_branch"
|
|
13
14
|
autoload :Submit, "gem_contribute/cli/submit"
|
|
15
|
+
autoload :RateLimitFooter, "gem_contribute/cli/rate_limit_footer"
|
|
14
16
|
USAGE = <<~USAGE
|
|
15
17
|
Usage: gem-contribute <command> [options]
|
|
16
18
|
|
|
17
19
|
Commands:
|
|
20
|
+
init One-time interactive setup (sets clone_root).
|
|
18
21
|
scan [path] Summarize the contributable surface of a Gemfile.lock.
|
|
19
22
|
Path defaults to ./Gemfile.lock.
|
|
20
23
|
issues <gem|all> List open "good first issue" issues for a gem (or all gems).
|
|
@@ -58,6 +61,7 @@ module GemContribute
|
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
COMMANDS = {
|
|
64
|
+
"init" => ->(o, e) { Init.new(stdout: o, stderr: e) },
|
|
61
65
|
"scan" => ->(o, e) { Scan.new(stdout: o, stderr: e, adapter: github_adapter) },
|
|
62
66
|
"issues" => ->(o, e) { Issues.new(stdout: o, stderr: e, adapter: github_adapter) },
|
|
63
67
|
"config" => ->(o, e) { Config.new(stdout: o, stderr: e) },
|
|
@@ -8,8 +8,6 @@ module GemContribute
|
|
|
8
8
|
# Honors XDG_CONFIG_HOME so tests stay hermetic and unusual layouts work.
|
|
9
9
|
# Missing or corrupt files are treated as an empty config (no crash).
|
|
10
10
|
class Config
|
|
11
|
-
DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
|
|
12
|
-
|
|
13
11
|
KNOWN_KEYS = %w[clone_root].freeze
|
|
14
12
|
|
|
15
13
|
def initialize(path: self.class.default_path)
|
|
@@ -19,7 +17,7 @@ module GemContribute
|
|
|
19
17
|
|
|
20
18
|
def clone_root
|
|
21
19
|
raw = @data["clone_root"]
|
|
22
|
-
raw ? File.expand_path(raw) :
|
|
20
|
+
raw ? File.expand_path(raw) : nil
|
|
23
21
|
end
|
|
24
22
|
|
|
25
23
|
def set(key, value)
|