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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -3
- data/README.md +15 -4
- data/app/controllers/proctolog/application_controller.rb +1 -1
- data/app/controllers/proctolog/bursts_controller.rb +1 -1
- data/app/controllers/proctolog/choices_controller.rb +3 -1
- data/app/controllers/proctolog/decisions_controller.rb +1 -1
- data/app/controllers/proctolog/notes_controller.rb +2 -2
- data/app/controllers/proctolog/prompts_controller.rb +1 -1
- data/app/controllers/proctolog/pull_requests_controller.rb +2 -4
- data/app/controllers/proctolog/review_data_controller.rb +3 -1
- data/app/controllers/proctolog/shares_controller.rb +27 -0
- data/app/helpers/proctolog/decisions_helper.rb +6 -2
- data/app/helpers/proctolog/desk_helper.rb +48 -8
- data/app/helpers/proctolog/rounds_helper.rb +4 -2
- data/app/helpers/proctolog/shares_helper.rb +11 -0
- data/app/helpers/proctolog/standings_helper.rb +5 -4
- data/app/models/proctolog/burst.rb +17 -8
- data/app/models/proctolog/choices.rb +8 -0
- data/app/models/proctolog/doctor.rb +9 -1
- data/app/models/proctolog/github.rb +1 -1
- data/app/models/proctolog/marks.rb +1 -1
- data/app/models/proctolog/note.rb +4 -0
- data/app/models/proctolog/owner.rb +3 -0
- data/app/models/proctolog/pick.rb +12 -0
- data/app/models/proctolog/rounds.rb +37 -0
- data/app/models/proctolog/share/ngrok.rb +120 -0
- data/app/models/proctolog/share/settings.rb +23 -0
- data/app/models/proctolog/share/tailscale.rb +103 -0
- data/app/models/proctolog/share.rb +101 -0
- data/app/models/proctolog/standing.rb +24 -7
- data/app/views/proctolog/bursts/_line.html.erb +2 -2
- data/app/views/proctolog/bursts/_side.html.erb +6 -5
- data/app/views/proctolog/bursts/_side_stop.html.erb +4 -4
- data/app/views/proctolog/notes/_card.html.erb +7 -3
- data/app/views/proctolog/open_pull_requests/_side.html.erb +4 -3
- data/app/views/proctolog/open_pull_requests/_start.html.erb +8 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +4 -5
- data/app/views/proctolog/open_pull_requests/start/_first_time.html.erb +3 -0
- data/app/views/proctolog/open_pull_requests/start/_nothing_open.html.erb +3 -0
- data/app/views/proctolog/open_pull_requests/start/_nothing_written.html.erb +3 -0
- data/app/views/proctolog/shares/_line.html.erb +5 -0
- data/app/views/proctolog/shares/show.html.erb +48 -0
- data/app/views/proctolog/shares/ways/_ngrok.html.erb +6 -0
- data/app/views/proctolog/shares/ways/_tailscale.html.erb +2 -0
- data/bin/proctolog +92 -6
- data/completions/_proctolog +28 -4
- data/config/application.rb +23 -4
- data/config/initializers/grouse.rb +1 -1
- data/config/routes.rb +3 -0
- data/docs/manual/bursts.md +7 -2
- data/docs/manual/deciding.md +4 -1
- data/docs/manual/handover.md +6 -0
- data/docs/manual/reading.md +54 -27
- data/docs/manual/sharing.md +77 -0
- data/lib/proctolog/changelog.rb +50 -0
- data/lib/proctolog/version.rb +1 -1
- data/public/asking.js +31 -4
- data/public/decisions.js +4 -4
- data/public/desk.js +10 -6
- data/public/keys.js +6 -0
- data/public/notes.js +164 -58
- data/public/pick.css +21 -0
- data/public/respond.css +6 -0
- data/public/respond.js +51 -7
- data/public/review-drafts.js +47 -9
- data/public/review-rounds.js +27 -3
- data/public/share.css +58 -0
- data/public/side.js +11 -20
- data/public/terminal.css +295 -34
- data/public/viewer.js +700 -173
- 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
|
-
|
|
24
|
-
|
|
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?
|
|
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
|
|
3
|
-
<%= tag.li id: "pr-#{stop.number}", data: {stop: "",
|
|
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
|
-
<%
|
|
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(
|
|
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:,
|
|
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,
|
|
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:,
|
|
2
|
-
<%# One pull request of the pane:
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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"
|
|
18
|
+
<span class="mark"></span>
|
|
19
19
|
<span class="number"></span>
|
|
20
20
|
<span class="title"><%= burst.name %></span>
|
|
21
|
-
<span class="
|
|
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
|
-
|
|
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>
|