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
@@ -46,6 +46,18 @@ module Proctolog
46
46
  def newness = [order, Rounds.moment(response[:createdAt])]
47
47
  end
48
48
 
49
+ # What came back from an agent on one pull request besides its notes,
50
+ # which say so themselves once addressed (move_notes): the answers the
51
+ # owner is to confirm and the questions they are to answer.
52
+ Returned = Data.define(:to_confirm, :questions) do
53
+ def initialize(to_confirm: 0, questions: 0) = super
54
+ end
55
+
56
+ # Where a note an agent answered stands in its marks, by where its
57
+ # request stands: addressed while the answer waits on the owner,
58
+ # confirmed once they confirmed it, open again once they replied.
59
+ NOTE_STATES = {owner: "addressed", question: "addressed", closed: "confirmed", agent: "open", out: "open"}.freeze
60
+
49
61
  # A time as a page or an agent wrote it; one that cannot be read is
50
62
  # taken for the oldest.
51
63
  def self.moment(text)
@@ -93,6 +105,31 @@ module Proctolog
93
105
  end
94
106
  end
95
107
 
108
+ # What came back to the owner, by pull request.
109
+ def returned
110
+ everything.reject { it.dig(:source, :noteId) }.group_by { it[:pr] }.transform_values do |requests|
111
+ states = requests.group_by { state_of(it) }.tap { it.default = [] }
112
+ Returned.new(to_confirm: states[:owner].size, questions: states[:question].size)
113
+ end
114
+ end
115
+
116
+ # Moves each note an agent answered in a handover to where its answer
117
+ # stands (NOTE_STATES), with the answer as its reply, so the marks say
118
+ # what Returned work says: to the pages, and to an agent reading them.
119
+ # A note the owner withdrew stays withdrawn.
120
+ def move_notes
121
+ everything.each do |item|
122
+ id = item.dig(:source, :noteId) or next
123
+ latest = latest(item) or next
124
+ to = NOTE_STATES[state_of(item)] or next
125
+ marks = @burst.marks(item[:pr] || "sitting")
126
+ note = marks.note(id)
127
+ next if note.nil? || note.withdrawn? || [note.state, note.reply&.dig("response")] == [to, latest.key]
128
+
129
+ marks.move(id, to:, reply: {"text" => latest.result[:summary], "commit" => latest.result[:head]&.first(8), "response" => latest.key}.compact)
130
+ end
131
+ end
132
+
96
133
  # What an agent took and has not answered, with the handover it went in.
97
134
  def with_agent
98
135
  current.select { in_focus?(it[:pr]) && state_of(it) == :out }.map do |item|
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "yaml"
6
+
7
+ module Proctolog
8
+ class Share
9
+ # ngrok: the desk on the internet, at the address ngrok gives the account
10
+ # (on the free plan, one that stays the same), behind ngrok's sign-in with
11
+ # Google, which lets in only the addresses the owner allows: their Git
12
+ # email unless they named others. ngrok runs in the background on its own
13
+ # until the share is closed, the desk running or not, and keeps its log
14
+ # beside the share's file. It opens no inspector on this Mac, which would
15
+ # take 4040 or the port after it, a desk's.
16
+ class Ngrok
17
+ DOWNLOAD = "https://ngrok.com/download"
18
+ AUTHTOKEN = "https://dashboard.ngrok.com/get-started/your-authtoken"
19
+ WAIT = 20
20
+
21
+ def initialize(path: ENV.fetch("PATH", ""))
22
+ @program = path.split(File::PATH_SEPARATOR).map { File.join(it, "ngrok") }.find { File.executable?(it) }
23
+ end
24
+
25
+ def name = "ngrok"
26
+
27
+ def to_param = "ngrok"
28
+
29
+ def who = "only #{allowed.to_sentence(two_words_connector: " or ", last_word_connector: ", or ")}, signed in with Google, gets in"
30
+
31
+ def allowed = Settings.current.allowed
32
+
33
+ # What to do before it can share the desk; nil when nothing.
34
+ def unready
35
+ if !@program then "ngrok is not installed: brew install ngrok, or #{DOWNLOAD}"
36
+ elsif !authtoken? then "ngrok has no authtoken: copy yours from #{AUTHTOKEN}, then run ngrok config add-authtoken TOKEN"
37
+ elsif allowed.empty? then "ngrok would let nobody in: name yourself with proctolog share --via ngrok --allow EMAIL"
38
+ end
39
+ end
40
+
41
+ def ready? = unready.nil?
42
+
43
+ def open(port)
44
+ Share.folder.mkpath
45
+ log = Share.folder.join("#{port}.ngrok.log").tap { it.delete if it.exist? }
46
+ process = Process.spawn(Outside.environment, @program, "http", "http://localhost:#{port}", "--config", [config, quiet].join(","),
47
+ "--oauth", "google", *allowed.flat_map { ["--oauth-allow-email", it] }, "--log", log.to_s, "--log-format", "json",
48
+ in: File::NULL, out: File::NULL, err: File::NULL, pgroup: true)
49
+ Process.detach(process)
50
+ Share.new(port:, way: self, address: started(log, process), process:)
51
+ end
52
+
53
+ def close(share) = (stop(share.process) if carrying?(share))
54
+
55
+ # Its process is still this share's ngrok, not another program given
56
+ # the same number since.
57
+ def carrying?(share)
58
+ share.process.present? && Open3.capture3("ps", "-o", "command=", "-p", share.process.to_s).first.include?("ngrok http http://localhost:#{share.port} ")
59
+ end
60
+
61
+ private
62
+
63
+ # What ngrok logged until its address, or its reason for giving none.
64
+ def started(log, process)
65
+ deadline = WAIT.seconds.from_now
66
+ while Time.current < deadline
67
+ lines = log.exist? ? log.each_line.filter_map { logged(it) } : []
68
+ if (tunnel = lines.find { it["msg"] == "started tunnel" }) then return tunnel.fetch("url")
69
+ elsif (failure = lines.find { it["lvl"] == "crit" }) then raise Refused, said(failure.fetch("err", ""))
70
+ elsif !alive?(process) then raise Refused, "ngrok stopped before it gave the desk an address: #{log}."
71
+ end
72
+ sleep 0.1
73
+ end
74
+ stop(process)
75
+ raise Refused, "ngrok gave the desk no address within #{WAIT} seconds: #{log}."
76
+ end
77
+
78
+ # A line of ngrok's log; nil for one it had not finished writing.
79
+ def logged(line)
80
+ JSON.parse(line)
81
+ rescue JSON::ParserError
82
+ nil
83
+ end
84
+
85
+ # ngrok's first sentence, and its error's code, which its docs explain.
86
+ def said(error) = "ngrok said: #{error.lines.first.to_s.strip.chomp(".")}#{" (#{error[/ERR_NGROK_\d+/]})" if error[/ERR_NGROK_\d+/]}."
87
+
88
+ def alive?(process)
89
+ Process.kill(0, process)
90
+ true
91
+ rescue Errno::ESRCH
92
+ false
93
+ end
94
+
95
+ # The whole group: ngrok, and whatever it started.
96
+ def stop(process)
97
+ Process.kill("TERM", -process)
98
+ rescue Errno::ESRCH, Errno::EPERM
99
+ nil
100
+ end
101
+
102
+ def authtoken? = ENV["NGROK_AUTHTOKEN"].present? || own_settings.then { it["authtoken"] || it.dig("agent", "authtoken") }.present?
103
+
104
+ # ngrok's own configuration, where `ngrok config add-authtoken` wrote.
105
+ def config
106
+ @config ||= Open3.capture2e(Outside.environment, @program, "config", "check").first[/configuration file at (.+\.yml)/, 1] ||
107
+ File.expand_path("~/Library/Application Support/ngrok/ngrok.yml")
108
+ end
109
+
110
+ def own_settings = File.file?(config) ? (YAML.safe_load_file(config) || {}) : {}
111
+
112
+ # Read over ngrok's own, in the same version: no inspector.
113
+ def quiet
114
+ Share.folder.join("ngrok.yml").tap do |file|
115
+ file.write((own_settings["version"].to_s == "3") ? {"version" => 3, "agent" => {"web_addr" => false}}.to_yaml : {"version" => "2", "web_addr" => false}.to_yaml)
116
+ end.to_s
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Proctolog
6
+ class Share
7
+ # What the owner chose about sharing, for every checkout on this Mac: the
8
+ # way they last named, and the addresses ngrok lets in. Kept in share.yml
9
+ # beside the desk's secret.
10
+ class Settings < Data.define(:via, :allow)
11
+ def self.file = Desk.data.join("share.yml")
12
+
13
+ def self.current = new(**(file.file? ? (YAML.safe_load_file(file) || {}) : {}).symbolize_keys.slice(:via, :allow))
14
+
15
+ def initialize(via: nil, allow: []) = super(via:, allow: Array(allow).map { it.to_s.strip.downcase }.compact_blank.uniq)
16
+
17
+ def save = self.class.file.tap { it.dirname.mkpath }.write({"via" => via, "allow" => allow}.compact_blank.to_yaml)
18
+
19
+ # Those named, or the owner as Git knows them, as Google spells an address.
20
+ def allowed = allow.presence || [Owner.git_email.downcase].compact_blank
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "uri"
6
+
7
+ module Proctolog
8
+ class Share
9
+ # Tailscale's serve: the desk at this Mac's own name on the owner's
10
+ # tailnet, https://mac.tailnet.ts.net, which only the devices signed in to
11
+ # that tailnet reach. Nothing goes on the internet, and Tailscale is the
12
+ # sign-in. Tailscale keeps serving it, the desk running or not, until the
13
+ # share is closed.
14
+ class Tailscale
15
+ DOWNLOAD = "https://tailscale.com/download/mac"
16
+ CERTIFICATES = "https://login.tailscale.com/admin/dns"
17
+ # The app's own program, for a Mac that never put tailscale on its PATH.
18
+ APP = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
19
+ # The first share of a tailnet can wait on its owner turning Serve on, in a browser.
20
+ WAIT = 20
21
+
22
+ def initialize(path: ENV.fetch("PATH", ""), app: APP)
23
+ @program = path.split(File::PATH_SEPARATOR).map { File.join(it, "tailscale") }.find { File.executable?(it) } ||
24
+ (app if File.executable?(app))
25
+ end
26
+
27
+ def name = "Tailscale"
28
+
29
+ def to_param = "tailscale"
30
+
31
+ # By the tailnet's name: one account can be in several, and a phone
32
+ # signed in to another one never sees this Mac.
33
+ def who = "only devices signed in to your tailnet#{", #{tailnet}," if tailnet} reach it"
34
+
35
+ def tailnet = status.dig("CurrentTailnet", "Name")
36
+
37
+ # What to do before it can share the desk; nil when nothing.
38
+ def unready
39
+ return "Tailscale is not installed: #{DOWNLOAD}" unless @program
40
+
41
+ case status["BackendState"]
42
+ when "Running" then ("Tailscale gives your tailnet no HTTPS certificates: turn them on at #{CERTIFICATES}" if status["CertDomains"].blank?)
43
+ when "NeedsLogin", "NeedsMachineAuth" then "Tailscale is signed out: sign in from its icon in the menu bar"
44
+ else "Tailscale is off: turn it on from its icon in the menu bar"
45
+ end
46
+ end
47
+
48
+ def ready? = unready.nil?
49
+
50
+ # On 443, the tailnet name itself, unless Tailscale serves something
51
+ # else there; then on the desk's own port.
52
+ def open(port)
53
+ https = served.fetch("TCP", {}).key?("443") ? port : 443
54
+ run("serve", "--bg", "--yes", "--https=#{https}", "http://localhost:#{port}")
55
+ Share.new(port:, way: self, address: "https://#{status.dig("Self", "DNSName").to_s.chomp(".")}#{":#{https}" unless https == 443}")
56
+ end
57
+
58
+ def close(share) = run("serve", "--https=#{URI(share.address).port}", "off")
59
+
60
+ def carrying?(share)
61
+ address = URI(share.address)
62
+ served.dig("Web", "#{address.host}:#{address.port}", "Handlers", "/", "Proxy") == "http://localhost:#{share.port}"
63
+ end
64
+
65
+ private
66
+
67
+ def status = @status ||= asked("status", "--json")
68
+
69
+ def served = asked("serve", "status", "--json")
70
+
71
+ # Tailscale's answer as JSON; empty when it gave none, as a stopped one does.
72
+ def asked(*arguments)
73
+ output, = Open3.capture3(Outside.environment, @program, *arguments)
74
+ JSON.parse(output)
75
+ rescue JSON::ParserError, SystemCallError
76
+ {}
77
+ end
78
+
79
+ def run(*arguments)
80
+ Open3.popen3(Outside.environment, @program, *arguments) do |input, output, errors, waiter|
81
+ input.close
82
+ said = [output, errors].map { |stream| Thread.new { stream.read } }
83
+ finished = waiter.join(WAIT)
84
+ Process.kill("TERM", waiter.pid) unless finished
85
+ words = said.map(&:value).join
86
+ raise Refused, refusal(words) unless finished&.value&.success?
87
+
88
+ words
89
+ end
90
+ end
91
+
92
+ # Serve asks a tailnet's owner once to turn it on, at an address it
93
+ # prints, and waits.
94
+ def refusal(words)
95
+ if (address = words[%r{https://login\.tailscale\.com/\S+}])
96
+ "Tailscale waits for Serve to be turned on for your tailnet: #{address}, then share again."
97
+ else
98
+ "Tailscale said: #{words.lines.grep_v(/\AWarning:|TPM/).join(" ").squish.chomp(".").presence || "nothing, and served nothing"}."
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "yaml"
5
+
6
+ module Proctolog
7
+ # The desk reached from away from this Mac, by its owner alone: from their
8
+ # phone, or a computer elsewhere, through whichever of Tailscale or ngrok
9
+ # this Mac has. Tailscale lets in the devices of the owner's own tailnet
10
+ # and puts nothing on the internet; ngrok puts the desk there behind its
11
+ # sign-in with Google, for the addresses the owner allows.
12
+ #
13
+ # A share is of the desk on one port, kept as a file in the cache by that
14
+ # port. It stays open until it is closed, across the desk's restarts, and
15
+ # while its file says so the desk answers to the share's name as to its own.
16
+ # `who` is whom it lets in, as it was opened.
17
+ class Share < Data.define(:port, :way, :address, :who, :process)
18
+ Refused = Class.new(StandardError)
19
+
20
+ def initialize(port:, way:, address:, who: way.who, process: nil) = super
21
+
22
+ class << self
23
+ # The ways this Mac could share the desk, Tailscale first: it puts
24
+ # nothing on the internet.
25
+ def ways(path: ENV.fetch("PATH", "")) = [Tailscale.new(path:), Ngrok.new(path:)]
26
+
27
+ # The share of the desk on port, while its way still carries it. One
28
+ # whose way let it go (ngrok ended with the Mac's restart, say) is
29
+ # forgotten.
30
+ def at(port, ways: self.ways)
31
+ kept(port, ways:)&.then do |share|
32
+ next share if share.way.carrying?(share)
33
+
34
+ file(port).delete
35
+ nil
36
+ end
37
+ end
38
+
39
+ # As its file says, without asking its way: for every page of a burst,
40
+ # which says whether the desk is shared.
41
+ def kept(port, ways: self.ways)
42
+ record = recorded(port) or return
43
+ way = ways.find { it.to_param == record["way"] } or return
44
+
45
+ new(port:, way:, address: record["address"], who: record.fetch("who") { way.who }, process: record["process"])
46
+ end
47
+
48
+ # Shares the desk on port through the way named, which is remembered,
49
+ # or the one last named while it is ready, or the first one ready.
50
+ # `allow` names whom ngrok lets in from now on. A share already open is
51
+ # only answered. A trial desk, on marks of its own, is never shared: it
52
+ # tries a change on this Mac, and its settings are the owner's.
53
+ def open(port, via: nil, allow: nil, ways: self.ways)
54
+ raise Refused, "A trial desk is not shared: it is for trying a change on this Mac." if Desk.trial?
55
+
56
+ Settings.current.with(allow:).save if allow
57
+ at(port, ways:) || chosen(via, ways).open(port).tap do
58
+ it.keep
59
+ Settings.current.with(via:).save if via
60
+ end
61
+ end
62
+
63
+ # Whether a request came through the desk's share: by the name it was
64
+ # asked for. Read from the file alone, as every request from away asks.
65
+ def carries?(request, port: Desk.port) = recorded(port)&.then { URI(it["address"]).host.casecmp?(request.host) } || false
66
+
67
+ def folder = Desk.cache.join("shares")
68
+
69
+ def file(port) = folder.join("#{port}.yml")
70
+
71
+ private
72
+
73
+ def recorded(port) = file(port).then { YAML.safe_load_file(it) if it.file? }
74
+
75
+ def chosen(via, ways)
76
+ if via
77
+ way = ways.find { it.to_param == via } or raise Refused, "The desk is shared through #{ways.map(&:to_param).to_sentence(last_word_connector: " or ", two_words_connector: " or ")}, not #{via}."
78
+ way.ready? ? way : raise(Refused, "#{way.unready}.")
79
+ else
80
+ remembered = Settings.current.via
81
+ ways.find { it.to_param == remembered && it.ready? } || ways.find(&:ready?) || raise(Refused, unshareable(ways))
82
+ end
83
+ end
84
+
85
+ def unshareable(ways) = "Neither #{ways.map(&:name).join(" nor ")} can share the desk yet. #{ways.map { "#{it.unready}." }.join(" ")}"
86
+ end
87
+
88
+ def keep = file.tap { it.dirname.mkpath }.write({"way" => way.to_param, "address" => address, "who" => who, "process" => process}.compact.to_yaml)
89
+
90
+ def close
91
+ way.close(self)
92
+ file.delete if file.exist?
93
+ end
94
+
95
+ def host = URI(address).host
96
+
97
+ def file = self.class.file(port)
98
+
99
+ def to_s = "The desk is shared at #{address} through #{way.name}: #{who}."
100
+ end
101
+ end
@@ -19,24 +19,43 @@ module Proctolog
19
19
  # under it. `scenarios` are the names of this pull request's scenarios, and
20
20
  # `named` the preconditions the burst file names for it. `progress` is how
21
21
  # far the owner's choices on its decisions are; without one, only the boxes
22
- # ticked on GitHub count as reviewed.
23
- def initialize(stop:, marks:, progress: nil, under: nil, above: [], scenarios: [], named: [])
24
- @stop, @marks, @under, @above, @scenarios, @named = stop, marks, under, above, scenarios, named
22
+ # ticked on GitHub count as reviewed. `returned` is what came back from an
23
+ # agent on it.
24
+ def initialize(stop:, marks:, progress: nil, returned: Rounds::Returned.new, under: nil, above: [], scenarios: [], named: [])
25
+ @stop, @marks, @returned, @under, @above, @scenarios, @named = stop, marks, returned, under, above, scenarios, named
25
26
  @progress = progress || Choices::Progress.of(decisions.index_with(&:ticked))
26
27
  end
27
28
 
28
29
  # Whose move it is: :waiting on something outside, the :agent's, the
29
- # :owner's, or nobody's because it is :settled.
30
+ # :owner's, or nobody's because it is :settled. What came back from the
31
+ # agent makes it the owner's, whatever else is still with the agent.
30
32
  def turn
31
33
  if marks.waiting_on then :waiting
34
+ elsif back_to_owner? then :owner
32
35
  elsif for_the_agent.any? then :agent
33
- elsif good_to_go? && !changed? && for_the_owner.empty? then :settled
36
+ elsif good_to_go? && !changed? then :settled
34
37
  else :owner
35
38
  end
36
39
  end
37
40
 
38
41
  def settled? = turn == :settled
39
42
 
43
+ # What came back from the agent for the owner, in a few words each: the
44
+ # notes it addressed, its answers to confirm and its questions.
45
+ def for_the_owner
46
+ addressed = notes(:addressed).size
47
+ [*("#{count(addressed, "note")} addressed, to confirm" if addressed.positive?),
48
+ *("#{@returned.to_confirm} returned, to confirm" if @returned.to_confirm.positive?),
49
+ *("#{count(@returned.questions, "question")} for you" if @returned.questions.positive?)]
50
+ end
51
+
52
+ def back_to_owner? = for_the_owner.any?
53
+
54
+ # Whether it waits for the owner: a blocking decision left, or something
55
+ # back from the agent. The non-blocking wait for one gesture, and hold
56
+ # nothing up.
57
+ def asks_for_owner? = back_to_owner? || progress.blocking_left.positive?
58
+
40
59
  def good_to_go? = marks.good_to_go.present?
41
60
 
42
61
  # What moved since "good to go" was given: the code, the description, both
@@ -91,8 +110,6 @@ module Proctolog
91
110
  *("#{count(stop.open_threads, "review thread")} unresolved" if stop.open_threads.positive?)]
92
111
  end
93
112
 
94
- def for_the_owner = notes(:addressed).any? ? ["#{count(notes(:addressed).size, "note")} addressed, to confirm"] : []
95
-
96
113
  def notes(state) = marks.notes.select { it.state == state.to_s }
97
114
 
98
115
  def count(number, noun) = "#{number} #{noun.pluralize(number)}"
@@ -1,6 +1,6 @@
1
1
  <%# locals: (burst:, stop:, standing:) %>
2
- <%# One pull request of the route, in a line: its number, its title, and at the right whose it is when it is not the owner's, and how far its decisions are. The line the cursor is on opens out: its title in full, what else there is to know of it, and its keys beside it. %>
3
- <%= tag.li id: "pr-#{stop.number}", data: {stop: "", blocking_left: standing.progress.blocking_left, turn: standing.turn} do %>
2
+ <%# One pull request of the route, in a line: its number, its title, and at the right whether something came back from the agent, or else whose it is when it is not the owner's, and its decisions as beads. The line the cursor is on opens out: its title in full, what else there is to know of it, and its keys beside it. %>
3
+ <%= tag.li id: "pr-#{stop.number}", data: {stop: "", asks: standing.asks_for_owner?, turn: standing.turn} do %>
4
4
  <p class="what">
5
5
  <%= link_to "##{stop.number}", burst_pull_request_path(burst, stop.number), class: "number" %>
6
6
  <%= link_to stop.title, burst_pull_request_path(burst, stop.number), class: "title" %>
@@ -1,7 +1,7 @@
1
1
  <%# locals: (burst:, at: nil) %>
2
- <%# The pane at the left of every page of a burst: the plaque the desk is named for, where the desk's pages are, and the route with how far each pull request's decisions are, so the owner always sees where they stand. `at` is the pull request the page is on. Focused on a part, it lists that part, with the key back to the whole burst, and counts what is left in the part against the burst; a pull request opened from outside the part stands apart under it. A page that keeps choices rewrites the counts as they change. data-desk keeps it out of a page's reading order. %>
2
+ <%# The pane at the left of every page of a burst: the plaque the desk is named for, where the desk's pages are, and the route with how far each pull request's decisions are and what came back from the agent, so the owner always sees where they stand. `at` is the pull request the page is on. Focused on a part, it lists that part, with the key back to the whole burst, and counts what is left in the part against the burst; a pull request opened from outside the part stands apart under it. A page that keeps choices rewrites the counts as they change. data-desk keeps it out of a page's reading order. %>
3
3
  <% route = burst.route %>
4
- <% progress = burst.progress(route) %>
4
+ <% standings = burst.standings(route) %>
5
5
  <aside class="side" aria-label="The burst" data-desk>
6
6
  <div class="head">
7
7
  <%= link_to image_tag("/plaque.png", alt: "", class: "plaque"), burst_path(burst), tabindex: -1, aria: {hidden: true} %>
@@ -19,7 +19,7 @@
19
19
  <p class="focus">Only <%= focus.part.in_words %> · <%= link_to url_for(part: nil), data: {key: "P"} do %><kbd>P</kbd> the whole burst<% end %></p>
20
20
  <% end %>
21
21
  <%= tag.p class: "overall", data: {side_overall: "", part_prs: (focus.map(&:number).join(" ") unless focus.whole?)} do %>
22
- <%= side_gauge(progress.each_value.sum(Proctolog::Choices::NONE), part: (focus.sum(Proctolog::Choices::NONE) { progress.fetch(it.number) } unless focus.whole?)) %>
22
+ <%= side_gauge(standings.each_value.sum(Proctolog::Choices::NONE, &:progress), part: (focus.sum(Proctolog::Choices::NONE) { standings.fetch(it).progress } unless focus.whole?)) %>
23
23
  <% end %>
24
24
  <%# Only how many notices there are: they are read at the head of the route. %>
25
25
  <% if (notices = burst.notices).any? %>
@@ -30,14 +30,14 @@
30
30
  <h2><%= part_heading(part, route) %></h2>
31
31
  <ol class="stops">
32
32
  <% route.stops_of(part).each do |stop| %>
33
- <%= render "proctolog/bursts/side_stop", burst:, stop:, progress: progress.fetch(stop.number), at: %>
33
+ <%= render "proctolog/bursts/side_stop", burst:, stop:, standing: standings.fetch(stop), at: %>
34
34
  <% end %>
35
35
  </ol>
36
36
  <% end %>
37
37
  <% if (outside = route.at(at)) && !focus.include?(outside) %>
38
38
  <h2>Outside this part</h2>
39
39
  <ol class="stops">
40
- <%= render "proctolog/bursts/side_stop", burst:, stop: outside, progress: progress.fetch(outside.number), at: %>
40
+ <%= render "proctolog/bursts/side_stop", burst:, stop: outside, standing: standings.fetch(outside), at: %>
41
41
  </ol>
42
42
  <% end %>
43
43
 
@@ -45,5 +45,6 @@
45
45
  <%= tag.div class: "read", data: {freshness: burst_freshness_path(burst), read_at: burst.read_at.iso8601} do %>
46
46
  <p>GitHub read <%= tag.time read_at_in_words(burst.read_at), datetime: burst.read_at.iso8601 %> · <%= link_to "read again", burst_freshness_path(burst, again: 1, back: request.fullpath), class: "again", data: {key: "r"} %></p>
47
47
  <p class="moved" data-moved hidden></p>
48
+ <%= render "proctolog/shares/line" %>
48
49
  <% end %>
49
50
  </aside>
@@ -1,10 +1,10 @@
1
- <%# locals: (burst:, stop:, progress:, at:) %>
2
- <%# One pull request of the pane: its mark, its number, its title and how far its decisions are, marked when the page is about it. Held under the pointer, side.js says the same in words, with its title in full and the way to it on GitHub. %>
1
+ <%# locals: (burst:, stop:, standing:, at:) %>
2
+ <%# One pull request of the pane: ↩ when the agent's answers came back on it, its number, its title and its decisions as beads, marked when the page is about it. Held under the pointer, side.js says the same in words, with its title in full and the way to it on GitHub. %>
3
3
  <%= tag.li data: {side_pr: stop.number, github: stop.url}, aria: {current: ("page" if stop.number == at)} do %>
4
4
  <%= link_to burst_pull_request_path(burst, stop.number) do %>
5
- <span class="mark" data-side-mark><%= side_mark(progress) %></span>
5
+ <%= side_mark(standing.for_the_owner) %>
6
6
  <span class="number"><%= stop.number %></span>
7
7
  <span class="title"><%= stop.title %></span>
8
- <span class="fraction" data-side-fraction><%= side_fraction(progress) %></span>
8
+ <span class="decisions" data-side-decisions><%= decision_beads(standing.progress) %></span>
9
9
  <% end %>
10
10
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <%# locals: (note:, burst:, stop:, where: nil, back: nil, attributes: {}) %>
2
- <%# One note as a card: what it is, what was said, where it is pinned (`where`), the agent's reply, and the ways to move it along. A press of those comes back to `back` when the page that showed the card gave one. %>
2
+ <%# One note as a card: what it is, what was said, where it is pinned (`where`), the agent's reply, and the ways to move it along. A press of those comes back to `back` when the page that showed the card gave one. A note answered in a handover is confirmed or answered back under Returned work, which moves it (Rounds#move_notes), so it can only be withdrawn here. %>
3
3
  <%= tag.li(**{class: "card", data: {kind: note.kind, state: note.state}}.deep_merge(attributes)) do %>
4
4
  <p class="meta"><span class="kind"><%= note.kind %></span> <span class="state"><%= note.state %></span></p>
5
5
  <p class="text"><%= with_mentions(note.text, burst:) %></p>
@@ -12,8 +12,12 @@
12
12
  <% end %>
13
13
  <div class="moves">
14
14
  <% path = burst_pull_request_note_path(burst, stop.number, note.id) %>
15
- <%= button_to "Confirm", path, method: :patch, params: {state: "confirmed", return: back}.compact, class: "quiet" if note.addressed? %>
16
- <%= button_to "Reopen", path, method: :patch, params: {state: "open", return: back}.compact, class: "quiet" unless note.open? %>
15
+ <% if note.answered_in_a_handover? %>
16
+ <% if note.addressed? %><p class="judged">Confirm it, or answer back, under Returned work.</p><% end %>
17
+ <% else %>
18
+ <%= button_to "Confirm", path, method: :patch, params: {state: "confirmed", return: back}.compact, class: "quiet" if note.addressed? %>
19
+ <%= button_to "Reopen", path, method: :patch, params: {state: "open", return: back}.compact, class: "quiet" unless note.open? %>
20
+ <% end %>
17
21
  <%= button_to "Withdraw", path, method: :patch, params: {state: "withdrawn", return: back}.compact, class: "quiet" unless note.withdrawn? %>
18
22
  </div>
19
23
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <%# locals: (bursts:, read_at:) %>
2
- <%# The pane at the left where no burst is open: the plaque, where the desk's pages are, and the bursts to open, the newest first, each with how far its decisions are. Those put away stay off it, as on the page. %>
2
+ <%# The pane at the left where no burst is open: the plaque, where the desk's pages are, and the bursts to open, the newest first, each with how many of its decisions wait. Those put away stay off it, as on the page. %>
3
3
  <aside class="side" aria-label="The desk" data-desk>
4
4
  <div class="head">
5
5
  <%= link_to image_tag("/plaque.png", alt: "", class: "plaque"), open_pull_requests_path, tabindex: -1, aria: {hidden: true} %>
@@ -15,10 +15,10 @@
15
15
  <% progress = burst.progress(burst.route).each_value.sum(Proctolog::Choices::NONE) %>
16
16
  <li>
17
17
  <%= link_to burst_path(burst) do %>
18
- <span class="mark"><%= side_mark(progress) %></span>
18
+ <span class="mark"></span>
19
19
  <span class="number"></span>
20
20
  <span class="title"><%= burst.name %></span>
21
- <span class="fraction"><%= side_fraction(progress) %></span>
21
+ <span class="decisions"><%= decisions_left(progress) %></span>
22
22
  <% end %>
23
23
  </li>
24
24
  <% end %>
@@ -26,5 +26,6 @@
26
26
  <% end %>
27
27
  <div class="read">
28
28
  <p>GitHub read <%= tag.time read_at_in_words(read_at), datetime: read_at.iso8601 %> · <%= link_to "read again", open_pull_requests_path(fresh: 1), class: "again" %></p>
29
+ <%= render "proctolog/shares/line" %>
29
30
  </div>
30
31
  </aside>
@@ -0,0 +1,8 @@
1
+ <%# locals: (pick:, repository:, first_time:) %>
2
+ <%# At the head of the page, what to do here: for someone who has not reviewed here yet, or when nothing below has decisions to ask. %>
3
+ <% if (start = pick.start(first_time:)) %>
4
+ <section class="start" aria-labelledby="start">
5
+ <h2 id="start">Start here</h2>
6
+ <%= render "proctolog/open_pull_requests/start/#{start}", pick:, repository: %>
7
+ </section>
8
+ <% end %>
@@ -15,6 +15,8 @@
15
15
  </header>
16
16
 
17
17
  <main class="route pick">
18
+ <%= render "start", pick: @pick, repository: @repository, first_time: [*@bursts, *@put_away].none?(&:own?) %>
19
+
18
20
  <% if @bursts.any? || @put_away.any? %>
19
21
  <h2>Your bursts</h2>
20
22
  <ol class="lines">
@@ -36,10 +38,6 @@
36
38
  <% end %>
37
39
  <% end %>
38
40
 
39
- <% if @pick.empty? %>
40
- <p class="lede">Nothing is open in <%= @repository %>.</p>
41
- <% end %>
42
-
43
41
  <% @pick.stacks.each do |stack| %>
44
42
  <h2>Stack #<%= stack.number %>, <%= stack.pull_requests.size %> from the bottom up</h2>
45
43
  <%= button_to "Review the stack", review_stack_path(stack.number), class: "quiet" %>
@@ -59,7 +57,8 @@
59
57
 
60
58
  <% if (loose = @pick.loose).any? %>
61
59
  <h2><%= @pick.stacks.any? ? "In no stack" : "Pull requests" %></h2>
62
- <%= form_with url: bursts_path, method: :post do |form| %>
60
+ <%# Its "Review alone" buttons post elsewhere, so the form carries the session's token, not one good for bursts_path alone. %>
61
+ <%= form_with url: bursts_path, method: :post, authenticity_token: form_authenticity_token do |form| %>
63
62
  <ol class="lines">
64
63
  <% loose.each do |pull_request| %>
65
64
  <li>
@@ -0,0 +1,3 @@
1
+ <%# locals: (pick:, repository:) %>
2
+ <p><%= pluralize(pick.written.size, "pull request") %> below <%= (pick.written.size == 1) ? "is" : "are" %> written for PRoctolog: their facts count the decisions they ask you. Press <b>Review alone</b> on one, <b>Review the stack</b> for a whole stack, or tick a few to review them together.</p>
3
+ <p>Inside, <kbd>?</kbd> shows the keys.</p>
@@ -0,0 +1,3 @@
1
+ <%# locals: (pick:, repository:) %>
2
+ <p>Nothing is open in <%= repository %>, and no branch here waits to be opened.</p>
3
+ <p>The desk reviews pull requests whose descriptions list decisions for you. Ask your agent to “write these PRs for PRoctolog”: it opens them on GitHub, or leaves them on branches here for you to review first. Then <%= link_to "read GitHub again", open_pull_requests_path(fresh: 1) %>.</p>
@@ -0,0 +1,3 @@
1
+ <%# locals: (pick:, repository:) %>
2
+ <p>None of these is written for PRoctolog yet, so the desk has no decisions to ask you. Any of them still opens, to read with its pictures and code.</p>
3
+ <p>For decisions, ask your agent to “rewrite these PRs for PRoctolog”. <code>proctolog check <%= pick.loose.first&.number || pick.stacks.first&.top&.number %></code> says what a description still lacks.</p>
@@ -0,0 +1,5 @@
1
+ <%# At the foot of the pane at the left: whether the desk is shared, as its file says, and the page to share it or stop. %>
2
+ <% share = Proctolog::Share.kept(Proctolog::Desk.port) %>
3
+ <p class="share">
4
+ <%= share ? "Shared through #{share.way.name}" : "Only on this Mac" %> · <%= link_to (share ? "see it" : "share it"), share_path(back: request.fullpath), class: "again" %>
5
+ </p>