proctolog 0.1.1 → 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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -3
  3. data/README.md +15 -4
  4. data/app/controllers/proctolog/application_controller.rb +1 -1
  5. data/app/controllers/proctolog/bursts_controller.rb +1 -1
  6. data/app/controllers/proctolog/choices_controller.rb +3 -1
  7. data/app/controllers/proctolog/decisions_controller.rb +1 -1
  8. data/app/controllers/proctolog/notes_controller.rb +2 -2
  9. data/app/controllers/proctolog/prompts_controller.rb +1 -1
  10. data/app/controllers/proctolog/pull_requests_controller.rb +2 -4
  11. data/app/controllers/proctolog/review_data_controller.rb +3 -1
  12. data/app/controllers/proctolog/shares_controller.rb +27 -0
  13. data/app/helpers/proctolog/decisions_helper.rb +6 -2
  14. data/app/helpers/proctolog/desk_helper.rb +48 -8
  15. data/app/helpers/proctolog/rounds_helper.rb +4 -2
  16. data/app/helpers/proctolog/shares_helper.rb +11 -0
  17. data/app/helpers/proctolog/standings_helper.rb +5 -4
  18. data/app/models/proctolog/burst.rb +17 -8
  19. data/app/models/proctolog/choices.rb +8 -0
  20. data/app/models/proctolog/doctor.rb +9 -1
  21. data/app/models/proctolog/github.rb +1 -1
  22. data/app/models/proctolog/marks.rb +1 -1
  23. data/app/models/proctolog/note.rb +4 -0
  24. data/app/models/proctolog/owner.rb +3 -0
  25. data/app/models/proctolog/pick.rb +12 -0
  26. data/app/models/proctolog/rounds.rb +37 -0
  27. data/app/models/proctolog/share/ngrok.rb +120 -0
  28. data/app/models/proctolog/share/settings.rb +23 -0
  29. data/app/models/proctolog/share/tailscale.rb +103 -0
  30. data/app/models/proctolog/share.rb +101 -0
  31. data/app/models/proctolog/standing.rb +24 -7
  32. data/app/views/proctolog/bursts/_line.html.erb +2 -2
  33. data/app/views/proctolog/bursts/_side.html.erb +6 -5
  34. data/app/views/proctolog/bursts/_side_stop.html.erb +4 -4
  35. data/app/views/proctolog/notes/_card.html.erb +7 -3
  36. data/app/views/proctolog/open_pull_requests/_side.html.erb +4 -3
  37. data/app/views/proctolog/open_pull_requests/_start.html.erb +8 -0
  38. data/app/views/proctolog/open_pull_requests/index.html.erb +4 -5
  39. data/app/views/proctolog/open_pull_requests/start/_first_time.html.erb +3 -0
  40. data/app/views/proctolog/open_pull_requests/start/_nothing_open.html.erb +3 -0
  41. data/app/views/proctolog/open_pull_requests/start/_nothing_written.html.erb +3 -0
  42. data/app/views/proctolog/shares/_line.html.erb +5 -0
  43. data/app/views/proctolog/shares/show.html.erb +48 -0
  44. data/app/views/proctolog/shares/ways/_ngrok.html.erb +6 -0
  45. data/app/views/proctolog/shares/ways/_tailscale.html.erb +2 -0
  46. data/bin/proctolog +92 -6
  47. data/completions/_proctolog +28 -4
  48. data/config/application.rb +23 -4
  49. data/config/initializers/grouse.rb +1 -1
  50. data/config/routes.rb +3 -0
  51. data/docs/manual/bursts.md +7 -2
  52. data/docs/manual/deciding.md +4 -1
  53. data/docs/manual/handover.md +6 -0
  54. data/docs/manual/reading.md +54 -27
  55. data/docs/manual/sharing.md +77 -0
  56. data/lib/proctolog/changelog.rb +50 -0
  57. data/lib/proctolog/version.rb +1 -1
  58. data/public/asking.js +31 -4
  59. data/public/decisions.js +4 -4
  60. data/public/desk.js +10 -6
  61. data/public/keys.js +6 -0
  62. data/public/notes.js +164 -58
  63. data/public/pick.css +21 -0
  64. data/public/respond.css +6 -0
  65. data/public/respond.js +51 -7
  66. data/public/review-drafts.js +47 -9
  67. data/public/review-rounds.js +27 -3
  68. data/public/share.css +58 -0
  69. data/public/side.js +11 -20
  70. data/public/terminal.css +295 -34
  71. data/public/viewer.js +700 -173
  72. metadata +18 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6eb6543861d7372fd74ab555d42c786fd2880d7f236a2a606cb8d8a69dda443
4
- data.tar.gz: 9c3785dbab77433adad9a7d388bd451864a213ca206cb3e99a919b9ade5773ce
3
+ metadata.gz: efc3995bd997f8129593d45bbe4de75341a7b27f43401cb329566ac552baffb5
4
+ data.tar.gz: 47456fcf6f060778f085e96a41d015e8d2b14c6d3e450178f8122b7bd6295bf6
5
5
  SHA512:
6
- metadata.gz: 83f284129d2ab34f5235510c39b196a181158a5b853dcbe41b31b855f0400df548c41e0d631902e5b398a8f1fcb893856b0509d3bd70d7232ddbbf128cf62090
7
- data.tar.gz: 80cf8f0953c7f7badbc7c5ed7791b4211b2f9a5d9047bb7bb7c8a2117b2f6ff133edc409c12b46c948d58e03862a536cf94f5e27adea2b9715d55a649d5d4900
6
+ metadata.gz: f0d7b8c993fe32e7a08c4212de1a30f287c10e0b1ce33a4d58a85c557f172f55a82196098ec6a8b5d894b2c003733809599e1db279a85a67ade37e680de2dae3
7
+ data.tar.gz: 3b38800d1b7bd1ef1aeb3dc364a87510ab3bf55837c8f3827b5ccc509c870aef742b5a78bbefbe3d2a6acb92cf131e7fde0b6d317e39686dbcef19dc2422dcf2
data/CHANGELOG.md CHANGED
@@ -1,11 +1,83 @@
1
1
  # Changelog
2
2
 
3
- What changed in each version of the `proctolog` gem, newest first. Versions
4
- are semantic ([docs/manual/updates.md](docs/manual/updates.md)); a release
5
- that asks something of you says what under **You do**.
3
+ What changed in each version of the `proctolog` gem, newest first: what it
4
+ adds, what it changes and what it fixes. Versions are semantic
5
+ ([docs/manual/updates.md](docs/manual/updates.md)); a release that asks
6
+ something of you says what under **You do**. Unreleased is what main has that
7
+ no gem has yet.
8
+
9
+ ## 0.2.0
10
+
11
+ ### Added
12
+
13
+ - The desk is at <http://proctolog.localhost:4040>, a name of its own that a
14
+ browser's history finds from `procto`; `localhost:4040` still works.
15
+ - Share the desk with your phone or another computer, through Tailscale (on
16
+ your tailnet, which the sharing page names, as one account can be in
17
+ several) or ngrok (behind its sign-in with Google, for your address): from
18
+ the foot of the pane at the left, with `proctolog share`, or with
19
+ `proctolog serve --share`. `proctolog share status` and `doctor` say what
20
+ each one needs. Neither is installed for you.
21
+ - "Start here" heads the page of open pull requests until you have a burst of
22
+ your own, and whenever there is nothing to decide: how many are written for
23
+ PRoctolog and how to open one, how to have them rewritten when none is, or
24
+ how to have some written when nothing is open.
25
+ - **back to you** on a line of the Route, and `↩` on it in the pane, once the
26
+ agent's answers have come back for you to confirm or answer, or it
27
+ addressed a note of yours. The `↩` follows as you go, without the page
28
+ loading again.
29
+ - A panel beside the pictures of a change, in the viewer: the five ways to
30
+ look at them, each a button with its key, and the changed regions, counted
31
+ and numbered. `n` and `p`, or a press on a number, focus on a region and
32
+ keep your zoom; `z` goes near it and `0` back to the whole. The panel shows
33
+ the region as the before has it and as the after does, and Enter, or a
34
+ press on either, writes a note on it, in any view.
35
+ - Shift B sets blink going by itself, or stops it.
36
+ - Once the viewer shows only part of a picture, a map in its corner shows the
37
+ whole, with the part on the stage framed; a press on the map goes there.
38
+
39
+ ### Changed
40
+
41
+ - Decisions are beads, on the Route and in the pane at the left: a full-size
42
+ bead for a blocking decision, a small one for a non-blocking, solid once
43
+ reviewed, hollow while it waits. They take the place of the counts and of
44
+ `x`, `~` and `-`, and in the pane follow your choices as you make them.
45
+ - `N` opens the next pull request that asks for you: one with a blocking
46
+ decision left, or something back from the agent.
47
+ - With no burst open, the pane lists your bursts with how many decisions of
48
+ each grade wait, rather than how far each one is.
49
+ - In the viewer, a view's key again goes back to side by side. Changed regions
50
+ are outlined dashed and a note's box framed solid, so the two are told apart
51
+ at a glance.
52
+ - A note on a picture is saved where you are: the page doesn't load again or
53
+ move, zoomed in or not, and closing the viewer draws the notes there where
54
+ the page stands, rather than coming back at the last one.
55
+ - `bin/release` refuses a patch that adds something, and says when lines are
56
+ still left under Unreleased.
57
+
58
+ ### Fixed
59
+
60
+ - "Review alone" on a pull request in no stack saved nothing, and said the
61
+ page was older than the desk's last start.
62
+ - "Ask Claude now" on a pull request nobody has opened yet sent the session to
63
+ GitHub, which has nothing of it. It names the branch, its base and the file
64
+ its description is kept in now, and has the session read it with git.
65
+ - The note button on selected text sat away from the selection on a burst's
66
+ pages.
67
+ - A note of yours that an agent answered stayed open in the marks, for the
68
+ page's Notes and for an agent reading them. It follows its answer now:
69
+ addressed once the answer is in, confirmed once you confirm it, open again
70
+ once you answer back, and its card points you to Returned work.
71
+ - `bin/release` and the Release workflow read the changelog whatever the
72
+ locale: a `⌘` or `↩` in it stopped them where none was set.
73
+
74
+ **You do**: nothing, unless you want to share the desk: install Tailscale or
75
+ ngrok, as [docs/manual/sharing.md](docs/manual/sharing.md) says.
6
76
 
7
77
  ## 0.1.1
8
78
 
79
+ ### Changed
80
+
9
81
  - The Try it rail follows the reading column and its keys rather than the far
10
82
  edge of the window, and grows to 20rem on wide screens; its outline scrolls
11
83
  on its own, leaving the starter room.
@@ -18,6 +90,8 @@ that asks something of you says what under **You do**.
18
90
 
19
91
  The first one published as a gem.
20
92
 
93
+ ### Added
94
+
21
95
  - `rvx proctolog install`, or
22
96
  `gem install proctolog` and `proctolog install`, in place of cloning the
23
97
  repository. Under rv it runs on a Ruby 4 of its own, whatever Ruby the shell
data/README.md CHANGED
@@ -19,7 +19,7 @@ whose PRs you review:
19
19
  rvx proctolog install
20
20
  cd path/to/your-repo
21
21
  proctolog doctor # fix what it marks !!
22
- proctolog serve # then open http://localhost:4040
22
+ proctolog serve # then open http://proctolog.localhost:4040
23
23
  ```
24
24
 
25
25
  Pick a PR, a stack, or a few ticked ones, and press `?` once you're in. When
@@ -79,7 +79,7 @@ cd path/to/your-repo
79
79
  proctolog serve
80
80
  ```
81
81
 
82
- Open <http://localhost:4040>. It lists the repo's open PRs: GitHub's stacks
82
+ Open <http://proctolog.localhost:4040>. It lists the repo's open PRs: GitHub's stacks
83
83
  whole, then the rest, yours first. Press "Review alone" on one, "Review the
84
84
  stack" on a stack, or tick a few and review them together. That's all the setup
85
85
  there is.
@@ -147,7 +147,7 @@ bundle config set --local with grouse && bundle install
147
147
 
148
148
  Then every page of the desk carries Grouse's overlay, at the bottom right: say or type what's wrong with the page you're
149
149
  on, and the note keeps the page, the templates that drew it, what you pointed
150
- at and, if you ask, a screenshot. Read them at <http://localhost:4040/grouse>.
150
+ at and, if you ask, a screenshot. Read them at <http://proctolog.localhost:4040/grouse>.
151
151
  An agent working on the desk reads them through Grouse's export API;
152
152
  [AGENTS.md](AGENTS.md) tells it how. They're kept under
153
153
  `~/.local/share/proctolog/grouse_notes`, one store for every repo you review.
@@ -156,15 +156,26 @@ A voice note is written out by ElevenLabs, and every note written up as an
156
156
  issue by Anthropic, with keys the desk reads from 1Password when it serves:
157
157
  `config/keys.yml` holds the references, never the keys, and `op` (1Password's
158
158
  CLI, signed in) reads them. Point them at your own items there if yours live
159
- elsewhere.
159
+ elsewhere. A trial desk, on marks of its own (`PROCTOLOG_MARKS`), reads none,
160
+ so an agent trying a change never has 1Password ask you to unlock it.
160
161
 
161
162
  Without a key a voice note arrives "audio only", with a recording that plays,
162
163
  and a note's page says "No structurer set up". `bin/rails grouse:doctor`, in the
163
164
  desk's checkout, says what Grouse is missing.
164
165
 
166
+ ## From your phone
167
+
168
+ `proctolog share` puts the desk where your other devices reach it and nobody
169
+ else does: through Tailscale, at your Mac's name on your tailnet, or through
170
+ ngrok, behind its sign-in with Google for your own address. The pane at the
171
+ left says whether it's shared, and opens the page that shares it. Neither is
172
+ installed for you; `proctolog share status` says what each one needs. More in
173
+ [the address, and sharing it](docs/manual/sharing.md).
174
+
165
175
  ## More
166
176
 
167
177
  - [Bursts, the route and where things live](docs/manual/bursts.md)
178
+ - [The address, and sharing it](docs/manual/sharing.md)
168
179
  - [How the desk runs your app](docs/manual/app.md)
169
180
  - [Reading a PR by keyboard](docs/manual/reading.md)
170
181
  - [Deciding](docs/manual/deciding.md)
@@ -4,7 +4,7 @@ module Proctolog
4
4
  class ApplicationController < ActionController::Base
5
5
  rescue_from(Github::Unreachable) { render plain: "GitHub did not answer.\n\n#{it.message}", status: :service_unavailable }
6
6
  rescue_from(ActionController::InvalidAuthenticityToken) do
7
- redirect_back_or_to root_path, alert: "This page was older than the desk's last start, so nothing was saved. Do it once more."
7
+ redirect_back_or_to root_path, alert: "The desk did not know this page any more, so nothing was saved. Do it once more."
8
8
  end
9
9
  rescue_from(Burst::Missing) { render plain: it.message, status: :not_found }
10
10
  # A button's press comes back to the page it was pressed on, with the
@@ -12,7 +12,7 @@ module Proctolog
12
12
  @route = @burst.route(fresh: params.key?(:fresh))
13
13
  return redirect_to(burst_path(@burst)) if params.key?(:fresh)
14
14
 
15
- @taken_in = @burst.reports.take_in
15
+ @taken_in = @burst.take_in
16
16
  @standings = @burst.standings(@route)
17
17
  @notices = @burst.notices
18
18
  @rules = @burst.rules.select { it.within?(focus) }
@@ -12,11 +12,13 @@ module Proctolog
12
12
  def show = render(json: {document: choices.document})
13
13
 
14
14
  # A desk from before bursts had their name reads `review`, so both are
15
- # written: one rolled back can still read what this one saved.
15
+ # written: one rolled back can still read what this one saved. What the
16
+ # owner said of an agent's answers moves the notes they answer at once.
16
17
  def update
17
18
  incoming = JSON.parse(request.raw_post)
18
19
  mine?(incoming) or raise KeyError
19
20
  choices.keep(incoming.merge("burst" => @burst.slug, "review" => @burst.slug))
21
+ @burst.take_in
20
22
  render json: {revision: choices.revision}
21
23
  end
22
24
 
@@ -13,7 +13,7 @@ module Proctolog
13
13
  return redirect_to(burst_decisions_path(@burst, show: params[:show]))
14
14
  end
15
15
 
16
- @burst.reports.take_in
16
+ @burst.take_in
17
17
  # Listed and counted within the focus; the stars and ticks it sends are
18
18
  # found among the whole burst's.
19
19
  @decisions = @burst.decisions(pages:, focus:)
@@ -10,8 +10,8 @@ module Proctolog
10
10
 
11
11
  before_action { @burst = Burst.find(params.expect(:burst_id)) }
12
12
 
13
- # A note written in the picture viewer is saved without leaving it: the
14
- # answer says where the page shows it, for once the viewer closes.
13
+ # A note on a picture, on the page or in the viewer, is saved without
14
+ # leaving either: the answer names the note and where the page shows it.
15
15
  def create
16
16
  note = marks.pin(kind: one_of(Note::KINDS, params.expect(:kind)), text: params.expect(:text).strip, place:)
17
17
  respond_to do |format|
@@ -8,7 +8,7 @@ module Proctolog
8
8
  before_action { @burst = Burst.find(params.expect(:burst_id)) }
9
9
 
10
10
  def show
11
- @taken_in = @burst.reports.take_in
11
+ @taken_in = @burst.take_in
12
12
  @handover = @burst.handover(@burst.route, focus:)
13
13
  end
14
14
 
@@ -11,13 +11,11 @@ module Proctolog
11
11
  @route = @burst.route(fresh: params.key?(:fresh))
12
12
  return redirect_to(burst_pull_request_path(@burst, params.expect(:number))) if params.key?(:fresh)
13
13
 
14
- @burst.reports.take_in
14
+ @burst.take_in
15
15
  @stop = @route.at(PullRequest.number(params.expect(:number))) or return render(plain: "##{params[:number]} is not part of #{@burst.name}.", status: :not_found)
16
16
  standings = @burst.standings(@route)
17
17
  @standing = standings.fetch(@stop)
18
- # Next to review is the next with blocking decisions left: the non-blocking
19
- # wait for one gesture, and do not hold up deciding.
20
- @next_to_review = focus.round_from(@stop).find { standings.fetch(it).progress.blocking_left.positive? }
18
+ @next_to_review = focus.round_from(@stop).find { standings.fetch(it).asks_for_owner? }
21
19
  @since = @burst.since(@stop) if @standing.changed?
22
20
  @scenarios = @burst.scenarios.select { it.pr == @stop.number }
23
21
  @rendering = Rendering.new(@burst.rendering(@stop.number), notes: @standing.marks.notes, pages: pages, linked: @stop.description.linked)
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Proctolog
4
4
  # What the pages' script needs of the burst's review rounds, built from the
5
- # whole burst; with `pr`, that pull request's descriptions too.
5
+ # whole burst; with `pr`, that pull request's descriptions too. Answers an
6
+ # agent wrote since are taken in first, as a page's load takes them in.
6
7
  class ReviewDataController < ApplicationController
7
8
  def show
8
9
  @burst = Burst.find(params.expect(:burst_id))
10
+ @burst.take_in
9
11
  render json: helpers.round_data(@burst, pr: params[:pr]&.then { PullRequest.number(it) })
10
12
  end
11
13
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The page that shares the desk with the owner's other devices, through
5
+ # whichever of Tailscale and ngrok this Mac has, and stops it (Share). A
6
+ # press comes back to it, with the sentence; `back` is the page it was
7
+ # opened from.
8
+ class SharesController < ApplicationController
9
+ before_action { @back = params[:back].to_s.then { (it.start_with?("/") && !it.start_with?("//")) ? it : root_path } }
10
+ rescue_from(Share::Refused) { redirect_to share_path(back: @back), alert: it.message }
11
+
12
+ def show
13
+ @share = Share.at(Desk.port)
14
+ @ways = Share.ways
15
+ end
16
+
17
+ def create
18
+ share = Share.open(Desk.port, via: params[:via].presence, allow: params[:allow]&.split(/[\s,]+/))
19
+ redirect_to share_path(back: @back), notice: share.to_s
20
+ end
21
+
22
+ def destroy
23
+ Share.at(Desk.port)&.close
24
+ redirect_to share_path(back: @back), notice: "The desk is only on this Mac again."
25
+ end
26
+ end
27
+ end
@@ -9,7 +9,11 @@ module Proctolog
9
9
  # focus is never taken for one whose decision is gone.
10
10
  # What the inventory holds changed with the desk: a page built from an
11
11
  # older one would read its decisions wrong.
12
- INVENTORY = 3
12
+ INVENTORY = 4
13
+
14
+ # Where the description of a pull request nobody has opened yet is kept,
15
+ # which an agent reads in place of GitHub's.
16
+ def unopened_file(stop) = (Unopened.find(stop.number)&.file&.to_s if stop.pull_request.unopened?)
13
17
 
14
18
  # The pictures' files and pixel signatures are looked up at each page,
15
19
  # not kept with the rest: a picture fetched since the reading is known.
@@ -20,7 +24,7 @@ module Proctolog
20
24
 
21
25
  def draft_decision(box)
22
26
  {id: box.to_param, pr: box.stop.number, title: box.stop.title, url: box.stop.url, branch: box.stop.pull_request.head,
23
- head: box.stop.head_oid, description: box.stop.description.digest, position: box.position, lead: box.lead,
27
+ base: box.stop.pull_request.base, file: unopened_file(box.stop), head: box.stop.head_oid, description: box.stop.description.digest, position: box.position, lead: box.lead,
24
28
  text: box.decision.text, key: box.decision.key, ticked: box.decision.ticked, protected: @burst.refusal(box.stop.number).present?,
25
29
  grade: box.decision.grade, theme: box.decision.theme_name, pictures: box.decision.pictures.map(&:to_h)}
26
30
  end
@@ -13,17 +13,48 @@ module Proctolog
13
13
  time.strftime((time.to_date == Date.current) ? "at %H:%M" : "on %-d %b at %H:%M")
14
14
  end
15
15
 
16
- # How far one pull request is, as the side pane marks its row: every
17
- # decision reviewed, some, none yet (which takes no mark), or none to
18
- # review at all. The page's script writes the same marks as choices are made.
19
- def side_mark(progress)
20
- if !progress.any? then "-"
21
- elsif progress.done? then "x"
22
- elsif progress.reviewed.positive? then "~"
16
+ # A pull request's decisions as beads, the way deciding draws them: a
17
+ # bead to each blocking decision and a smaller one to each non-blocking,
18
+ # solid once reviewed and hollow while it waits. Each grade is labelled
19
+ # in words, which the pane's card shows. The page's script draws the same
20
+ # as choices are made (beadsOf in review-drafts.js).
21
+ def decision_beads(progress)
22
+ return unless progress.any?
23
+
24
+ tag.span(class: "beads") do
25
+ safe_join(progress.grades.select { it.total.positive? }.map do |grade|
26
+ tag.span(class: grade.name, role: "img", aria: {label: reviewed_in_words(grade)}) do
27
+ safe_join(Array.new(grade.total) { tag.span(class: ["bead", ("reviewed" if it < grade.reviewed)]) })
28
+ end
29
+ end)
30
+ end
31
+ end
32
+
33
+ # A whole burst has too many decisions for a bead each: a hollow bead of
34
+ # each grade that waits, and how many of it.
35
+ def decisions_left(progress)
36
+ waiting = progress.grades.select { it.left.positive? }
37
+ return if waiting.empty?
38
+
39
+ tag.span(class: "beads") do
40
+ safe_join(waiting.map do |grade|
41
+ tag.span(class: [grade.name, "count"], role: "img", aria: {label: "#{pluralize(grade.left, "#{grade.name} decision")} to review"}) do
42
+ tag.span(class: "bead") + grade.left.to_s
43
+ end
44
+ end)
23
45
  end
24
46
  end
25
47
 
26
- def side_fraction(progress) = ("#{progress.reviewed}/#{progress.total}" if progress.any?)
48
+ # The pane's mark for a pull request the agent's answers came back on,
49
+ # labelled with what came back (Standing#for_the_owner). The page's
50
+ # script marks the pane again from the same words as answers are judged.
51
+ def side_mark(came_back)
52
+ return tag.span(class: "mark", data: {side_mark: ""}) if came_back.empty?
53
+
54
+ tag.span("↩", class: "mark", data: {side_mark: ""}, role: "img", aria: {label: back_in_words(came_back)})
55
+ end
56
+
57
+ def back_in_words(came_back) = "back to you: #{came_back.to_sentence}"
27
58
 
28
59
  # The whole burst in one line: "6 of 439 decisions reviewed" over a bar.
29
60
  # Focused on a part, what is left in it against the burst: "120 / 380
@@ -57,5 +88,14 @@ module Proctolog
57
88
  private
58
89
 
59
90
  def keycaps(pressed) = safe_join(pressed.split.map { tag.kbd(it) })
91
+
92
+ # "1 of 4 blocking decisions reviewed".
93
+ def reviewed_in_words(grade)
94
+ decisions = "#{grade.name} decision".pluralize(grade.total)
95
+ if grade.left.zero? then (grade.total == 1) ? "its one #{decisions} reviewed" : "all #{grade.total} #{decisions} reviewed"
96
+ elsif grade.reviewed.zero? then "none of #{grade.total} #{decisions} reviewed yet"
97
+ else "#{grade.reviewed} of #{grade.total} #{decisions} reviewed"
98
+ end
99
+ end
60
100
  end
61
101
  end
@@ -7,13 +7,15 @@ module Proctolog
7
7
  # which a page takes in as its own so that it knows the answer to a pass
8
8
  # an agent took itself. On a pull request's page, its description now
9
9
  # and as each handover the desk kept found it, by batch, so the page can
10
- # say what changed since.
10
+ # say what changed since. `back` names the pull requests something came
11
+ # back on, in the words of the pane's mark.
11
12
  def round_data(burst, pr: nil)
12
13
  route = burst.route
13
14
  rounds = Rounds.new(burst, route:)
14
15
  responses = ResponseFiles.new(burst)
16
+ back = burst.standings(route).filter_map { |stop, standing| [stop.number, back_in_words(standing.for_the_owner)] if standing.back_to_owner? }.to_h
15
17
  {requests: rounds.requests, batches: Handover.kept(burst).map(&:batch), reports: responses.all, errors: responses.errors,
16
- responseFolder: responses.folder.to_s, heads: rounds.heads,
18
+ responseFolder: responses.folder.to_s, heads: rounds.heads, back:,
17
19
  **(route.at(pr)&.then { |stop| {descriptions: descriptions_of(burst, stop)} } || {})}
18
20
  end
19
21
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module SharesHelper
5
+ # A way's sentence, with the addresses in it to follow: where to download
6
+ # it, or where to turn something on.
7
+ def linked_sentence(sentence)
8
+ safe_join(sentence.split(%r{(https?://[^\s,]*[^\s,.])}).map { it.start_with?("http") ? link_to(it, it, target: "_blank", rel: "noopener") : it })
9
+ end
10
+ end
11
+ end
@@ -34,11 +34,12 @@ module Proctolog
34
34
  safe_join([safe_join(parts, ", "), "."])
35
35
  end
36
36
 
37
- # What a line of the route says at its right: whose the pull request is when
38
- # it is not the owner's, then how far its decisions are.
37
+ # What a line of the route says at its right: whose the pull request is,
38
+ # "back to you" when the owner's turn is what came back from the agent,
39
+ # then its decisions as beads.
39
40
  def beside_a_line(standing)
40
- whose = {agent: "with the agent", settled: "settled", waiting: "waiting"}[standing.turn]
41
- safe_join([whose, (tag.span(side_fraction(standing.progress), class: "fraction") if standing.progress.any?)].compact, " · ")
41
+ whose = {owner: (tag.strong("back to you") if standing.back_to_owner?), agent: "with the agent", settled: "settled", waiting: "waiting"}.fetch(standing.turn)
42
+ safe_join([whose, decision_beads(standing.progress)].compact, " ")
42
43
  end
43
44
 
44
45
  def verdict_of(condition) = VERDICTS.fetch(condition.met)
@@ -124,14 +124,9 @@ module Proctolog
124
124
  def decisions(pages: {}, focus: route) = Decisions.new(route: focus, marks: method(:marks), renderings: method(:rendering), named: @waiting, pages:)
125
125
 
126
126
  # Where every stop of the route stands, in its order: each layer knows the
127
- # standing of the one it sits on.
128
- def standings(route)
129
- progress, scenarios = progress(route), scenarios_on(route)
130
- route.each_with_object({}) do |stop, standings|
131
- standings[stop] = Standing.new(stop:, marks: marks(stop.number), progress: progress.fetch(stop.number), under: route.under(stop)&.then { standings[it] },
132
- above: route.above(stop), scenarios: scenarios.select { it.pr == stop.number }.map(&:name), named: @before_it_goes.fetch(stop.number, []))
133
- end
134
- end
127
+ # standing of the one it sits on. Worked out once for a reading of a
128
+ # page, which asks for it from the page and from its pane.
129
+ def standings(route) = (@standings ||= {}.compare_by_identity)[route] ||= standings_on(route)
135
130
 
136
131
  # What moved in a pull request since the owner's "good to go", to show
137
132
  # when its standing says something did.
@@ -143,6 +138,11 @@ module Proctolog
143
138
 
144
139
  def reports = Reports.new(self)
145
140
 
141
+ # What came back from agents, taken into the marks: a report moves the
142
+ # note it names, and a handover's answers move theirs as the owner judges
143
+ # them (Rounds#move_notes). Answers the notes the reports moved.
144
+ def take_in = reports.take_in.tap { Rounds.new(self, route:).move_notes }
145
+
146
146
  # A description's picture as a file on this Mac, once the desk has shown it.
147
147
  def picture_file(asset) = (Project.current.pictures.glob("#{asset}.*").first if Picture.asset?(asset.to_s))
148
148
 
@@ -246,6 +246,15 @@ module Proctolog
246
246
 
247
247
  private
248
248
 
249
+ def standings_on(route)
250
+ progress, scenarios, returned = progress(route), scenarios_on(route), Rounds.new(self, route:).returned
251
+ route.each_with_object({}) do |stop, standings|
252
+ standings[stop] = Standing.new(stop:, marks: marks(stop.number), progress: progress.fetch(stop.number),
253
+ returned: returned.fetch(stop.number, Rounds::Returned.new), under: route.under(stop)&.then { standings[it] },
254
+ above: route.above(stop), scenarios: scenarios.select { it.pr == stop.number }.map(&:name), named: @before_it_goes.fetch(stop.number, []))
255
+ end
256
+ end
257
+
249
258
  # The pictures its descriptions show, each by its id and its kind, as GitHub's HTML addresses them.
250
259
  def pictures_of(route) = route.flat_map { Rendering.pictures(rendering(it.number)) }
251
260
 
@@ -9,6 +9,12 @@ module Proctolog
9
9
  # A writing older than the one kept: another page moved on since.
10
10
  Stale = Class.new(StandardError)
11
11
 
12
+ # One grade's share of a pull request's decisions: "blocking" or
13
+ # "non-blocking", how many it has, and how many of them wait.
14
+ Grade = Data.define(:name, :total, :left) do
15
+ def reviewed = total - left
16
+ end
17
+
12
18
  # How far one pull request's decisions are: a decision is reviewed once
13
19
  # the owner chose on its present words, or it is ticked on GitHub. Its
14
20
  # blocking decisions are counted apart: those left of them are what
@@ -31,6 +37,8 @@ module Proctolog
31
37
  def non_blocking = total - blocking
32
38
  def non_blocking_left = left - blocking_left
33
39
 
40
+ def grades = [Grade.new(name: "blocking", total: blocking, left: blocking_left), Grade.new(name: "non-blocking", total: non_blocking, left: non_blocking_left)]
41
+
34
42
  def +(other) = with(**members.to_h { [it, public_send(it) + other.public_send(it)] })
35
43
  end
36
44
 
@@ -16,7 +16,7 @@ module Proctolog
16
16
  end
17
17
 
18
18
  NEEDS = %i[ruby gh launcher checkout repository bursts].freeze
19
- NICETIES = %i[release skills chrome diffle imagemagick ffmpeg app port].freeze
19
+ NICETIES = %i[release skills chrome diffle imagemagick ffmpeg app share port].freeze
20
20
 
21
21
  # project is nil when the directory is no Git checkout.
22
22
  def initialize(project, home: Dir.home, path: ENV.fetch("PATH", ""))
@@ -104,6 +104,14 @@ module Proctolog
104
104
  fine("app.yml names #{app.parts.to_sentence}")
105
105
  end
106
106
 
107
+ # Tailscale or ngrok, either of which shares the desk with the owner's phone.
108
+ def share
109
+ ready, unready = Share.ways(path: @path.join(File::PATH_SEPARATOR)).partition(&:ready?)
110
+ return fine("Sharing through #{ready.map(&:name).to_sentence}: proctolog share") if ready.any?
111
+
112
+ off("Nothing shares the desk beyond this Mac. #{unready.map { "#{it.unready}." }.join(" ")}")
113
+ end
114
+
107
115
  def port = answers?(4040) ? off("Port 4040 answers already: a desk may be open there, or serve --port 4041") : fine("Port 4040 is free")
108
116
 
109
117
  def fine(line) = Finding.new(:fine, line)
@@ -240,7 +240,7 @@ module Proctolog
240
240
  # when the owner presses the button that shows this command first. An
241
241
  # alternate marks store remains a trial, even when a data directory moves.
242
242
  def rewrite(number, text)
243
- (ENV.key?("PROCTOLOG_MARKS") || ENV.key?("PROCTOLOG_READ_ONLY")) and raise Unreachable, "A trial or read-only desk does not write to GitHub."
243
+ (Desk.trial? || ENV.key?("PROCTOLOG_READ_ONLY")) and raise Unreachable, "A trial or read-only desk does not write to GitHub."
244
244
 
245
245
  Tempfile.create(["description", ".md"]) do |file|
246
246
  file.write(text)
@@ -20,7 +20,7 @@ module Proctolog
20
20
  def self.root = Pathname(ENV.fetch("PROCTOLOG_MARKS") { Project.current.marks })
21
21
 
22
22
  # A desk with marks of its own is a trial, whose marks are not the owner's.
23
- def self.trial? = ENV.key?("PROCTOLOG_MARKS")
23
+ def self.trial? = Desk.trial?
24
24
 
25
25
  def self.directory_of(burst) = root.join(burst.slug)
26
26
 
@@ -82,6 +82,10 @@ module Proctolog
82
82
  # Still needs someone: the agent while it is open, the owner once addressed.
83
83
  def live? = open? || addressed?
84
84
 
85
+ # Answered by an agent in a handover: the owner judges the answer under
86
+ # Returned work, which moves the note (Rounds#move_notes).
87
+ def answered_in_a_handover? = reply&.key?("response")
88
+
85
89
  # The decision of a description that this note answers otherwise: a change
86
90
  # asked for on words of a box. A question or a remark leaves the box open.
87
91
  def answers_in(description) = (description.decision_quoted_by(place) if change? && (place in Quote[section: Description::DECISIONS]))
@@ -11,6 +11,9 @@ module Proctolog
11
11
 
12
12
  def self.git_name = Open3.capture2("git", "config", "--get", "user.name").first.strip
13
13
 
14
+ # The address ngrok lets in when the owner named none (Share::Settings).
15
+ def self.git_email = Open3.capture2("git", "config", "--get", "user.email").first.strip
16
+
14
17
  def first_name = name.split.first
15
18
  end
16
19
  end
@@ -32,6 +32,18 @@ module Proctolog
32
32
 
33
33
  def empty? = @pull_requests.empty? && unopened.empty?
34
34
 
35
+ # Those of every kind with decisions for the reviewer, open or not.
36
+ def written = [*@pull_requests, *unopened].select { written?(it) }
37
+
38
+ # What the page first tells someone who has not reviewed here yet, or
39
+ # who would otherwise find nothing to do: nil once neither is true.
40
+ def start(first_time:)
41
+ if empty? then :nothing_open
42
+ elsif written.none? then :nothing_written
43
+ elsif first_time then :first_time
44
+ end
45
+ end
46
+
35
47
  # Whether a pull request nobody has opened yet sits on another pull
36
48
  # request's branch, so that it tops a chain.
37
49
  def chained?(pull_request) = [*@pull_requests, *unopened].any? { it.head == pull_request.base }