basecamp-sdk 0.18.0 → 0.19.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/basecamp/api_error.rb +5 -3
  4. data/lib/basecamp/bucket_mismatch_error.rb +41 -0
  5. data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
  6. data/lib/basecamp/campfire_index.rb +398 -0
  7. data/lib/basecamp/client.rb +47 -4
  8. data/lib/basecamp/generated/metadata.json +728 -1
  9. data/lib/basecamp/generated/services/account_service.rb +2 -2
  10. data/lib/basecamp/generated/services/attachments_service.rb +1 -1
  11. data/lib/basecamp/generated/services/automation_service.rb +1 -1
  12. data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
  13. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  14. data/lib/basecamp/generated/services/calendars_service.rb +2 -2
  15. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  16. data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
  17. data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
  18. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  19. data/lib/basecamp/generated/services/cards_service.rb +3 -3
  20. data/lib/basecamp/generated/services/checkins_service.rb +9 -9
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  23. data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
  25. data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
  26. data/lib/basecamp/generated/services/comments_service.rb +3 -3
  27. data/lib/basecamp/generated/services/documents_service.rb +3 -3
  28. data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
  29. data/lib/basecamp/generated/services/everything_service.rb +2 -2
  30. data/lib/basecamp/generated/services/folders_service.rb +4 -4
  31. data/lib/basecamp/generated/services/forwards_service.rb +3 -3
  32. data/lib/basecamp/generated/services/gauges_service.rb +10 -6
  33. data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
  34. data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
  35. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  36. data/lib/basecamp/generated/services/message_types_service.rb +3 -3
  37. data/lib/basecamp/generated/services/messages_service.rb +3 -3
  38. data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
  39. data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
  40. data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
  41. data/lib/basecamp/generated/services/people_service.rb +11 -11
  42. data/lib/basecamp/generated/services/projects_service.rb +4 -4
  43. data/lib/basecamp/generated/services/recordings_service.rb +1 -1
  44. data/lib/basecamp/generated/services/reports_service.rb +3 -3
  45. data/lib/basecamp/generated/services/schedules_service.rb +6 -6
  46. data/lib/basecamp/generated/services/search_service.rb +1 -1
  47. data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
  48. data/lib/basecamp/generated/services/templates_service.rb +8 -8
  49. data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
  50. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  51. data/lib/basecamp/generated/services/todolists_service.rb +3 -3
  52. data/lib/basecamp/generated/services/todos_service.rb +4 -4
  53. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  54. data/lib/basecamp/generated/services/tools_service.rb +3 -3
  55. data/lib/basecamp/generated/services/uploads_service.rb +4 -4
  56. data/lib/basecamp/generated/services/vaults_service.rb +3 -3
  57. data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
  58. data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
  59. data/lib/basecamp/generated/types.rb +108 -6
  60. data/lib/basecamp/http.rb +383 -60
  61. data/lib/basecamp/ids.rb +277 -0
  62. data/lib/basecamp/mentions.rb +1119 -0
  63. data/lib/basecamp/person_id_sites.rb +184 -0
  64. data/lib/basecamp/recording_routing_error.rb +50 -0
  65. data/lib/basecamp/recording_summary_error.rb +33 -0
  66. data/lib/basecamp/services/comments_extensions.rb +121 -0
  67. data/lib/basecamp/services/merge_safe.rb +37 -13
  68. data/lib/basecamp/services/recordings_extensions.rb +1305 -0
  69. data/lib/basecamp/services/todolists_extensions.rb +4 -3
  70. data/lib/basecamp/text.rb +94 -0
  71. data/lib/basecamp/unresolved_recording_error.rb +68 -0
  72. data/lib/basecamp/version.rb +1 -1
  73. data/lib/basecamp/webhooks/event.rb +4 -1
  74. data/lib/basecamp.rb +22 -4
  75. data/scripts/generate-metadata.rb +75 -2
  76. data/scripts/generate-services.rb +35 -8
  77. data/scripts/generate-types.rb +19 -0
  78. metadata +15 -2
@@ -0,0 +1,1305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # A compact projection of one recording, resolved from the pointer an
6
+ # account event feed row or a webhook carries — bucket id, recording id, and
7
+ # the event type or recording type — through the typed read that type names.
8
+ # Prepended onto the generated {RecordingsService} (see the +on_load+ hook in
9
+ # +basecamp.rb+).
10
+ #
11
+ # It exists for consumers that must decide something about a recording
12
+ # without paying for its full payload: an agent connector's admission step,
13
+ # an MCP tool answering "what is this?".
14
+ #
15
+ # The SDK has no untyped recording read (BC3 has no such route), so the type
16
+ # is the routing key: +comment.created+ reads a comment, +card.created+ reads
17
+ # a card, and so on — one typed read per type. Chat lines are the exception,
18
+ # because their read needs the Campfire id and the pointer does not carry it;
19
+ # {#summarize} discovers the Campfire first (see the Campfire discovery
20
+ # section below).
21
+ #
22
+ # This is hand-written composition over the generated services (AGENTS.md;
23
+ # SPEC.md section 18, Appendix F). It makes no wire request of its own, and
24
+ # it mints no operation identity: hooks see the constituent reads under their
25
+ # own names (SPEC.md section 18 rule 3), so a +summarize+ of a comment shows
26
+ # up as +comments.get+ and nothing else.
27
+ module RecordingsExtensions
28
+ # Maps the subject of an account event feed type — everything before its
29
+ # final "." — to a read. This is the feed's catalog (bc3
30
+ # +Event::EventType+) minus +boost+, which names no recording type and is
31
+ # refused explicitly rather than left to fall through as unknown.
32
+ EVENT_SUBJECTS = {
33
+ "comment" => :comment,
34
+ "message" => :message,
35
+ "todo" => :todo,
36
+ "card" => :card,
37
+ "chat.line" => :chat_line
38
+ }.freeze
39
+
40
+ # Chat line subtypes (+Chat::Lines::Text+, +::RichText+, +::Code+,
41
+ # +::Upload+, +::Integration+) all read through the same route, so they are
42
+ # matched by prefix rather than listed.
43
+ CHAT_LINE_TYPE_PREFIX = "Chat::Lines::"
44
+
45
+ # What a malformed field was being read FOR. Two sites, two hints: a hint
46
+ # that sends the reader to the wrong half of the composite is worse than a
47
+ # shorter one.
48
+ RECORDING_HINT =
49
+ "The recording summary reads this field to decide what the recording is and which " \
50
+ "project it belongs to, so a value of the wrong type cannot be used."
51
+
52
+ DISCOVERY_HINT =
53
+ "The recording summary reads this field to decide which Campfires a chat line could " \
54
+ "be in, so a value of the wrong type cannot be used."
55
+
56
+ # The fields of a nested member the reference types, by the model it
57
+ # decodes into. See #read_member_fields for why these are transcribed from
58
+ # +generated+ rather than from the hand-written wrapper types, and for
59
+ # what deliberately stays out of them.
60
+ #
61
+ # +generated.Person+: every one of these is a +*string+ but +name+, which
62
+ # is a plain +string+ — null is a no-op for both, so they take one rule.
63
+ # +created_at+ and +updated_at+ are +*time.Time+ there; a non-string fails
64
+ # on both sides and an unparseable string fails only there.
65
+ # +system_label+ is deliberately ABSENT: the generated model has no such
66
+ # field, so the reference ignores it as an unknown key.
67
+ PERSON_STRING_FIELDS = %w[
68
+ attachable_sgid avatar_url bio created_at email_address location
69
+ name personable_type tagline time_zone title updated_at
70
+ ].freeze
71
+
72
+ # +generated.Person+'s +*bool+ fields.
73
+ PERSON_BOOL_FIELDS = %w[
74
+ admin can_access_hill_charts can_access_timesheet can_manage_people
75
+ can_manage_projects can_ping client employee owner
76
+ ].freeze
77
+
78
+ # +generated.RecordingParent+ / +generated.TodoParent+. Their +bucket+ is
79
+ # the difference between the two and is handled separately.
80
+ PARENT_STRING_FIELDS = %w[title type url app_url].freeze
81
+
82
+ # +generated.TodoBucket+ / +generated.RecordingBucket+.
83
+ BUCKET_STRING_FIELDS = %w[name type].freeze
84
+
85
+ # Maps BC3's recording type strings to a read. It is the routing contract,
86
+ # and it is a DELIBERATE set, not an exhaustive one: the recording types
87
+ # the account event feed's trigger matrix names (comment, message, to-do,
88
+ # card, chat line), plus the content and tool recordings a consumer
89
+ # reasoning about those is likely to hold an id for.
90
+ #
91
+ # A type outside this set is +unknown_recording_type+ by design, whether or
92
+ # not the SDK has an id-only read for it — timesheet entries and gauge
93
+ # needles do, and are not routed; +Client::Reply+ and +Forward::Reply+
94
+ # cannot be, since their reads need a parent id the pointer does not carry.
95
+ # Widening the set is a product decision, not a gap: add the type here, its
96
+ # projection in +read_summary+, a routing row in the native test, and a
97
+ # case in +conformance/tests/recording_summary.json+, the fixture this
98
+ # implements.
99
+ RECORDING_TYPES = {
100
+ "Comment" => :comment,
101
+ "Message" => :message,
102
+ "Todo" => :todo,
103
+ "Kanban::Card" => :card,
104
+ "Document" => :document,
105
+ "Upload" => :upload,
106
+ "Schedule::Entry" => :schedule_entry,
107
+ "Question" => :question,
108
+ "Question::Answer" => :question_answer,
109
+ "Todolist" => :todolist,
110
+ "Vault" => :vault,
111
+ "Inbox::Forward" => :forward,
112
+ "Client::Approval" => :client_approval,
113
+ "Client::Correspondence" => :client_correspondence,
114
+ "GoogleDocument" => :google_document,
115
+ "CloudFile" => :cloud_file,
116
+ "Kanban::Step" => :card_step,
117
+ "Questionnaire" => :questionnaire,
118
+ "Schedule" => :schedule,
119
+ "Todoset" => :todoset,
120
+ "Message::Board" => :message_board,
121
+ "Kanban::Board" => :card_table,
122
+ "Kanban::Column" => :card_column,
123
+ "Inbox" => :inbox,
124
+ "Chat::Transcript" => :campfire
125
+ }.freeze
126
+
127
+ # The chat line subtypes that carry rich text — the two that declare
128
+ # +rich_text_attribute :content+ in BC3, and so the only two whose content
129
+ # can hold a mention. A Text line's content is HTML-escaped on the way out
130
+ # (+content_helper.rb+, +format_chat_line_with+), a Code line's is served
131
+ # verbatim — a snippet that happens to contain a bc-attachment tag — and an
132
+ # Upload line has no content.
133
+ RICH_TEXT_CHAT_LINE_TYPES = [ "Chat::Lines::RichText", "Chat::Lines::Integration" ].freeze
134
+
135
+ # Bounds how many Campfires one {#summarize} call tries, across both
136
+ # discovery sources and the refresh. A project has one Campfire and a
137
+ # handful of pings; a bucket past this bound is not a shape BC3 produces,
138
+ # and the call reports {Basecamp::CampfireDiscoveryIncompleteError} rather
139
+ # than calling the rest absent.
140
+ MAX_CAMPFIRE_CANDIDATES = 50
141
+
142
+ # Where #project leaves the validated bucket id for the cross-bucket
143
+ # check, removed before the summary is returned. A symbol, so it cannot
144
+ # collide with any string key the projection carries.
145
+ BUCKET_ID_MEMO = :__validated_bucket_id
146
+
147
+ # Resolves a recording pointer into a compact projection through the typed
148
+ # read its type names.
149
+ #
150
+ # The projection is a Hash with string keys: +id+, +status+, +type+,
151
+ # +title+, +app_url+, +content+, +updated_at+ and +mentioned_person_ids+
152
+ # always; +parent+, +bucket+, +creator+ and +assignees+ when the type has
153
+ # them; +campfire_id+ for a chat line — the Campfire it was found under,
154
+ # which is the reply destination for a chat trigger.
155
+ #
156
+ # @param bucket_id [Integer] the project the recording lives in. Required:
157
+ # it scopes the Campfire discovery for chat lines, and the read is
158
+ # checked against it so a pointer from one project can never resolve to a
159
+ # recording in another.
160
+ # @param recording_id [Integer] the recording's id
161
+ # @param event_type [String, nil] the account event feed type that named
162
+ # the recording — "comment.created", "card.assignment_changed",
163
+ # "chat.line.created". The segment before the action names the recording
164
+ # type. Used when +recording_type+ is empty.
165
+ # @param recording_type [String, nil] the recording's own type as BC3
166
+ # spells it — "Comment", "Kanban::Card", "Chat::Lines::Text". When set it
167
+ # takes precedence over +event_type+, being the more exact of the two.
168
+ # @return [Hash] the projection
169
+ # @raise [Basecamp::UsageError] when the pointer is incomplete
170
+ # @raise [Basecamp::RecordingRoutingError] before any request, when the
171
+ # pointer names no routable type
172
+ # @raise [Basecamp::UnresolvedRecordingError] when every visible Campfire
173
+ # answered 404 for a chat line
174
+ # @raise [Basecamp::CampfireDiscoveryIncompleteError] when candidates were
175
+ # left unsearched
176
+ # @raise [Basecamp::BucketMismatchError] when the read returned a recording
177
+ # from another bucket
178
+ # @raise [Basecamp::ApiError] non-retryable, when a successful response
179
+ # carries a value the reference's decoder would have refused — a body
180
+ # that is not an object, a bucket that is not one, an id that is not an
181
+ # integer. Minted by this composite, not by the read.
182
+ # @raise [Basecamp::Error] the read's own error otherwise — a 404 is a
183
+ # {Basecamp::NotFoundError}, as from the typed read itself
184
+ def summarize(bucket_id:, recording_id:, event_type: nil, recording_type: nil)
185
+ bucket_id = Ids.integer(bucket_id, "bucket id")
186
+ recording_id = Ids.integer(recording_id, "recording id")
187
+ unless bucket_id.positive? && recording_id.positive?
188
+ raise UsageError.new("bucket id and recording id are required")
189
+ end
190
+
191
+ kind = route_recording(event_type: event_type, recording_type: recording_type)
192
+ summary = read_summary(kind, bucket_id: bucket_id, recording_id: recording_id)
193
+
194
+ # UNREACHABLE TODAY, and deliberately kept. All 26 arms of #read_summary
195
+ # go through #project exactly once, so the memo is always present and
196
+ # this block never runs — a reviewer confirmed that by mutating it to
197
+ # `{ 0 }` and finding both suites green. It is here because the failure
198
+ # it guards is silent: a future route that builds a summary without
199
+ # #project would otherwise read "no bucket" and skip the comparison
200
+ # rather than fail. Stated as unreachable rather than tested, because a
201
+ # test for it could only be written by reaching around the design.
202
+ read_bucket_id = summary.delete(BUCKET_ID_MEMO) { raise unvalidated_summary }
203
+ # NON-ZERO, not positive. The reference compares whenever its bucket id
204
+ # is not the zero value, so a negative one is a mismatch there and was
205
+ # returned as a match here — this is the one check standing between a
206
+ # pointer and a recording in another project, and it was the only place
207
+ # the earlier sweep of this same defect did not reach.
208
+ if !read_bucket_id.zero? && read_bucket_id != bucket_id
209
+ raise BucketMismatchError.new(
210
+ bucket_id: bucket_id, actual_bucket_id: read_bucket_id, recording_id: recording_id
211
+ )
212
+ end
213
+
214
+ summary
215
+ end
216
+
217
+ # The recording types {#summarize} routes by +recording_type+, sorted, with
218
+ # the +Chat::Lines+ subtypes represented by their shared prefix. The set is
219
+ # deliberate rather than exhaustive — see {RECORDING_TYPES} — and any other
220
+ # type is +unknown_recording_type+ by design.
221
+ #
222
+ # @return [Array<String>]
223
+ def summarizable_recording_types
224
+ (RECORDING_TYPES.keys + [ "#{CHAT_LINE_TYPE_PREFIX}*" ]).sort
225
+ end
226
+
227
+ # The account event feed subjects {#summarize} routes by +event_type+ — an
228
+ # event type is "<subject>.<action>", and any action on a listed subject
229
+ # routes to that subject's read — sorted. "boost" is absent on purpose.
230
+ #
231
+ # @return [Array<String>]
232
+ def summarizable_event_types
233
+ EVENT_SUBJECTS.keys.map { |subject| "#{subject}.*" }.sort
234
+ end
235
+
236
+ private
237
+
238
+ # The read's body, which has to be an object before a single field is read
239
+ # off it.
240
+ #
241
+ # This is the guard one level up from the field checks, and it is the one
242
+ # that matters most, because a String body does not fail — it PASSES. In
243
+ # Ruby <tt>"scalar"["bucket"]</tt> is a substring search that quietly
244
+ # answers nil, so the bucket reads as absent, the cross-bucket comparison
245
+ # never runs, and a recording from another project is returned. That is
246
+ # the same fail-open this composite has now produced from three
247
+ # directions; the other two were a negative bucket id and a non-object
248
+ # bucket. An Array or a number raises TypeError instead, and nil raises
249
+ # NoMethodError — native exceptions out of a public method, where the
250
+ # reference has a decode failure.
251
+ #
252
+ # {Basecamp::Services::MergeSafe#require_hash} describes this same hazard
253
+ # in the same words; the message differs only because the escape it names
254
+ # belongs to the merge-safe writes.
255
+ #
256
+ # NULL IS NOT MALFORMED, and the first version of this guard had that
257
+ # wrong — it said the reference has a decode failure for nil, and the
258
+ # reference has no such thing. Measured: json.Unmarshal of `null` into a
259
+ # struct returns no error and leaves it zero, everywhere and at every
260
+ # depth, so a null body is a zero-valued summary there. An empty object
261
+ # is the same zero: every field reads as absent, and the bucket
262
+ # comparison is skipped exactly as it is for a zero bucket id.
263
+ def read_record(record)
264
+ return record if record.is_a?(Hash)
265
+ return {} if record.nil?
266
+
267
+ raise malformed_response("the read returned #{MergeSafe.describe(record)}, not a recording object")
268
+ end
269
+
270
+ # The bucket the read came back in, or 0 when it carries none.
271
+ #
272
+ # An absent or null "bucket" is genuinely none: the reference holds a
273
+ # pointer there and reads its zero value. ANYTHING ELSE that is not an
274
+ # object — a number, a string, an array — is a decode failure in the
275
+ # reference, which fails the read rather than reaching the comparison, and
276
+ # so is a malformed response here. Reading it as "none" would skip the
277
+ # comparison, which is how a projection from another project would have
278
+ # been returned.
279
+ def read_bucket_id(record)
280
+ bucket = record["bucket"]
281
+ return 0 if bucket.nil?
282
+
283
+ unless bucket.is_a?(Hash)
284
+ raise malformed_response("the recording's \"bucket\" is #{MergeSafe.describe(bucket)}, not an object")
285
+ end
286
+
287
+ id = Ids.from_wire(bucket["id"])
288
+ return id unless id.nil?
289
+
290
+ raise malformed_response("the recording's bucket id is #{MergeSafe.describe(bucket["id"])}, not an integer")
291
+ end
292
+
293
+ # Raised when a summary reaches the cross-bucket check without having
294
+ # been through #project, which is the only place the bucket is validated.
295
+ def unvalidated_summary
296
+ malformed_response("the recording summary was built without validating its bucket")
297
+ end
298
+
299
+ # The error for a body this composite cannot read.
300
+ #
301
+ # ApiError and not UsageError, non-retryable, for the reason
302
+ # {Basecamp::Services::MergeSafe} gives: the value arrived in a successful
303
+ # response, nothing the caller passed is at fault, and re-requesting
304
+ # cannot repair it. The reference gets this refusal from its decoder; this
305
+ # tier has no decoder, so it is explicit.
306
+ # The hint names what the field is actually read FOR, so the two sites get
307
+ # two hints. Attaching the recording one to a dock item said the value
308
+ # decided "which project it belongs to" when it decides which Campfire to
309
+ # search — a hint that sends the reader to the wrong half of the composite
310
+ # is worse than a shorter one.
311
+ def malformed_response(message, hint: RECORDING_HINT)
312
+ MergeSafe.malformed(message, hint)
313
+ end
314
+
315
+ # Picks the read for a pointer. +recording_type+ wins when set.
316
+ #
317
+ # Matched on BYTES. A Go string carries arbitrary bytes, so the reference
318
+ # routes "comment.\xFF" on its "comment" subject like any other; Ruby's
319
+ # String#strip validates the encoding and raised
320
+ # Encoding::CompatibilityError straight out of the public method for both
321
+ # arguments. The tables are ASCII, and an ASCII-only binary string is
322
+ # eql? to its text twin, so the lookups are unaffected.
323
+ #
324
+ # Trimmed by {Basecamp::Text}, not by String#strip, and the difference is
325
+ # a routing decision rather than a nicety. strip removes a leading or
326
+ # trailing NUL where the reference's TrimSpace does not, so "Comment\0"
327
+ # SELECTED A REAL TYPE here and was unknown_recording_type there — a
328
+ # malformed key reaching a live read, in the accepting direction. strip on
329
+ # a byte string also leaves the nineteen multi-byte spaces the reference
330
+ # trims. Measured: one byte of 256 diverges one way and nineteen
331
+ # characters the other.
332
+ #
333
+ # Only the RECORDING TYPE, because only it is compared whole. An event
334
+ # type is split on its last "." and only the subject is looked up, so a
335
+ # NUL in the action segment is never examined and "comment.created\0"
336
+ # routes on BOTH sides. An earlier version of this paragraph claimed
337
+ # otherwise and the test three files away already said so — the review
338
+ # that found the NUL made the same claim, and measuring it is what
339
+ # separated the half that was true from the half that was not.
340
+ def route_recording(event_type:, recording_type:)
341
+ type = Text.trim_space(recording_type.to_s)
342
+ unless type.empty?
343
+ return :chat_line if type.start_with?(CHAT_LINE_TYPE_PREFIX)
344
+
345
+ kind = RECORDING_TYPES[type]
346
+ return kind if kind
347
+
348
+ raise RecordingRoutingError.unknown_recording_type(type)
349
+ end
350
+
351
+ subject_type = Text.trim_space(event_type.to_s)
352
+ raise RecordingRoutingError.unknown_recording_type(subject_type) if subject_type.empty?
353
+
354
+ # A feed type is "<subject>.<action>"; the subject names the recording
355
+ # type. A string with no action is not a feed type and is not routed.
356
+ separator = subject_type.rindex(".")
357
+ if separator.nil? || !separator.positive? || separator == subject_type.length - 1
358
+ raise RecordingRoutingError.unknown_recording_type(subject_type)
359
+ end
360
+
361
+ subject = subject_type[0, separator]
362
+ raise RecordingRoutingError.no_recording_type(subject_type) if subject == "boost"
363
+
364
+ EVENT_SUBJECTS[subject] || raise(RecordingRoutingError.unknown_recording_type(subject_type))
365
+ end
366
+
367
+ # Performs the one typed read a kind names and projects it. Every branch is
368
+ # a single public generated method: no path is built here and no verb is
369
+ # chosen here.
370
+ def read_summary(kind, bucket_id:, recording_id:)
371
+ id = recording_id
372
+
373
+ case kind
374
+ when :comment
375
+ project(@client.comments.get(comment_id: id))
376
+ when :message
377
+ record = read_record(@client.messages.get(message_id: id))
378
+ project(record, title: first_non_empty(record["title"], record["subject"]))
379
+ when :todo
380
+ # A to-do's content is its plain title; the rich text — where mentions
381
+ # live — is the description.
382
+ record = read_record(@client.todos.get(todo_id: id))
383
+ project(
384
+ record,
385
+ title: first_non_empty(record["title"], record["content"]),
386
+ content: record["description"],
387
+ assignees: record["assignees"],
388
+ # A todo carries a TodoParent, which has no bucket field at all.
389
+ parent_has_bucket: false
390
+ )
391
+ when :card
392
+ record = read_record(@client.cards.get(card_id: id))
393
+ project(
394
+ record,
395
+ content: first_non_empty(record["content"], record["description"]),
396
+ assignees: record["assignees"]
397
+ )
398
+ when :chat_line
399
+ summarize_chat_line(bucket_id: bucket_id, line_id: id)
400
+ when :document
401
+ project(@client.documents.get(document_id: id))
402
+ when :upload
403
+ record = read_record(@client.uploads.get(upload_id: id))
404
+ project(
405
+ record,
406
+ title: first_non_empty(record["title"], record["filename"]),
407
+ content: record["description"]
408
+ )
409
+ when :schedule_entry
410
+ record = read_record(@client.schedules.get_entry(entry_id: id))
411
+ project(
412
+ record,
413
+ title: first_non_empty(record["title"], record["summary"]),
414
+ content: record["description"]
415
+ )
416
+ when :question
417
+ project(@client.checkins.get_question(question_id: id), content: "")
418
+ when :question_answer
419
+ project(@client.checkins.get_answer(answer_id: id))
420
+ when :todolist
421
+ record = read_record(@client.todolists.get(id: id))
422
+ project(
423
+ record,
424
+ title: first_non_empty(record["title"], record["name"]),
425
+ content: record["description"],
426
+ # A todolist carries a TodoParent too — same shape, same absence.
427
+ parent_has_bucket: false
428
+ )
429
+ when :vault
430
+ project(@client.vaults.get(vault_id: id), content: "")
431
+ when :forward
432
+ record = read_record(@client.forwards.get(forward_id: id))
433
+ project(record, title: first_non_empty(record["title"], record["subject"]))
434
+ when :client_approval
435
+ record = read_record(@client.client_approvals.get(approval_id: id))
436
+ project(record, title: first_non_empty(record["title"], record["subject"]))
437
+ when :client_correspondence
438
+ record = read_record(@client.client_correspondences.get(correspondence_id: id))
439
+ project(record, title: first_non_empty(record["title"], record["subject"]))
440
+ when :google_document
441
+ record = read_record(@client.google_documents.get_google_document(google_document_id: id))
442
+ project(record, content: record["description"])
443
+ when :cloud_file
444
+ record = read_record(@client.cloud_files.get_cloud_file(cloud_file_id: id))
445
+ project(record, content: record["description"])
446
+ when :card_step
447
+ record = read_record(@client.card_steps.get(step_id: id))
448
+ project(record, content: "", assignees: record["assignees"])
449
+ when :questionnaire
450
+ record = read_record(@client.checkins.get_questionnaire(questionnaire_id: id))
451
+ project(
452
+ record,
453
+ title: first_non_empty(record["title"], record["name"]),
454
+ content: "",
455
+ parent: nil
456
+ )
457
+ when :schedule
458
+ project(@client.schedules.get(schedule_id: id), content: "", parent: nil)
459
+ when :todoset
460
+ record = read_record(@client.todosets.get(todoset_id: id))
461
+ project(
462
+ record,
463
+ title: first_non_empty(record["title"], record["name"]),
464
+ content: "",
465
+ parent: nil
466
+ )
467
+ when :message_board
468
+ project(@client.message_boards.get(board_id: id), content: "", parent: nil)
469
+ when :card_table
470
+ project(@client.card_tables.get(card_table_id: id), content: "", parent: nil)
471
+ when :card_column
472
+ record = read_record(@client.card_columns.get(column_id: id))
473
+ project(record, content: record["description"])
474
+ when :inbox
475
+ project(@client.forwards.get_inbox(inbox_id: id), content: "", parent: nil)
476
+ when :campfire
477
+ project(@client.campfires.get(campfire_id: id), content: "", parent: nil)
478
+ else
479
+ # Unreachable while every value in RECORDING_TYPES and EVENT_SUBJECTS
480
+ # has a branch. It is here so that adding a routing row and forgetting
481
+ # the projection refuses the pointer by name rather than returning nil
482
+ # and failing somewhere else.
483
+ raise RecordingRoutingError.unknown_recording_type(kind)
484
+ end
485
+ end
486
+
487
+ # Projects one read recording into the summary shape.
488
+ #
489
+ # Absent members are omitted rather than carried as nil, so a consumer
490
+ # reads the same shape whatever the type: a comment has no assignees, a
491
+ # vault no content. +mentioned_person_ids+ is the exception — always
492
+ # present, so a consumer reads [] rather than a missing key.
493
+ # Builds the projection.
494
+ #
495
+ # WHERE THIS PORT'S TYPE CHECKING STOPS, stated here because the edge is a
496
+ # decision rather than an oversight. The reference decodes the whole body
497
+ # into a struct, so any field of the wrong type fails its read. This tier
498
+ # has no decoder, and reproducing one field by field would be writing a
499
+ # second decoder by hand against a spec that moves.
500
+ #
501
+ # So the rule is: this composite refuses what would make it ACT wrongly,
502
+ # and passes through what it merely REPORTS — but that line moved three
503
+ # times, each time because a fix made this composite read a field it had
504
+ # only been reporting, so it is now drawn where the REFERENCE draws it
505
+ # instead of where this port happened to need it.
506
+ #
507
+ # TYPED, because the reference holds a plain string and a value of another
508
+ # type is a decode failure there: "status", "type", "title", "content",
509
+ # "app_url". Null normalizes to "" at every one of them, which is what its
510
+ # decoder does. Also refused, for reasons of their own: the body envelope,
511
+ # the bucket and its id (they decide whether the recording is in the
512
+ # caller's project), the dock and listing entries with their ids and names
513
+ # (they decide which Campfires get searched), and "assignees" (it decides
514
+ # whether the key appears at all).
515
+ #
516
+ # Also typed: "id", which the reference holds as a plain 64-bit integer.
517
+ # An earlier version of this paragraph listed it as passed through and
518
+ # called it the place where reproducing the decoder begins. That was
519
+ # wrong twice over — ten shapes reached the projection verbatim that the
520
+ # reference refuses, and the check was already written, four times, on the
521
+ # sibling id fields in this same file.
522
+ #
523
+ # ALSO TYPED, as of card 39 — "parent", "bucket" and "creator", the
524
+ # nested objects, FIELD BY FIELD against the model the reference decodes
525
+ # into. #read_member_fields carries the table and the reasoning; the
526
+ # short version is that these used to be passed through whole, and this
527
+ # paragraph used to argue that widening meant writing a decoder. The
528
+ # survey that settled the card showed the argument was scoped wrong:
529
+ # a nested identity is a bounded, enumerable set — Person's 12 strings,
530
+ # 9 bools and a company, Parent's 4 strings and its bucket, Bucket's 2 —
531
+ # and it was only the WHOLE-RECORDING decode that was out of reach.
532
+ # Four of the seven SDKs get the nested half for free through a typed
533
+ # decoder, so stopping short of it made this port the minority rather
534
+ # than the careful one.
535
+ #
536
+ # Their ID is decoded rather than merely checked, because this composite
537
+ # reads it to decide whether the member appears at all, and reading a
538
+ # value one way while emitting it another is what made a caller see
539
+ # "007" where the contract gives 7. An ABSENT id is still not
540
+ # synthesised: the reference emits a whole typed struct with every field
541
+ # present, and inventing one of them while passing the rest through would
542
+ # be half a decode. What is also honoured is the reference's emptiness
543
+ # rule — it builds each member only when it has an id or a name, so an
544
+ # empty object leaves the key out rather than appearing as "{}".
545
+ #
546
+ # "bucket" appears in both lists above for a reason worth stating rather
547
+ # than tidying: its OBJECT is reported to the caller, and its ID is read
548
+ # by the cross-bucket check. It is the one member that is both reported
549
+ # and interpreted, which is exactly why it has been the source of four
550
+ # separate defects on this branch.
551
+ #
552
+ # The rule that produced three rounds of findings, stated so the next
553
+ # person does not rediscover it: when a change makes this composite READ
554
+ # a field it used to only report, the field moves into the typed set and
555
+ # this paragraph has to move with it.
556
+ #
557
+ # THE RESIDUE — the one place it is enumerated in this file. The other
558
+ # comments that mention part of it point here rather than repeating it,
559
+ # because three spellings of this list in one file had already drifted
560
+ # into three different lists, under a heading calling one of them whole.
561
+ # * the recording's own fields this projection never reads — "url",
562
+ # "visible_to_clients", "bookmark_url", "created_at",
563
+ # "content_attachments". The reference decodes the entire typed model,
564
+ # so a wrong-typed one of those fails ITS read and not this one.
565
+ # Closing that is the whole-recording decoder across every routed type,
566
+ # which is a different change and deliberately not this one. Python and
567
+ # TypeScript carry the identical residue and their SPEC rows say so.
568
+ # * "updated_at", a deliberate divergence rather than a gap: the
569
+ # reference parses an instant and every port here keeps the API's own
570
+ # string, which Appendix F records. The TYPE is checked; the VALUE is
571
+ # not.
572
+ # * the same for a PERSON's "created_at" / "updated_at", one level down
573
+ # (#read_member_fields): <tt>*time.Time</tt> in the model, so a string
574
+ # that is not RFC 3339 fails only in the reference.
575
+ # * an assignee's null "name", emitted as nil where the reference emits
576
+ # "" — the label write-back #read_member does for a creator, a parent
577
+ # and a bucket does not reach #read_assignees. TypeScript matches this
578
+ # port there rather than moving alone.
579
+ # * a parent's nested "bucket", which is TYPED (a wrong-typed one fails
580
+ # the read, as the generated decode does) but then passed through
581
+ # whole, id undecoded, where the reference's conversion builds a
582
+ # <tt>Parent</tt> without a bucket and emits no such key at all.
583
+ def project(record, title: :default, content: :default, assignees: nil, parent: :default,
584
+ parent_has_bucket: true)
585
+ record = read_record(record)
586
+ title = record["title"] if title == :default
587
+ content = record["content"] if content == :default
588
+ parent = record["parent"] if parent == :default
589
+ # CONTENT is refused rather than coerced, because it is the one member
590
+ # of the projection this composite INTERPRETS: mentioned_person_ids is
591
+ # derived from it. to_s turned an array or a hash into its Ruby
592
+ # rendering and then scanned that for mentions, which is reading a body
593
+ # the reference would have failed to decode. Title goes through the same
594
+ # check because first_non_empty reaches both.
595
+ content = read_text(content, "content")
596
+ title = read_text(title, "title")
597
+
598
+ summary = {
599
+ "id" => read_id(record["id"]),
600
+ "status" => read_text(record["status"], "status"),
601
+ # Typed, not passed through, because the chat-line route READS this to
602
+ # decide whether a line's content can carry a mention. The reference
603
+ # holds a plain string, so an array or an object there is a decode
604
+ # failure — and without this a line whose type was an object came back
605
+ # successfully with its mentions silently cleared.
606
+ "type" => read_text(record["type"], "type"),
607
+ "title" => title.to_s,
608
+ "app_url" => read_text(record["app_url"], "app_url"),
609
+ "parent" => parent,
610
+ "bucket" => record["bucket"],
611
+ "creator" => record["creator"],
612
+ "assignees" => assignees,
613
+ "mentioned_person_ids" => Mentions.mentioned_person_ids(content),
614
+ "content" => content,
615
+ "updated_at" => record["updated_at"]
616
+ }
617
+ # EMPTY, not merely absent. The reference builds each of these only when
618
+ # it has something in it — `if Id != 0 || Name != ""` for a bucket, the
619
+ # same shape for a parent and a creator — so a `{}` in the response
620
+ # leaves the field nil there and `omitempty` drops the key. Ruby emitted
621
+ # the empty object, so a caller testing `summary.key?("bucket")` got a
622
+ # different answer from the contract's.
623
+ # VALIDATED HERE, before any key is dropped, and memoized for the
624
+ # cross-bucket check. Reading it out of the summary afterwards made
625
+ # keep_member? — a predicate whose job is "should this key appear" —
626
+ # the thing guaranteeing that check runs at all, through two clauses
627
+ # written purely for that purpose. It failed open twice, both times
628
+ # because someone editing an output-shape predicate had no reason to
629
+ # suspect they were disabling a safety check. The reference validates
630
+ # the raw member and drops the key on its own emptiness rule, which is
631
+ # what this now does in the same order.
632
+ summary[BUCKET_ID_MEMO] = read_bucket_id(record)
633
+
634
+ parent_member = read_member(parent, "title", "parent", kind: :parent, parent_has_bucket: parent_has_bucket)
635
+ parent_member.nil? ? summary.delete("parent") : summary["parent"] = parent_member
636
+
637
+ bucket_member = read_member(summary["bucket"], "name", "bucket", kind: :bucket)
638
+ bucket_member.nil? ? summary.delete("bucket") : summary["bucket"] = bucket_member
639
+ # A creator's id is decoded FLEXIBLY, because a creator is a Person and
640
+ # that is the one id in the generated model typed that way. Reading it
641
+ # with the strict decoder made {"id" => "basecamp"} — the sentinel the
642
+ # API serves for system-generated entities — look malformed, so the
643
+ # member was kept where the reference reads 0 and drops it.
644
+ creator_member = read_member(summary["creator"], "name", "creator", kind: :person)
645
+ creator_member.nil? ? summary.delete("creator") : summary["creator"] = creator_member
646
+ # Absent or empty is genuinely nothing to report — the reference's
647
+ # +omitempty+ leaves an empty slice out of its summary too, so the key
648
+ # goes. Anything else that is not an array of objects is a decode
649
+ # failure there and fails the read here.
650
+ #
651
+ # This comment used to say a malformed "assignees" was read as absent
652
+ # "for the same reason a malformed bucket is". That reason stopped being
653
+ # true when the bucket started raising, and the sentence survived the
654
+ # change it contradicted — which is the whole hazard of writing an
655
+ # invariant down next to the code instead of into a test.
656
+ if assignees.nil? || (assignees.is_a?(Array) && assignees.empty?)
657
+ summary.delete("assignees")
658
+ else
659
+ summary["assignees"] = read_assignees(assignees)
660
+ end
661
+ summary
662
+ end
663
+
664
+ # The assignees, which the reference decodes as a slice of people.
665
+ #
666
+ # Each member is checked for being an object, its ID is decoded FLEXIBLY
667
+ # and re-emitted, and EVERY OTHER FIELD the reference types is typed here
668
+ # too — the same Person table #read_member_fields applies to the creator,
669
+ # applied one level down and to EVERY element rather than only the first.
670
+ # What this does NOT do — including emitting a null name as nil where the
671
+ # reference emits "" — is enumerated once, on #project.
672
+ #
673
+ # This paragraph used to end "every other field passes through whole" and
674
+ # called that the deliberate edge of this port's rule. Card 39 moved the
675
+ # edge — the reference decodes <tt>[]Person</tt>, so a wrong-typed
676
+ # <tt>avatar_url</tt> on the SECOND assignee fails its read there — and
677
+ # the sentence survived the change that contradicted it. That is twice now
678
+ # for this one comment.
679
+ #
680
+ # Before that it ended "a person id is the one place the two genuinely
681
+ # differ", and by the time it was written that was false three ways: the
682
+ # id is where the two now AGREE, the NAME is where they differ, and the
683
+ # reader it cited is the strict one while this path uses the flexible one.
684
+ # It sat directly above the gap it denied.
685
+ def read_assignees(assignees)
686
+ unless assignees.is_a?(Array)
687
+ raise malformed_response("the recording's \"assignees\" is #{MergeSafe.describe(assignees)}, not an array")
688
+ end
689
+
690
+ assignees = assignees.map do |assignee|
691
+ # A null member is the ZERO PERSON there, and the reference emits it
692
+ # as an object — so passing nil through handed a consumer something
693
+ # that crashes on `assignee["id"]` where the contract gives 0.
694
+ # An empty object, which is what this tier passes through for a member
695
+ # carrying nothing.
696
+ #
697
+ # The previous version emitted {"id" => 0, "name" => ""} here, which
698
+ # is what the reference marshals for a null member — and left {},
699
+ # {"id" => 5} and {"name" => "N"} alone, which the reference marshals
700
+ # the SAME way. So it synthesised the zero Person for one of the four
701
+ # inputs that produce it and passed the other three through, under a
702
+ # comment naming as fixed the very input it left. One inconsistency
703
+ # replaced by a smaller one is not a fix.
704
+ #
705
+ # Passing through is the boundary #project states and the one every
706
+ # other nested member follows: the reference emits a whole typed
707
+ # struct and this tier emits the object it was given. An empty hash is
708
+ # still indexable, which was the actual complaint — a nil member
709
+ # crashed on assignee["id"]. Whether to close this uniformly across
710
+ # all four inputs, and across parent, bucket and creator, is still
711
+ # open: card 39 settled how far a nested member is TYPED, not whether
712
+ # this tier synthesises the zero Person the reference emits, and this
713
+ # line should not be read as if it had.
714
+ next {} if assignee.nil?
715
+
716
+ unless assignee.is_a?(Hash)
717
+ raise malformed_response("an assignee is #{MergeSafe.describe(assignee)}, not an object")
718
+ end
719
+
720
+ # Every assignee is a Person, so its id takes the flexible decoder and
721
+ # is emitted decoded — the same rule as the creator, one level down,
722
+ # and the level the first version of that fix did not reach.
723
+ #
724
+ # Its OTHER FIELDS take the same Person table the creator takes. They
725
+ # used to pass through untyped, which was the stopping point
726
+ # #read_member declared and card 39 closed: the reference decodes
727
+ # <tt>[]Person</tt>, so a wrong-typed <tt>avatar_url</tt> on the
728
+ # SECOND assignee fails its read exactly as it does on the first, and
729
+ # a rule applied to the creator but not to the assignees would have
730
+ # been the same inconsistency one level out.
731
+ read_member_fields(assignee, "assignee", kind: :person)
732
+
733
+ id = read_member_id(assignee, person: true)
734
+ if id.nil?
735
+ raise malformed_response("an assignee's id is #{MergeSafe.describe(assignee["id"])}, not a person id")
736
+ end
737
+
738
+ assignee.key?("id") ? assignee.merge("id" => id) : assignee
739
+ end
740
+
741
+ # Returned explicitly rather than leaning on #each handing back its
742
+ # receiver. The projection assigns from this call, so a later edit that
743
+ # ends the method on anything else — an each_with_index, a guard clause,
744
+ # one more line — would put nil into the summary silently.
745
+ assignees
746
+ end
747
+
748
+ # The first of these that is a non-empty string.
749
+ #
750
+ # Each candidate is type-checked rather than coerced, for the reason
751
+ # read_text gives: these feed the title and the content, and a to_s here
752
+ # would have hidden exactly what that check exists to catch.
753
+ # The recording's own id.
754
+ #
755
+ # The reference types this as a plain 64-bit integer, so a string, a
756
+ # float, a boolean, an array or an object is a decode failure there — ten
757
+ # shapes that reached the projection verbatim while the boundary comment
758
+ # claimed this field was where reproducing the decoder would begin. It is
759
+ # not: the check is the same one already applied to four sibling id fields
760
+ # in this file. Absent is 0, as its zero value is.
761
+ def read_id(value)
762
+ id = Ids.from_wire(value)
763
+ return id unless id.nil?
764
+
765
+ raise malformed_response("the recording's id is #{MergeSafe.describe(value)}, not an integer")
766
+ end
767
+
768
+ # A nested member as the projection should carry it, or nil to drop it.
769
+ #
770
+ # The name and the shape both changed when this stopped being a predicate,
771
+ # and this paragraph described the predicate for two rounds after — down
772
+ # to citing a mutation result that no longer reproduces. What it does now:
773
+ # refuse a member the reference could not decode, decode its id, and
774
+ # return it or nil by the emptiness rule.
775
+ #
776
+ # EMPTINESS IS TWO NAMED FIELDS, and the label differs by member: the
777
+ # reference tests <tt>Id != 0 || Name != ""</tt> for a bucket and a
778
+ # creator and <tt>Id != 0 || Title != ""</tt> for a parent, uniformly
779
+ # across all 17 conversions. A predicate over every value instead kept
780
+ # <tt>{"type" => "Project"}</tt>, which the reference drops.
781
+ #
782
+ # WHAT IS AND IS NOT VALIDATED, stated exactly, because a previous
783
+ # version of this claimed the line was drawn where the reference draws it
784
+ # and that was measurably false. Validated: the member is an object, its
785
+ # ID decodes, its LABEL is a string, and EVERY OTHER FIELD the reference
786
+ # types is typed here too — see #read_member_fields for the table and for
787
+ # why it is transcribed from the generated model rather than the
788
+ # hand-written one. What is NOT validated, or is emitted differently, is
789
+ # enumerated once, on #project, and deliberately not restated here: a
790
+ # partial copy of that list under the words "stated exactly" is what this
791
+ # paragraph was until a review counted it.
792
+ #
793
+ # This used to stop at the three values the composite reads, and said so
794
+ # as a deliberate limit. Card 39 settled it the other way: four of the
795
+ # seven SDKs reproduce the whole nested decode for free through a typed
796
+ # decoder and cannot stop short of it, so the contract is the reference's
797
+ # line rather than this port's, and a port that stopped earlier was the
798
+ # minority rather than the rule.
799
+ def read_member(member, label, member_name, kind:, parent_has_bucket: true)
800
+ return nil if member.nil?
801
+
802
+ unless member.is_a?(Hash)
803
+ raise malformed_response("the recording's #{member_name} is " \
804
+ "#{MergeSafe.describe(member)}, not an object")
805
+ end
806
+
807
+ person = kind == :person
808
+ read_member_fields(member, member_name, kind: kind, parent_has_bucket: parent_has_bucket)
809
+
810
+ # REFUSED, not kept. The reference holds a plain string for a name or a
811
+ # title and a typed integer for an id, so a value of another type is a
812
+ # whole-body decode failure there — and both earlier versions of this
813
+ # code kept the member instead, on a comment claiming that the reader
814
+ # which reports a malformed body had to see it. Only the BUCKET has such
815
+ # a reader; for a parent and a creator nothing else ever looked, so
816
+ # "kept for the reader" was under-refusal with a justification that did
817
+ # not apply at two of its three call sites.
818
+ # Normalized to "" for a null, like every other string field — the
819
+ # boundary paragraph on #project already claims that for the top-level
820
+ # ones and the nested labels were silently exempt, so a null title came
821
+ # back as nil where the contract gives "" and `creator["name"].empty?`
822
+ # raised for a caller.
823
+ name = read_text(member[label], "#{member_name}'s #{label}")
824
+
825
+ id = read_member_id(member, person: person)
826
+ if id.nil?
827
+ raise malformed_response("the #{member_name}'s id is #{MergeSafe.describe(member["id"])}, not an id")
828
+ end
829
+
830
+ # The DECODED id is what travels, not the raw wire value. The reference
831
+ # decodes a person id flexibly and re-emits the integer — "007" comes
832
+ # back as 7 and the "basecamp" sentinel as 0 — so reading it only to
833
+ # decide whether to keep the member, while emitting the string, applied
834
+ # half of the rule and left a caller reading a different type from the
835
+ # contract's.
836
+ # The normalized LABEL is written back too. It used to be computed into a
837
+ # local, used for the emptiness test, and discarded — so a null title
838
+ # still reached the caller as nil while the comment above claimed it
839
+ # came back as "". A reviewer proved it a pure no-op: reverting the
840
+ # whole change altered not one assertion in the suite.
841
+ decoded = member.key?("id") ? member.merge("id" => id) : member
842
+ decoded = decoded.merge(label => name) if member.key?(label)
843
+
844
+ # Present only when it carries something, which is the emptiness rule
845
+ # the reference builds these under: `Id != 0 || Name != ""` for a bucket
846
+ # and a creator, `Id != 0 || Title != ""` for a parent.
847
+ (!id.zero? || !name.to_s.empty?) ? decoded : nil
848
+ end
849
+
850
+ # Every field of a nested member that the reference types, typed here too.
851
+ #
852
+ # THE TABLE IS TRANSCRIBED FROM <tt>generated</tt>, NOT FROM
853
+ # <tt>pkg/basecamp</tt>, and the distinction is not academic. The service
854
+ # wrapper the composite calls hands the body to the GENERATED client and
855
+ # converts the result afterwards (<tt>commentFromGenerated</tt>), so the
856
+ # decode — the thing that fails a read — happens against the generated
857
+ # model. The two disagree in exactly the places that would have bitten:
858
+ # <tt>system_label</tt> is a field of the hand-written Person and NOT of
859
+ # the generated one, so it is an unknown key the reference IGNORES and
860
+ # refusing it here would reject a body the reference accepts; and a
861
+ # person's id is <tt>FlexibleInt64</tt> there against a plain
862
+ # <tt>int64</tt> here, which is why #read_member_id keeps two readers.
863
+ #
864
+ # NULL IS NOT A WRONG TYPE. Every field below is a pointer or a plain
865
+ # string in the model, and <tt>json.Unmarshal</tt> of <tt>null</tt> is a
866
+ # no-op at any depth, so only a PRESENT value of the wrong type fails.
867
+ # The same asymmetry #read_record applies to the body applies to each
868
+ # field of each member.
869
+ #
870
+ # NOT VALIDATED here: the VALUE of a person's <tt>created_at</tt> /
871
+ # <tt>updated_at</tt>, which the reference holds as a
872
+ # <tt>*time.Time</tt> and therefore parses. A non-string fails on both
873
+ # sides; a string that is not RFC 3339 fails only there. That and the
874
+ # rest of what this port declares rather than closes are enumerated once,
875
+ # on #project.
876
+ def read_member_fields(member, member_name, kind:, parent_has_bucket: true)
877
+ case kind
878
+ when :person
879
+ PERSON_STRING_FIELDS.each { |field| read_member_string(member, field, member_name) }
880
+ PERSON_BOOL_FIELDS.each { |field| read_member_bool(member, field, member_name) }
881
+ read_person_company(member, member_name)
882
+ when :parent
883
+ PARENT_STRING_FIELDS.each { |field| read_member_string(member, field, member_name) }
884
+ # A parent's bucket is a typed <tt>*RecordingBucket</tt> under
885
+ # <tt>RecordingParent</tt> and does not exist at all under
886
+ # <tt>TodoParent</tt>, which is what a todo and a todolist carry. So
887
+ # the same key is a typed struct under one containing type and an
888
+ # ignored unknown under another, and only the reference says which —
889
+ # hence the flag, threaded from the routing arm rather than guessed
890
+ # from the payload. Applying one shape to both would refuse
891
+ # <tt>{"parent" => {"bucket" => 7}}</tt> on a todo, which the
892
+ # reference accepts.
893
+ read_nested_bucket(member["bucket"], "#{member_name}'s bucket") if parent_has_bucket
894
+ when :bucket
895
+ BUCKET_STRING_FIELDS.each { |field| read_member_string(member, field, member_name) }
896
+ end
897
+ end
898
+
899
+ # A nested bucket — the parent's, whose shape is the top-level one's.
900
+ def read_nested_bucket(value, member_name)
901
+ return if value.nil?
902
+
903
+ unless value.is_a?(Hash)
904
+ raise malformed_response("the recording's #{member_name} is " \
905
+ "#{MergeSafe.describe(value)}, not an object")
906
+ end
907
+
908
+ id = Ids.from_wire(value["id"])
909
+ if id.nil?
910
+ raise malformed_response("the #{member_name} id is #{MergeSafe.describe(value["id"])}, not an integer")
911
+ end
912
+ BUCKET_STRING_FIELDS.each { |field| read_member_string(value, field, member_name) }
913
+ end
914
+
915
+ # A person's company: an object, with a plain integer id and a string name.
916
+ def read_person_company(member, member_name)
917
+ company = member["company"]
918
+ return if company.nil?
919
+
920
+ unless company.is_a?(Hash)
921
+ raise malformed_response("the #{member_name}'s company is " \
922
+ "#{MergeSafe.describe(company)}, not an object")
923
+ end
924
+
925
+ # Plain <tt>int64</tt> on <tt>PersonCompany</tt>, NOT the flexible
926
+ # reader the person's own id takes — a company id of "1" fails the read
927
+ # there where the person's resolves.
928
+ id = Ids.from_wire(company["id"])
929
+ if id.nil?
930
+ raise malformed_response("the #{member_name}'s company id is " \
931
+ "#{MergeSafe.describe(company["id"])}, not an integer")
932
+ end
933
+ read_member_string(company, "name", "#{member_name}'s company")
934
+ end
935
+
936
+ def read_member_string(member, field, member_name)
937
+ value = member[field]
938
+ return if value.nil? || value.is_a?(String)
939
+
940
+ raise malformed_response("the #{member_name}'s #{field} is " \
941
+ "#{MergeSafe.describe(value)}, not a string")
942
+ end
943
+
944
+ def read_member_bool(member, field, member_name)
945
+ value = member[field]
946
+ return if value.nil? || value == true || value == false
947
+
948
+ raise malformed_response("the #{member_name}'s #{field} is " \
949
+ "#{MergeSafe.describe(value)}, not a boolean")
950
+ end
951
+
952
+ # A nested member's id, by the decoder the reference types that field with.
953
+ def read_member_id(member, person: false)
954
+ return Ids.from_wire(member["id"]) unless person
955
+ # Absent is the zero value; a present null is a decode failure, which is
956
+ # the one place these two readers differ on nil.
957
+ return 0 unless member.key?("id")
958
+
959
+ Ids.person_from_wire(member["id"])
960
+ end
961
+
962
+ def first_non_empty(*values)
963
+ # EVERY candidate is type-checked, not just the ones reached before the
964
+ # first non-empty one. Returning early left a malformed SECOND candidate
965
+ # unread — a card with a good "content" and a numeric "description"
966
+ # succeeded here and was a decode failure there, because the reference
967
+ # decodes the whole struct before choosing between its fields.
968
+ texts = values.map { |value| read_text(value, "title or content") }
969
+ texts.find { |text| !text.empty? } || ""
970
+ end
971
+
972
+ # A text member of a read, or "" when it carries none.
973
+ #
974
+ # Absent or null is genuinely empty. A String passes through. Anything
975
+ # else is a decode failure in the reference, which holds a plain string
976
+ # for every one of these.
977
+ def read_text(value, name)
978
+ return "" if value.nil?
979
+ return value if value.is_a?(String)
980
+
981
+ raise malformed_response("the recording's \"#{name}\" is #{MergeSafe.describe(value)}, not a string")
982
+ end
983
+
984
+ # Resolves the line, then projects it with the Campfire it was found under.
985
+ def summarize_chat_line(bucket_id:, line_id:)
986
+ line, campfire_id = resolve_chat_line(bucket_id: bucket_id, line_id: line_id)
987
+ summary = project(line)
988
+ # Read off the PROJECTION, not off the original. project normalizes a
989
+ # null body to a zero-valued summary — the reference decodes `null` as
990
+ # the zero value with no error — and this line went on indexing the raw
991
+ # response, so a chat line that came back null raised NoMethodError out
992
+ # of a public method while every other summary route handled it. The
993
+ # null rule was applied where it was found and not at the one site that
994
+ # reads around it.
995
+ unless RICH_TEXT_CHAT_LINE_TYPES.include?(summary["type"])
996
+ # A plain-text or code line's content is text BC3 never read as markup,
997
+ # so a literal "<bc-attachment>" in it mentions nobody.
998
+ summary["mentioned_person_ids"] = []
999
+ end
1000
+ # Omitted when zero, as the reference's `omitempty` omits it. A listed
1001
+ # Campfire with a zero id is a real candidate on both sides — neither
1002
+ # filters ids there — so this is reachable rather than theoretical.
1003
+ summary["campfire_id"] = campfire_id unless campfire_id.zero?
1004
+ summary
1005
+ end
1006
+
1007
+ # Campfire discovery for chat lines.
1008
+ #
1009
+ # The loop tries the line under each candidate until one answers, within
1010
+ # one total budget of {MAX_CAMPFIRE_CANDIDATES} per call.
1011
+ #
1012
+ # Two failure shapes are kept apart on purpose. A candidate that answers
1013
+ # anything but 404 — 401, 403, 5xx, a network error — stops the loop and is
1014
+ # raised as that error: the read failed, and trying the next Campfire would
1015
+ # only hide it. A 404 means "not here", so the loop moves on. Only when
1016
+ # every candidate said "not here" is the line unresolved
1017
+ # ({Basecamp::UnresolvedRecordingError}) — and before concluding that, the
1018
+ # cached sources are refreshed (subject to the floor) so a Campfire created
1019
+ # after the cache filled is tried too. Discovery that could not be
1020
+ # completed — a listing cut off at its cap, a bucket with more candidates
1021
+ # than the budget — is {Basecamp::CampfireDiscoveryIncompleteError}, never
1022
+ # "unresolved": nothing unsearched is ever reported absent.
1023
+ #
1024
+ # What HTTP cannot tell apart: BC3 answers 404 both for a line that is not
1025
+ # in a Campfire and for a Campfire the caller may no longer see. See
1026
+ # {Basecamp::UnresolvedRecordingError} for what that means for a consumer.
1027
+ def resolve_chat_line(bucket_id:, line_id:)
1028
+ index = @client.campfire_index
1029
+ account_id = @client.account_id
1030
+ search = ChatLineSearch.new(campfires: @client.campfires, line_id: line_id)
1031
+
1032
+ # Pass 1: what the sources already hold — the dock (read if it must be),
1033
+ # then the listing only if it is cached. A listing fetch is the
1034
+ # expensive, slow request, and it is not made until the dock — including
1035
+ # its refresh — has had its say, so a listing that is down, over its cap,
1036
+ # or stalled never stands between a project's line and the one project
1037
+ # read that finds it.
1038
+ dock = index.dock_campfires(account_id: account_id, bucket_id: bucket_id) do
1039
+ dock_campfire_ids(bucket_id)
1040
+ end
1041
+ found = search.try(dock.ids)
1042
+ return found if found
1043
+
1044
+ listed = index.cached_listed_campfires(account_id: account_id, bucket_id: bucket_id)
1045
+ list_cached = !listed.nil?
1046
+ if list_cached
1047
+ found = search.try(listed.ids)
1048
+ return found if found
1049
+ end
1050
+
1051
+ # Pass 2: re-read the dock if it was served from cache (the floor may
1052
+ # decline), then fetch or refresh the listing. Whatever comes back is the
1053
+ # current snapshot of that source, whoever loaded it — another caller may
1054
+ # have populated or refreshed it in the meantime — so it always replaces
1055
+ # the pass-1 one; "refreshed" is whether a source the conclusion had
1056
+ # consulted is now newer than when it was consulted.
1057
+ #
1058
+ # What a spent budget does here has three cases, and they are three
1059
+ # different answers rather than one:
1060
+ #
1061
+ # 1. A source ALREADY CONSULTED is not re-read. It could hand this call
1062
+ # no candidate it may try, so the request cannot help — and a failure
1063
+ # on it would replace a settled verdict with a transient error a
1064
+ # consumer retries forever.
1065
+ # 2. A source NEVER CONSULTED is incomplete, and says which one. There
1066
+ # may be candidates there, unsearched, and nothing unsearched is ever
1067
+ # reported absent.
1068
+ # 3. A spent budget with BOTH sources consulted is unresolved, not
1069
+ # incomplete. Everything was searched; "look again" would be wrong.
1070
+ refreshed = false
1071
+
1072
+ if search.budget_left? && dock.cached?
1073
+ again = index.dock_campfires(account_id: account_id, bucket_id: bucket_id, refresh: true) do
1074
+ dock_campfire_ids(bucket_id)
1075
+ end
1076
+ refreshed = true if again.fetched > dock.fetched || !again.cached?
1077
+ dock = again
1078
+ found = search.try(dock.ids)
1079
+ return found if found
1080
+ end
1081
+
1082
+ if !search.budget_left?
1083
+ unless list_cached
1084
+ raise CampfireDiscoveryIncompleteError.new(
1085
+ bucket_id: bucket_id, recording_id: line_id,
1086
+ reason: "the candidate budget of #{MAX_CAMPFIRE_CANDIDATES} was spent before " \
1087
+ "the account listing was consulted"
1088
+ )
1089
+ end
1090
+ else
1091
+ begin
1092
+ again = index.listed_campfires(
1093
+ account_id: account_id, bucket_id: bucket_id, refresh: list_cached
1094
+ ) { listed_campfire_ids_by_bucket }
1095
+ rescue CampfireIndex::ListingOverflow => e
1096
+ raise CampfireDiscoveryIncompleteError.new(
1097
+ bucket_id: bucket_id, recording_id: line_id, reason: e.message
1098
+ )
1099
+ end
1100
+ refreshed = true if list_cached && (again.fetched > listed.fetched || !again.cached?)
1101
+ listed = again
1102
+ found = search.try(listed.ids)
1103
+ return found if found
1104
+ end
1105
+
1106
+ raise budget_exhausted(bucket_id, line_id) if search.skipped?
1107
+
1108
+ stale = if refreshed
1109
+ search.tried.reject { |id| dock.ids.include?(id) || listed.ids.include?(id) }
1110
+ else
1111
+ []
1112
+ end
1113
+ raise UnresolvedRecordingError.new(
1114
+ bucket_id: bucket_id,
1115
+ recording_id: line_id,
1116
+ campfire_ids: search.tried,
1117
+ refreshed: refreshed,
1118
+ stale_campfire_ids: stale
1119
+ )
1120
+ end
1121
+
1122
+ # The Campfire ids a bucket's project dock names. A bucket that is not a
1123
+ # project (a 404 on the project read) has none; any other failure of the
1124
+ # read is raised.
1125
+ def dock_campfire_ids(bucket_id)
1126
+ project = begin
1127
+ @client.projects.get(project_id: bucket_id)
1128
+ rescue NotFoundError
1129
+ return []
1130
+ end
1131
+
1132
+ project = read_record(project)
1133
+ dock = project["dock"]
1134
+ # An absent dock is genuinely none — a project need not have one, and the
1135
+ # reference reads its zero value. Anything else that is not an array is a
1136
+ # decode failure there, and reading it as "no Campfires" would report a
1137
+ # line absent from a project whose dock was never legible.
1138
+ return [] if dock.nil?
1139
+
1140
+ unless dock.is_a?(Array)
1141
+ raise malformed_response("the project's \"dock\" is #{MergeSafe.describe(dock)}, not an array",
1142
+ hint: DISCOVERY_HINT)
1143
+ end
1144
+
1145
+ dock.filter_map do |item|
1146
+ # A null element decodes to a ZERO item there, not to an error, and a
1147
+ # zero item's name is "" — so it is skipped for the same reason any
1148
+ # non-chat item is, rather than failing the read.
1149
+ next if item.nil?
1150
+
1151
+ unless item.is_a?(Hash)
1152
+ raise malformed_response("a dock item is #{MergeSafe.describe(item)}, not an object",
1153
+ hint: DISCOVERY_HINT)
1154
+ end
1155
+ # Read BEFORE the name filter, because the reference's dock item holds
1156
+ # a plain int64 id and a plain string name for EVERY item, whatever it
1157
+ # docks — so a malformed id on the schedule fails the read there while
1158
+ # a check placed after the filter would never see it. A rule that only
1159
+ # runs on the entries that survive an earlier filter is a rule about
1160
+ # this port's control flow rather than about the response.
1161
+ name = item["name"]
1162
+ unless name.nil? || name.is_a?(String)
1163
+ raise malformed_response("a dock item's name is #{MergeSafe.describe(name)}, not a string",
1164
+ hint: DISCOVERY_HINT)
1165
+ end
1166
+
1167
+ # Any NON-ZERO id, as the reference keeps, rather than any positive
1168
+ # one — a negative id is a candidate there and dropping it here would
1169
+ # search one Campfire fewer. An id of the wrong type is a decode
1170
+ # failure there, so it fails this read rather than skipping an entry.
1171
+ id = Ids.from_wire(item["id"])
1172
+ if id.nil?
1173
+ raise malformed_response("a dock item's id is #{MergeSafe.describe(item["id"])}, not an integer",
1174
+ hint: DISCOVERY_HINT)
1175
+ end
1176
+
1177
+ next unless name == "chat"
1178
+
1179
+ id.zero? ? nil : id
1180
+ end
1181
+ end
1182
+
1183
+ # The account-wide Campfire listing, grouped by bucket. BC3 has no
1184
+ # per-bucket listing, so the whole account is read and filtered. A listing
1185
+ # that overflows the cap is not cached and is reported as incomplete.
1186
+ def listed_campfire_ids_by_bucket
1187
+ campfires = @client.campfires.list(max_items: CampfireIndex::MAX_LISTING)
1188
+ listed = campfires.to_a
1189
+ if campfires.meta.truncated?
1190
+ # The reason states what was OBSERVED and not why. meta.truncated? is
1191
+ # set both by the max_items cap this call passes and by the client's
1192
+ # max_pages limit leaving a next page unfetched, and the reference
1193
+ # conflates the two in exactly the same way (client.go sets hasMore
1194
+ # for the page cap). Naming one cause reported a small two-page
1195
+ # listing under max_pages: 1 as "exceeds 1000".
1196
+ #
1197
+ # The VERDICT is the same either way, which is why this stays one
1198
+ # error: candidates were left unsearched, so the call is incomplete
1199
+ # rather than absent, and re-requesting repairs neither bound.
1200
+ raise CampfireIndex::ListingOverflow,
1201
+ "the account campfire listing was truncated before it was complete — either past the " \
1202
+ "#{CampfireIndex::MAX_LISTING}-item cap or past the client's max_pages limit"
1203
+ end
1204
+
1205
+ listed.each_with_object({}) do |campfire, by_bucket|
1206
+ campfire = read_record(campfire)
1207
+
1208
+ # The id is read BEFORE the bucket filter, for the reason the dock's is
1209
+ # read before its name filter: the reference decodes every listed
1210
+ # Campfire, so an id it cannot decode fails the listing whether or not
1211
+ # this call would have gone on to want that entry.
1212
+ #
1213
+ # No id FILTER at all, which is what the reference applies here — its
1214
+ # only guard on a listed Campfire is the BUCKET id below, so an id of
1215
+ # zero is a candidate too.
1216
+ campfire_id = Ids.from_wire(campfire["id"])
1217
+ if campfire_id.nil?
1218
+ raise malformed_response("a campfire's id is #{MergeSafe.describe(campfire["id"])}, not an integer",
1219
+ hint: DISCOVERY_HINT)
1220
+ end
1221
+
1222
+ bucket = campfire["bucket"]
1223
+ # An absent bucket is genuinely none and cannot be grouped; anything
1224
+ # else that is not an object is a decode failure there, and skipping it
1225
+ # would drop a candidate rather than report the body.
1226
+ next if bucket.nil?
1227
+
1228
+ unless bucket.is_a?(Hash)
1229
+ raise malformed_response("a campfire's \"bucket\" is #{MergeSafe.describe(bucket)}, not an object",
1230
+ hint: DISCOVERY_HINT)
1231
+ end
1232
+
1233
+ bucket_id = Ids.from_wire(bucket["id"])
1234
+ if bucket_id.nil?
1235
+ raise malformed_response("a campfire's bucket id is #{MergeSafe.describe(bucket["id"])}, not an integer",
1236
+ hint: DISCOVERY_HINT)
1237
+ end
1238
+ next if bucket_id.zero?
1239
+
1240
+ (by_bucket[bucket_id] ||= []) << campfire_id
1241
+ end
1242
+ end
1243
+
1244
+ def budget_exhausted(bucket_id, line_id)
1245
+ CampfireDiscoveryIncompleteError.new(
1246
+ bucket_id: bucket_id,
1247
+ recording_id: line_id,
1248
+ reason: "more than #{MAX_CAMPFIRE_CANDIDATES} visible campfires in the bucket"
1249
+ )
1250
+ end
1251
+
1252
+ # One {#summarize} call's discovery state: the candidates already tried,
1253
+ # what is left of the budget, and whether a candidate was left untried for
1254
+ # want of it.
1255
+ class ChatLineSearch
1256
+ # @return [Array<Integer>] candidates that answered 404, in order
1257
+ attr_reader :tried
1258
+
1259
+ def initialize(campfires:, line_id:, budget: MAX_CAMPFIRE_CANDIDATES)
1260
+ @campfires = campfires
1261
+ @line_id = line_id
1262
+ @budget = budget
1263
+ @tried = []
1264
+ @skipped = false
1265
+ end
1266
+
1267
+ # Reads the line under each candidate not yet tried.
1268
+ #
1269
+ # @param candidates [Array<Integer>]
1270
+ # @return [Array(Hash, Integer), nil] the line and its Campfire on a hit;
1271
+ # nil on a miss, with the candidates recorded in {#tried}
1272
+ # @raise [Basecamp::Error] any answer but 404, as that read's own error
1273
+ def try(candidates)
1274
+ Array(candidates).each do |campfire_id|
1275
+ next if @tried.include?(campfire_id)
1276
+
1277
+ if @budget <= 0
1278
+ @skipped = true
1279
+ return nil
1280
+ end
1281
+
1282
+ @budget -= 1
1283
+ begin
1284
+ line = @campfires.get_line(campfire_id: campfire_id, line_id: @line_id)
1285
+ return [ line, campfire_id ]
1286
+ rescue NotFoundError
1287
+ @tried << campfire_id
1288
+ end
1289
+ end
1290
+ nil
1291
+ end
1292
+
1293
+ # @return [Boolean] whether a candidate was left untried for want of budget
1294
+ def skipped?
1295
+ @skipped
1296
+ end
1297
+
1298
+ # @return [Boolean] whether this call may still try another candidate
1299
+ def budget_left?
1300
+ @budget.positive?
1301
+ end
1302
+ end
1303
+ end
1304
+ end
1305
+ end