proctolog 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/README.md +178 -0
- data/app/controllers/proctolog/acceptances_controller.rb +29 -0
- data/app/controllers/proctolog/application_controller.rb +32 -0
- data/app/controllers/proctolog/bursts_controller.rb +34 -0
- data/app/controllers/proctolog/choices_controller.rb +30 -0
- data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
- data/app/controllers/proctolog/decisions_controller.rb +33 -0
- data/app/controllers/proctolog/entries_controller.rb +14 -0
- data/app/controllers/proctolog/evidence_controller.rb +23 -0
- data/app/controllers/proctolog/freshness_controller.rb +27 -0
- data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
- data/app/controllers/proctolog/memberships_controller.rb +20 -0
- data/app/controllers/proctolog/mentions_controller.rb +16 -0
- data/app/controllers/proctolog/moments_controller.rb +26 -0
- data/app/controllers/proctolog/note_params.rb +24 -0
- data/app/controllers/proctolog/notes_controller.rb +56 -0
- data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
- data/app/controllers/proctolog/pictures_controller.rb +27 -0
- data/app/controllers/proctolog/prompts_controller.rb +28 -0
- data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
- data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
- data/app/controllers/proctolog/put_away_controller.rb +19 -0
- data/app/controllers/proctolog/recordings_controller.rb +28 -0
- data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
- data/app/controllers/proctolog/review_data_controller.rb +12 -0
- data/app/controllers/proctolog/rules_controller.rb +20 -0
- data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
- data/app/controllers/proctolog/scenario_scoped.rb +21 -0
- data/app/controllers/proctolog/scenarios_controller.rb +67 -0
- data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
- data/app/controllers/proctolog/waiting_controller.rb +14 -0
- data/app/controllers/proctolog/with_decisions.rb +28 -0
- data/app/helpers/proctolog/bursts_helper.rb +52 -0
- data/app/helpers/proctolog/decisions_helper.rb +102 -0
- data/app/helpers/proctolog/desk_helper.rb +61 -0
- data/app/helpers/proctolog/mentions_helper.rb +27 -0
- data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
- data/app/helpers/proctolog/prompts_helper.rb +21 -0
- data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
- data/app/helpers/proctolog/recordings_helper.rb +30 -0
- data/app/helpers/proctolog/rounds_helper.rb +27 -0
- data/app/helpers/proctolog/scenarios_helper.rb +123 -0
- data/app/helpers/proctolog/standings_helper.rb +80 -0
- data/app/jobs/proctolog/application_job.rb +8 -0
- data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
- data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
- data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
- data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
- data/app/models/proctolog/acceptance.rb +47 -0
- data/app/models/proctolog/app.rb +92 -0
- data/app/models/proctolog/browser.rb +42 -0
- data/app/models/proctolog/burst.rb +268 -0
- data/app/models/proctolog/burst_notices.rb +143 -0
- data/app/models/proctolog/chapters.rb +42 -0
- data/app/models/proctolog/check.rb +122 -0
- data/app/models/proctolog/choices.rb +112 -0
- data/app/models/proctolog/code_review.rb +251 -0
- data/app/models/proctolog/decision_record.rb +12 -0
- data/app/models/proctolog/decisions.rb +199 -0
- data/app/models/proctolog/description.rb +165 -0
- data/app/models/proctolog/difference.rb +138 -0
- data/app/models/proctolog/doctor.rb +127 -0
- data/app/models/proctolog/evidence.rb +29 -0
- data/app/models/proctolog/failure.rb +7 -0
- data/app/models/proctolog/focus.rb +48 -0
- data/app/models/proctolog/fresh_sitting.rb +35 -0
- data/app/models/proctolog/github.rb +353 -0
- data/app/models/proctolog/handover/brief.rb +138 -0
- data/app/models/proctolog/handover/layers.rb +128 -0
- data/app/models/proctolog/handover/places.rb +98 -0
- data/app/models/proctolog/handover/taken.rb +41 -0
- data/app/models/proctolog/handover/worklist.rb +35 -0
- data/app/models/proctolog/handover.rb +123 -0
- data/app/models/proctolog/housekeeping.rb +23 -0
- data/app/models/proctolog/kit.rb +36 -0
- data/app/models/proctolog/marks.rb +130 -0
- data/app/models/proctolog/membership.rb +91 -0
- data/app/models/proctolog/mentions.rb +117 -0
- data/app/models/proctolog/note.rb +94 -0
- data/app/models/proctolog/outside.rb +45 -0
- data/app/models/proctolog/overlay/brief.rb +52 -0
- data/app/models/proctolog/overlay/written.rb +33 -0
- data/app/models/proctolog/overlay.rb +87 -0
- data/app/models/proctolog/owner.rb +16 -0
- data/app/models/proctolog/pick.rb +47 -0
- data/app/models/proctolog/picture.rb +138 -0
- data/app/models/proctolog/pieces_rendering.rb +40 -0
- data/app/models/proctolog/project.rb +118 -0
- data/app/models/proctolog/pull_request.rb +89 -0
- data/app/models/proctolog/recording.rb +194 -0
- data/app/models/proctolog/release.rb +59 -0
- data/app/models/proctolog/rendering.rb +303 -0
- data/app/models/proctolog/reports.rb +66 -0
- data/app/models/proctolog/response_files.rb +55 -0
- data/app/models/proctolog/rounds.rb +305 -0
- data/app/models/proctolog/route.rb +151 -0
- data/app/models/proctolog/rules.rb +121 -0
- data/app/models/proctolog/scenario.rb +126 -0
- data/app/models/proctolog/server.rb +273 -0
- data/app/models/proctolog/since.rb +108 -0
- data/app/models/proctolog/size.rb +30 -0
- data/app/models/proctolog/standing.rb +132 -0
- data/app/models/proctolog/theme.rb +40 -0
- data/app/models/proctolog/unopened/numbers.rb +48 -0
- data/app/models/proctolog/unopened/opening.rb +88 -0
- data/app/models/proctolog/unopened.rb +203 -0
- data/app/models/proctolog/visit.rb +26 -0
- data/app/views/layouts/proctolog/application.html.erb +58 -0
- data/app/views/proctolog/acceptances/new.html.erb +34 -0
- data/app/views/proctolog/bursts/_line.html.erb +28 -0
- data/app/views/proctolog/bursts/_merged.html.erb +23 -0
- data/app/views/proctolog/bursts/_moved.html.erb +16 -0
- data/app/views/proctolog/bursts/_notices.html.erb +39 -0
- data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
- data/app/views/proctolog/bursts/_rules.html.erb +27 -0
- data/app/views/proctolog/bursts/_side.html.erb +49 -0
- data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
- data/app/views/proctolog/bursts/show.html.erb +54 -0
- data/app/views/proctolog/code_reviews/show.html.erb +33 -0
- data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
- data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
- data/app/views/proctolog/decisions/_box.html.erb +27 -0
- data/app/views/proctolog/decisions/_choices.html.erb +9 -0
- data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
- data/app/views/proctolog/decisions/_part.html.erb +61 -0
- data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
- data/app/views/proctolog/decisions/_theme.html.erb +13 -0
- data/app/views/proctolog/decisions/index.html.erb +75 -0
- data/app/views/proctolog/notes/_card.html.erb +19 -0
- data/app/views/proctolog/notes/_template.html.erb +41 -0
- data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
- data/app/views/proctolog/prompts/show.html.erb +41 -0
- data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
- data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
- data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
- data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
- data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
- data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
- data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
- data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
- data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
- data/app/views/proctolog/pull_requests/show.html.erb +145 -0
- data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
- data/app/views/proctolog/recordings/_list.html.erb +8 -0
- data/app/views/proctolog/recordings/_note.html.erb +22 -0
- data/app/views/proctolog/recordings/index.html.erb +33 -0
- data/app/views/proctolog/recordings/show.html.erb +118 -0
- data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
- data/app/views/proctolog/scenarios/_note.html.erb +18 -0
- data/app/views/proctolog/scenarios/_open.html.erb +9 -0
- data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
- data/app/views/proctolog/scenarios/_server.html.erb +20 -0
- data/app/views/proctolog/scenarios/index.html.erb +62 -0
- data/bin/proctolog +330 -0
- data/completions/_proctolog +143 -0
- data/config/application.rb +94 -0
- data/config/database.yml +16 -0
- data/config/environment.rb +6 -0
- data/config/initializers/grouse.rb +113 -0
- data/config/initializers/grouse_notes.rb +9 -0
- data/config/keys.yml +6 -0
- data/config/routes.rb +71 -0
- data/config/storage.yml +4 -0
- data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
- data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
- data/docs/manual/app.md +157 -0
- data/docs/manual/bursts.md +129 -0
- data/docs/manual/deciding.md +89 -0
- data/docs/manual/desk.png +0 -0
- data/docs/manual/films.md +20 -0
- data/docs/manual/handover.md +93 -0
- data/docs/manual/pr-format.md +75 -0
- data/docs/manual/reading.md +233 -0
- data/docs/manual/unopened.md +80 -0
- data/docs/manual/updates.md +55 -0
- data/lib/proctolog/installation.rb +125 -0
- data/lib/proctolog/version.rb +7 -0
- data/public/asking.js +50 -0
- data/public/code-review.js +40 -0
- data/public/cursor.js +292 -0
- data/public/decision-screenshots.js +97 -0
- data/public/decisions.css +949 -0
- data/public/decisions.js +1289 -0
- data/public/desk.css +582 -0
- data/public/desk.js +126 -0
- data/public/favicon.png +0 -0
- data/public/keys.js +228 -0
- data/public/mentions.js +389 -0
- data/public/messages.js +103 -0
- data/public/notes.js +731 -0
- data/public/pick.css +99 -0
- data/public/plaque.png +0 -0
- data/public/pr-number.js +11 -0
- data/public/prompt.css +46 -0
- data/public/prompt.js +16 -0
- data/public/pull_request.css +805 -0
- data/public/recording.css +379 -0
- data/public/recording.js +394 -0
- data/public/release.js +25 -0
- data/public/respond.css +260 -0
- data/public/respond.js +95 -0
- data/public/review-drafts.js +949 -0
- data/public/review-rounds.js +1447 -0
- data/public/scenarios.css +253 -0
- data/public/scenarios.js +36 -0
- data/public/schemas/proctolog-response-v1.json +101 -0
- data/public/side.js +133 -0
- data/public/standing.css +478 -0
- data/public/terminal-apl.svg +6 -0
- data/public/terminal-cuneiform.svg +3 -0
- data/public/terminal-grain-dots.svg +6 -0
- data/public/terminal-grain-weave.svg +6 -0
- data/public/terminal-grain-wedges.svg +6 -0
- data/public/terminal-shades.svg +3 -0
- data/public/terminal.css +4426 -0
- data/public/toast.js +26 -0
- data/public/try.js +124 -0
- data/public/viewer.js +808 -0
- data/public/words.js +81 -0
- data/skills/proctolog-handover/SKILL.md +77 -0
- data/skills/proctolog-pr-writing/SKILL.md +228 -0
- data/skills/proctolog-pr-writing/references/example-description.md +33 -0
- metadata +366 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# What the owner last saw of a burst's membership, in one YAML file beside
|
|
5
|
+
# its marks: its pull requests, each listed stack's members from the bottom
|
|
6
|
+
# up, its pull requests as the last handover found them, and those opened
|
|
7
|
+
# on GitHub since they waited unopened, each with the number it had.
|
|
8
|
+
#
|
|
9
|
+
# A reading alone never moves it: the owner does, by dismissing what joined
|
|
10
|
+
# or left, or by writing a handover. Nor does a reading that could not say
|
|
11
|
+
# where GitHub's stacks stand, which says nothing of who joined or left.
|
|
12
|
+
class Membership
|
|
13
|
+
attr_reader :pull_requests, :stacks, :handed_over, :opened
|
|
14
|
+
|
|
15
|
+
def self.of(burst) = new(Marks.directory_of(burst).join("membership.yml"))
|
|
16
|
+
|
|
17
|
+
# A file that is not as the desk reads it is left for the owner to mend,
|
|
18
|
+
# as a marks file is.
|
|
19
|
+
def initialize(path)
|
|
20
|
+
@path = path
|
|
21
|
+
stored = (path.exist? && YAML.safe_load_file(path)) || {}
|
|
22
|
+
@pull_requests = stored.fetch("pull_requests", []).map { number(it) }
|
|
23
|
+
@stacks = stored.fetch("stacks", {}).to_h { |stack, members| [Integer(stack), members.map { number(it) }] }
|
|
24
|
+
@handed_over = stored.fetch("handed_over", @pull_requests).map { number(it) }
|
|
25
|
+
@opened = stored.fetch("opened", {}).to_h { |now, was| [number(now), was.to_s] }
|
|
26
|
+
rescue Psych::Exception, ArgumentError, TypeError, NoMethodError => e
|
|
27
|
+
raise Failure, "The membership file #{path} is not as the desk reads it (#{e.message.lines.first&.strip}). The desk left it as it is: mend it, or move it aside to start the membership again."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def kept? = @path.exist?
|
|
31
|
+
|
|
32
|
+
# The pull requests that joined the burst, and those that left it, since
|
|
33
|
+
# the owner last looked.
|
|
34
|
+
def joined(route) = counting?(route) ? numbers(route) - pull_requests : []
|
|
35
|
+
|
|
36
|
+
def left(route) = counting?(route) ? pull_requests - numbers(route) : []
|
|
37
|
+
|
|
38
|
+
# Those that left since the last handover: their choices are not yet handed over.
|
|
39
|
+
def departed(route) = kept? ? handed_over - numbers(route) : []
|
|
40
|
+
|
|
41
|
+
# The first reading of a burst names nothing: it is where joining and
|
|
42
|
+
# leaving count from.
|
|
43
|
+
def start(route) = (keep(route, members: numbers(route), handed_over: numbers(route)) if !kept? && route.stacks_known?)
|
|
44
|
+
|
|
45
|
+
# The owner has seen that this pull request joined, that it left, or
|
|
46
|
+
# that it was opened.
|
|
47
|
+
def acknowledge(number, route)
|
|
48
|
+
return seen_opened(number) if opened.key?(number)
|
|
49
|
+
return unless route.stacks_known?
|
|
50
|
+
|
|
51
|
+
keep(route, members: numbers(route).include?(number) ? pull_requests | [number] : pull_requests - [number], handed_over:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# It waited unopened as `was`, and is open as `number` now: said until
|
|
55
|
+
# the owner has seen it.
|
|
56
|
+
def note_opened(number, was:)
|
|
57
|
+
@opened[number] = was
|
|
58
|
+
write
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A handover takes in what the burst holds now.
|
|
62
|
+
def hand_over(route) = (keep(route, members: numbers(route), handed_over: numbers(route)) if route.stacks_known?)
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def number(written) = PullRequest.number(written) || raise(ArgumentError, "#{written.inspect} is no pull request's number")
|
|
67
|
+
|
|
68
|
+
def seen_opened(number)
|
|
69
|
+
@opened.delete(number)
|
|
70
|
+
write
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def counting?(route) = kept? && route.stacks_known?
|
|
74
|
+
|
|
75
|
+
def numbers(route) = route.map(&:number)
|
|
76
|
+
|
|
77
|
+
def keep(route, members:, handed_over:)
|
|
78
|
+
@pull_requests, @handed_over = members, handed_over
|
|
79
|
+
@stacks = route.parts.select(&:stack?).to_h { [it.id, route.stops_of(it).map(&:number)] }
|
|
80
|
+
write
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Written beside the file and moved over it, so a reader never meets half a file.
|
|
84
|
+
def write
|
|
85
|
+
@path.dirname.mkpath
|
|
86
|
+
draft = @path.sub_ext(".yml.part")
|
|
87
|
+
draft.write({"pull_requests" => pull_requests, "stacks" => stacks, "handed_over" => handed_over, "opened" => opened.presence}.compact.to_yaml(line_width: -1))
|
|
88
|
+
draft.rename(@path)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The pull requests a # in the owner's words can name: every one of the
|
|
5
|
+
# repository, open, merged or closed. GitHub is read in full once and the
|
|
6
|
+
# answer kept for the next start of the desk; after that it is asked only
|
|
7
|
+
# for what changed since the last change kept, and no more than once a
|
|
8
|
+
# minute.
|
|
9
|
+
class Mentions
|
|
10
|
+
# A pull request, as a # names it.
|
|
11
|
+
Mention = Data.define(:number, :title, :state, :url) do
|
|
12
|
+
def self.from(node) = new(number: node.fetch("number"), title: node.fetch("title"), state: node.fetch("state").downcase, url: node.fetch("url"))
|
|
13
|
+
|
|
14
|
+
def digits = number.to_s
|
|
15
|
+
|
|
16
|
+
def open? = state == "open"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A # in the owner's words: one that begins a word, and its digits end one.
|
|
20
|
+
WRITTEN = /(?<![\w&#])#\d+(?!\w)/
|
|
21
|
+
|
|
22
|
+
# How many a # offers at once.
|
|
23
|
+
OFFERED = 8
|
|
24
|
+
|
|
25
|
+
# How long what GitHub said is taken for what it says now.
|
|
26
|
+
GOOD_FOR = 1.minute
|
|
27
|
+
|
|
28
|
+
# A desk serves several pages at once: one asks GitHub, the others wait
|
|
29
|
+
# for its answer instead of asking again.
|
|
30
|
+
ASKING = Mutex.new
|
|
31
|
+
|
|
32
|
+
# Those of the checkout's own repository; none for a checkout that has
|
|
33
|
+
# no repository on GitHub.
|
|
34
|
+
def self.here = Project.current.github_repository&.then { new(Github.new(it)) }
|
|
35
|
+
|
|
36
|
+
def initialize(github) = @github = github
|
|
37
|
+
|
|
38
|
+
# A repository the desk has never read is read behind the owner's back
|
|
39
|
+
# (ReadMentionsAheadJob).
|
|
40
|
+
def read_ahead
|
|
41
|
+
ReadMentionsAheadJob.perform_later(@github.repository) unless Rails.cache.exist?(cached) || kept&.file?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Those a # with these digits may mean: the pull request with that very
|
|
45
|
+
# number first, then those whose number begins so, the newest first.
|
|
46
|
+
def matching(digits)
|
|
47
|
+
begun = every.select { it.digits.start_with?(digits) }
|
|
48
|
+
exact, longer = begun.partition { it.digits == digits }
|
|
49
|
+
[*exact, *longer.max_by(OFFERED, &:number)].first(OFFERED)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The pull request a number names, of those already kept: a page that
|
|
53
|
+
# shows the owner's words asks GitHub nothing.
|
|
54
|
+
def [](number) = in_hand&.dig(:nodes, number)&.then { Mention.from(it) }
|
|
55
|
+
|
|
56
|
+
# What GitHub holds, asked again no more than once a minute. What is in
|
|
57
|
+
# hand is read again once the way is clear: the page that asked meanwhile
|
|
58
|
+
# may have brought it up to date.
|
|
59
|
+
def up_to_date
|
|
60
|
+
ASKING.synchronize do
|
|
61
|
+
@in_hand = nil
|
|
62
|
+
fresh? ? in_hand : brought_up_to_date
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def every = up_to_date.fetch(:nodes).values.map { Mention.from(it) }
|
|
69
|
+
|
|
70
|
+
def in_hand = @in_hand ||= Rails.cache.read(cached) || from_disk
|
|
71
|
+
|
|
72
|
+
def fresh? = in_hand&.fetch(:checked)&.after?(GOOD_FOR.ago)
|
|
73
|
+
|
|
74
|
+
# The first reading asks for every pull request; the next only for those
|
|
75
|
+
# that changed at the last change it kept, or since. When GitHub cannot
|
|
76
|
+
# be asked, what is kept is good enough.
|
|
77
|
+
def brought_up_to_date
|
|
78
|
+
nodes = in_hand&.fetch(:nodes) || {}
|
|
79
|
+
since = nodes.values.pluck("updatedAt").max&.then { Time.iso8601(it) }
|
|
80
|
+
changed = @github.pull_requests_changed(since:).index_by { it.fetch("number") }
|
|
81
|
+
keep(checked: Time.current, nodes: nodes.merge(changed))
|
|
82
|
+
rescue Github::Unreachable
|
|
83
|
+
in_hand or raise
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def keep(reading)
|
|
87
|
+
Rails.cache.write(cached, reading)
|
|
88
|
+
to_disk(reading)
|
|
89
|
+
@in_hand = reading
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cached = [:mentions, @github.repository]
|
|
93
|
+
|
|
94
|
+
# Where the reading waits for the next start of the desk. A desk that is
|
|
95
|
+
# only being tried out keeps none.
|
|
96
|
+
def kept = (Project.current.cache.join("mentions", "#{@github.repository.tr("/", "-")}.json") if Desk.config.x.keeps_readings)
|
|
97
|
+
|
|
98
|
+
# A file that cannot be read is as good as none: GitHub is asked again.
|
|
99
|
+
def from_disk
|
|
100
|
+
return unless kept&.file?
|
|
101
|
+
|
|
102
|
+
JSON.parse(kept.read).then { |read| {checked: Time.iso8601(read.fetch("checked")), nodes: read.fetch("nodes").index_by { it.fetch("number") }} }
|
|
103
|
+
rescue JSON::ParserError, KeyError, ArgumentError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Written beside the file and moved over it, so a start never meets half a reading.
|
|
108
|
+
def to_disk(reading)
|
|
109
|
+
return unless kept
|
|
110
|
+
|
|
111
|
+
kept.dirname.mkpath
|
|
112
|
+
draft = kept.sub_ext(".json.part")
|
|
113
|
+
draft.write(JSON.generate(checked: reading.fetch(:checked).iso8601(6), nodes: reading.fetch(:nodes).values))
|
|
114
|
+
draft.rename(kept)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Something the owner wrote about a pull request, pinned to the place it is
|
|
5
|
+
# about: words of the description, a region of a picture, a moment of a
|
|
6
|
+
# recording, a scenario. A note with no place is about the whole pull request.
|
|
7
|
+
class Note < Data.define(:id, :kind, :state, :text, :written_at, :place, :reply)
|
|
8
|
+
KINDS = %w[change question remark].freeze
|
|
9
|
+
STATES = %w[open addressed confirmed withdrawn].freeze
|
|
10
|
+
|
|
11
|
+
# How far around a quote its surroundings are compared, in characters.
|
|
12
|
+
NEAR = 64
|
|
13
|
+
|
|
14
|
+
# How many characters of what stood around a quote must still stand there
|
|
15
|
+
# for a place to be its place: a word's worth, or all of it when less was kept.
|
|
16
|
+
CONFIRMING = 8
|
|
17
|
+
|
|
18
|
+
# Words this long are their own evidence and are followed wherever they
|
|
19
|
+
# moved: about seven words do not turn up by chance in a place they were not
|
|
20
|
+
# written. Shorter ones ("Expected: 0 failures.") recur through a description.
|
|
21
|
+
DISTINCTIVE = 40
|
|
22
|
+
|
|
23
|
+
# Words of a description, kept with a little of what stands before and
|
|
24
|
+
# after them, so they are found again wherever an edit moves them.
|
|
25
|
+
Quote = Data.define(:exact, :before, :after, :section) do
|
|
26
|
+
# A form sends a line break as two characters and a description has one,
|
|
27
|
+
# so the words are kept as the description has them.
|
|
28
|
+
def initialize(exact:, before:, after:, section:) = super(exact: exact.delete("\r"), before: before&.delete("\r"), after: after&.delete("\r"), section:)
|
|
29
|
+
|
|
30
|
+
# Where the words stand in a text now, as a range of it; nil when they
|
|
31
|
+
# are no longer there. Of several places, the one whose surroundings fit,
|
|
32
|
+
# and that one only while enough of its surroundings do or the words are
|
|
33
|
+
# distinctive: a phrase standing somewhere it was not written is not the note's.
|
|
34
|
+
def locate(text)
|
|
35
|
+
spot = spots(text).max_by { fit(text, it) } or return
|
|
36
|
+
spot if distinctive? || fit(text, spot) >= [CONFIRMING, before.to_s.length + after.to_s.length].min
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# How many characters of the surroundings stand round the words where
|
|
40
|
+
# they stand best in a text; nil when the words are not in it.
|
|
41
|
+
def fit_in(text) = spots(text).map { fit(text, it) }.max
|
|
42
|
+
|
|
43
|
+
def distinctive? = exact.squish.length >= DISTINCTIVE
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def spots(text) = text.enum_for(:scan, pattern).map { Regexp.last_match.begin(0)...Regexp.last_match.end(0) }
|
|
48
|
+
|
|
49
|
+
# The words, with any run of white space standing for any other.
|
|
50
|
+
def pattern = Regexp.new(exact.split.map { Regexp.escape(it) }.join('\s+'))
|
|
51
|
+
|
|
52
|
+
def fit(text, spot)
|
|
53
|
+
shared(text[[spot.begin - NEAR, 0].max...spot.begin].reverse, before.to_s.reverse) + shared(text[spot.end, NEAR], after.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def shared(one, other) = one.each_char.zip(other.each_char).take_while { |a, b| a == b }.size
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A rectangle of a picture: `box` is x, y, width and height as fractions of
|
|
60
|
+
# the picture, so it holds at any size. `picture` is the alt text, `asset`
|
|
61
|
+
# the uploaded file it was drawn on.
|
|
62
|
+
Region = Data.define(:picture, :asset, :box)
|
|
63
|
+
|
|
64
|
+
# A moment of a recording: seconds from its start, the step it falls in,
|
|
65
|
+
# that frame saved as a picture, and a rectangle on it when one was drawn.
|
|
66
|
+
Moment = Data.define(:recording, :time, :step, :frame, :box)
|
|
67
|
+
|
|
68
|
+
Scenario = Data.define(:name)
|
|
69
|
+
|
|
70
|
+
PLACES = {"quote" => Quote, "picture" => Region, "recording" => Moment, "scenario" => Scenario}.freeze
|
|
71
|
+
|
|
72
|
+
STATES.each { |state| define_method(:"#{state}?") { self.state == state } }
|
|
73
|
+
KINDS.each { |kind| define_method(:"#{kind}?") { self.kind == kind } }
|
|
74
|
+
|
|
75
|
+
def self.restored(stored)
|
|
76
|
+
name, place = PLACES.find { |key, _| stored.key?(key) }
|
|
77
|
+
new(**%w[id kind state text reply].index_with { stored[it] }.symbolize_keys,
|
|
78
|
+
written_at: Time.iso8601(stored.fetch("written_at")),
|
|
79
|
+
place: place&.new(**place.members.index_with { stored[name][it.to_s] }))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Still needs someone: the agent while it is open, the owner once addressed.
|
|
83
|
+
def live? = open? || addressed?
|
|
84
|
+
|
|
85
|
+
# The decision of a description that this note answers otherwise: a change
|
|
86
|
+
# asked for on words of a box. A question or a remark leaves the box open.
|
|
87
|
+
def answers_in(description) = (description.decision_quoted_by(place) if change? && (place in Quote[section: Description::DECISIONS]))
|
|
88
|
+
|
|
89
|
+
def stored
|
|
90
|
+
{"id" => id, "kind" => kind, "state" => state, "text" => text, "written_at" => written_at.iso8601,
|
|
91
|
+
PLACES.key(place.class) => place&.to_h&.stringify_keys&.compact, "reply" => reply}.compact
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The environment a program that is not the desk's runs in when the desk
|
|
5
|
+
# starts it: the reviewed app's commands, Diffle. Whatever put the desk on
|
|
6
|
+
# its own Ruby and gems stays with the desk, so that the app finds its own:
|
|
7
|
+
#
|
|
8
|
+
# - from a checkout, Bundler's variables;
|
|
9
|
+
# - as an rv tool, the gem home rv made for it, the variables that name its
|
|
10
|
+
# Ruby, and the folders rv put first on the PATH;
|
|
11
|
+
# - and a version manager's Ruby of the desk's (asdf, for one), which leaves
|
|
12
|
+
# the PATH while another ruby is still on it.
|
|
13
|
+
module Outside
|
|
14
|
+
# rv sets these for the tool it runs; the shell it was run from had none.
|
|
15
|
+
RV = %w[GEM_HOME GEM_PATH RUBY_ROOT RUBY_ENGINE RUBY_VERSION].freeze
|
|
16
|
+
|
|
17
|
+
# Yields what to hand Process.spawn, with Bundler's variables gone from
|
|
18
|
+
# ENV while the block runs.
|
|
19
|
+
def self.run(&) = defined?(Bundler) ? Bundler.with_unbundled_env { yield environment } : yield(environment)
|
|
20
|
+
|
|
21
|
+
def self.environment(env = ENV, installation: Installation.here, desks: RbConfig::CONFIG["bindir"])
|
|
22
|
+
path = env.fetch("PATH", "")
|
|
23
|
+
return {"BUNDLE_GEMFILE" => nil, "PATH" => path_for_the_app(path, desks:)} unless installation.rv_tool?
|
|
24
|
+
|
|
25
|
+
RV.index_with(nil).merge("BUNDLE_GEMFILE" => nil, "PATH" => path_for_the_app(before_rv(path, env), desks:))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The PATH as the shell had it: rv puts its Ruby's folders, and its gems',
|
|
29
|
+
# in front.
|
|
30
|
+
def self.before_rv(path, env)
|
|
31
|
+
own = [env["RUBY_ROOT"], env["GEM_HOME"], *env.fetch("GEM_PATH", "").split(File::PATH_SEPARATOR)].compact_blank
|
|
32
|
+
path.split(File::PATH_SEPARATOR).drop_while { |entry| own.any? { entry.start_with?("#{it}/") } }.join(File::PATH_SEPARATOR)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The app's bin/ scripts would run under the desk's own Ruby, put first on
|
|
36
|
+
# the PATH by a version manager, rather than the Ruby the app names. It is
|
|
37
|
+
# left out whenever another ruby is still on the PATH.
|
|
38
|
+
def self.path_for_the_app(path, desks: RbConfig::CONFIG["bindir"])
|
|
39
|
+
entries = path.split(File::PATH_SEPARATOR)
|
|
40
|
+
rest = entries - [desks]
|
|
41
|
+
another = rest.any? { File.executable?(File.join(it, "ruby")) }
|
|
42
|
+
(another ? rest : entries).join(File::PATH_SEPARATOR)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
class Overlay
|
|
5
|
+
# What the desk tells Grouse of a scenario when it opens its overlay: the
|
|
6
|
+
# strip's label, who narrates, what to expect and the steps, and the way
|
|
7
|
+
# back to the desk. Grouse checks every field as the session opens, so the
|
|
8
|
+
# brief keeps within its limits; the rest of `metadata` is the desk's own,
|
|
9
|
+
# and no page shows it.
|
|
10
|
+
class Brief < Data.define(:label, :reviewer_label, :external_ref, :metadata)
|
|
11
|
+
SOURCE = "proctolog"
|
|
12
|
+
LABEL = 200
|
|
13
|
+
EXPECT = 1_000
|
|
14
|
+
STEP = 500
|
|
15
|
+
STEPS = 50
|
|
16
|
+
METADATA = 16_000
|
|
17
|
+
|
|
18
|
+
# "grouse-rails #7 · Send a note", or "sartoria · Step 2 · Sign in" for a
|
|
19
|
+
# step of the sitting. `steps` are the scenario's, one line each.
|
|
20
|
+
def self.for(scenario, burst:, steps:, head:, desk_url:)
|
|
21
|
+
label = [[Project.current.name, ("##{scenario.pr}" if scenario.pr)].compact.join(" "), ("Step #{scenario.sitting}" if scenario.sitting), scenario.name]
|
|
22
|
+
new(label: line(label.compact.join(" · "), LABEL), reviewer_label: "#{Owner.current.first_name} (PRoctolog)",
|
|
23
|
+
external_ref: line("#{SOURCE}:#{burst.slug}/#{scenario.to_param}#{"@#{head}" if head}", LABEL),
|
|
24
|
+
metadata: {expect: scenario.expect&.truncate(EXPECT), steps: steps.map { line(plain(it), STEP) }.compact_blank.first(STEPS), desk_url:,
|
|
25
|
+
burst: burst.slug, pr: scenario.pr, scenario: scenario.to_param, recipe: scenario.recipe, head:}.compact).fitted
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A step as GitHub sets it, read as words: no code ticks, bold or links.
|
|
29
|
+
def self.plain(text) = text.gsub(/\[([^\]]+)\]\([^)\s]+\)/, '\1').delete("`").gsub("**", "")
|
|
30
|
+
|
|
31
|
+
def self.line(text, most) = text.squish.truncate(most)
|
|
32
|
+
|
|
33
|
+
# The member's email never reaches the strip: wherever the label, what to
|
|
34
|
+
# expect or a step names it, it says "the member".
|
|
35
|
+
def without(email)
|
|
36
|
+
return self if email.blank?
|
|
37
|
+
|
|
38
|
+
unnamed = ->(text) { text&.gsub(email, "the member") }
|
|
39
|
+
with(label: unnamed.call(label), metadata: metadata.merge({expect: unnamed.call(metadata[:expect]), steps: metadata[:steps]&.map(&unnamed)}.compact))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Within Grouse's limit for all of `metadata`, losing steps from the end.
|
|
43
|
+
def fitted
|
|
44
|
+
return self if metadata.to_json.bytesize <= METADATA || metadata[:steps].blank?
|
|
45
|
+
|
|
46
|
+
with(metadata: metadata.merge(steps: metadata[:steps][...-1])).fitted
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def as_json(*) = {source: SOURCE, label:, reviewer_label:, external_ref:, metadata:}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
class Overlay
|
|
5
|
+
# A note the owner wrote in the overlay, as its app's `notes` printed it
|
|
6
|
+
# (Grouse's export at its standard detail), and as the desk pins it on the
|
|
7
|
+
# scenario: their words, then where Grouse keeps the note.
|
|
8
|
+
class Written < Data.define(:id, :intent, :body, :transcript, :transcription, :url, :route, :templates, :created_at)
|
|
9
|
+
# Grouse's intents as the desk's kinds of note; approving, or saying
|
|
10
|
+
# nothing of what is wanted, is a remark.
|
|
11
|
+
KINDS = {"fix" => "change", "change" => "change", "question" => "question"}.freeze
|
|
12
|
+
TEMPLATES = 3
|
|
13
|
+
|
|
14
|
+
def self.from(note, app: nil)
|
|
15
|
+
new(id: note.fetch("public_id"), intent: note["intent"], body: note["body"], transcript: note["transcript"],
|
|
16
|
+
transcription: note["transcription_state"], url: (app && note["url"]&.start_with?("/")) ? URI.join(app, note["url"]).to_s : note["url"], route: note["route"],
|
|
17
|
+
templates: Array(note["templates"]), created_at: note["created_at"].to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A voice note waits for its words while they are being written out;
|
|
21
|
+
# once done, failed or not to be, it is pinned as it stands.
|
|
22
|
+
def ready? = transcription != "pending"
|
|
23
|
+
|
|
24
|
+
def kind = KINDS.fetch(intent.to_s, "remark")
|
|
25
|
+
|
|
26
|
+
def text
|
|
27
|
+
words = [body, transcript].map { it.to_s.strip }.compact_blank
|
|
28
|
+
words = ["A voice note with no words (#{transcription.to_s.tr("_", " ")})."] if words.empty?
|
|
29
|
+
[*words, [url, route, *templates.first(TEMPLATES)].compact_blank.join(" · ")].join("\n\n")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Grouse's overlay in the app under review, labelled with a scenario: a
|
|
5
|
+
# review session of the app's, which one press of the scenario's button
|
|
6
|
+
# opened through app.yml's `overlay` (docs/manual/app.md). It is kept in the
|
|
7
|
+
# marks beside its scenario, with the ids of the Grouse notes pinned from it,
|
|
8
|
+
# so that a pull pins each note once. A second press opens a second one.
|
|
9
|
+
class Overlay < Data.define(:session, :opened, :head, :pinned)
|
|
10
|
+
# The session a press opened, and the cookie that shows the overlay to the
|
|
11
|
+
# browser it opens, a whole Set-Cookie line as the app printed it.
|
|
12
|
+
Opened = Data.define(:session, :cookie) do
|
|
13
|
+
def missing = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Why a press opened the app without the overlay, in words that follow
|
|
17
|
+
# "Grouse's overlay did not open: ".
|
|
18
|
+
Refused = Data.define(:reason) do
|
|
19
|
+
def cookie = nil
|
|
20
|
+
|
|
21
|
+
def session = nil
|
|
22
|
+
|
|
23
|
+
def missing = "Grouse's overlay did not open: #{reason}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Opens a session for the brief. A refusal is said, and never stops the
|
|
27
|
+
# entering: the member is signed in all the same.
|
|
28
|
+
def self.open(server, brief)
|
|
29
|
+
case server.open_overlay(brief).symbolize_keys
|
|
30
|
+
in {public_id: String => session, cookie: String => cookie} then Opened.new(session:, cookie:)
|
|
31
|
+
in {problem: problem, **said} then Refused.new(reason: refusal(problem, said[:errors]))
|
|
32
|
+
else Refused.new(reason: "the app printed no session.")
|
|
33
|
+
end
|
|
34
|
+
rescue Server::Error => e
|
|
35
|
+
Refused.new(reason: e.message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# "the app said invalid (metadata.steps: too_long)." Grouse names the
|
|
39
|
+
# fields of a refusal, never their values.
|
|
40
|
+
def self.refusal(problem, errors)
|
|
41
|
+
fields = errors.to_h.map { |field, codes| "#{field}: #{Array(codes).join(", ")}" }
|
|
42
|
+
"the app said #{problem}#{" (#{fields.join("; ")})" if fields.any?}."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# What a pull brought: notes pinned, and notes still waiting on their words.
|
|
46
|
+
Pulled = Data.define(:pinned, :waiting) do
|
|
47
|
+
def sentence
|
|
48
|
+
found = pinned.zero? ? "No new notes in Grouse's overlay." : "Pinned #{pinned} #{"note".pluralize(pinned)} from Grouse's overlay."
|
|
49
|
+
later = " #{waiting} #{(waiting == 1) ? "waits" : "wait"} for #{(waiting == 1) ? "its" : "their"} words: pull again in a moment." if waiting.positive?
|
|
50
|
+
"#{found}#{later}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Pins on a scenario every note written in its overlays that is not pinned
|
|
55
|
+
# yet. Each overlay's `notes` boots the app, so it is run when asked for.
|
|
56
|
+
def self.pull(server, marks, scenario)
|
|
57
|
+
counts = marks.overlays_of(scenario).map do |overlay|
|
|
58
|
+
answer = server.overlay_notes(overlay.session)
|
|
59
|
+
fresh = overlay.unpinned(answer, app: server.address).each { marks.pin_written(scenario, overlay:, written: it) }
|
|
60
|
+
[fresh.size, overlay.waiting(answer)]
|
|
61
|
+
end
|
|
62
|
+
pinned, waiting = counts.transpose.map(&:sum)
|
|
63
|
+
Pulled.new(pinned: pinned.to_i, waiting: waiting.to_i)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.restored(stored) = new(session: stored.fetch("session"), opened: stored["opened"], head: stored["head"], pinned: stored.fetch("pinned", []))
|
|
67
|
+
|
|
68
|
+
def self.opening(session, head:) = new(session:, opened: Time.current.iso8601, head:, pinned: [])
|
|
69
|
+
|
|
70
|
+
# The notes of what its app's `notes` printed that are ready to pin and are
|
|
71
|
+
# not pinned yet, the oldest first.
|
|
72
|
+
# A link Grouse printed as a path, as a runner has no host to give it, is
|
|
73
|
+
# made whole on `app`, the address the sitting serves the app on.
|
|
74
|
+
def unpinned(answer, app: nil) = written(answer, app:).select(&:ready?).reject { pinned.include?(it.id) }
|
|
75
|
+
|
|
76
|
+
# The notes still waiting on their words.
|
|
77
|
+
def waiting(answer) = written(answer).count { !it.ready? }
|
|
78
|
+
|
|
79
|
+
def pinning(id) = with(pinned: pinned | [id])
|
|
80
|
+
|
|
81
|
+
def stored = {"session" => session, "opened" => opened, "head" => head, "pinned" => pinned.presence}.compact
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def written(answer, app: nil) = answer.fetch("notes", []).map { Written.from(it, app:) }.sort_by(&:created_at)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Proctolog
|
|
6
|
+
# Whom the desk serves: one person, on this Mac, named as Git names them
|
|
7
|
+
# (`user.name`), else by their login. Grouse's notes on the desk are theirs,
|
|
8
|
+
# and so are those written in the overlay of a scenario they opened.
|
|
9
|
+
class Owner < Data.define(:name)
|
|
10
|
+
def self.current = @current ||= new(name: git_name.presence || ENV.fetch("USER", "the owner"))
|
|
11
|
+
|
|
12
|
+
def self.git_name = Open3.capture2("git", "config", "--get", "user.name").first.strip
|
|
13
|
+
|
|
14
|
+
def first_name = name.split.first
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The repository's open pull requests as a reviewer picks a burst from them:
|
|
5
|
+
# GitHub's stacks whole, from the bottom up, then the rest, the reviewer's
|
|
6
|
+
# own first, then those written for PRoctolog, then the newest.
|
|
7
|
+
class Pick
|
|
8
|
+
Stack = Data.define(:number, :pull_requests) do
|
|
9
|
+
def top = pull_requests.last
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :viewer, :unopened
|
|
13
|
+
|
|
14
|
+
# `unopened` are the pull requests nobody has opened yet, in the order of
|
|
15
|
+
# their numbers.
|
|
16
|
+
def initialize(pull_requests, viewer:, unopened: [])
|
|
17
|
+
@pull_requests, @viewer, @unopened = pull_requests, viewer, unopened
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stacks
|
|
21
|
+
@pull_requests.select(&:stack).group_by(&:stack).sort.map do |number, layers|
|
|
22
|
+
Stack.new(number:, pull_requests: layers.sort_by(&:position))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def loose = @pull_requests.reject(&:stack).sort_by { [yours?(it) ? 0 : 1, written?(it) ? 0 : 1, -it.number] }
|
|
27
|
+
|
|
28
|
+
def yours?(pull_request) = viewer.present? && pull_request.author == viewer
|
|
29
|
+
|
|
30
|
+
# Whether its description asks the reviewer anything the way PRoctolog reads it.
|
|
31
|
+
def written?(pull_request) = pull_request.description.decisions.any?
|
|
32
|
+
|
|
33
|
+
def empty? = @pull_requests.empty? && unopened.empty?
|
|
34
|
+
|
|
35
|
+
# Whether a pull request nobody has opened yet sits on another pull
|
|
36
|
+
# request's branch, so that it tops a chain.
|
|
37
|
+
def chained?(pull_request) = [*@pull_requests, *unopened].any? { it.head == pull_request.base }
|
|
38
|
+
|
|
39
|
+
# A name for a burst of these pull requests, from the branch of the first:
|
|
40
|
+
# what `burst new` would be told, and free among the bursts there are.
|
|
41
|
+
def self.slug_for(pull_requests)
|
|
42
|
+
base = pull_requests.first&.head.to_s.parameterize.first(40).delete_suffix("-").presence || "burst"
|
|
43
|
+
taken = Burst.files.map { it.basename(".yml").to_s }
|
|
44
|
+
(1..).lazy.map { (it == 1) ? base : "#{base}-#{it}" }.find { !taken.include?(it) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|