plan_my_stuff 0.1.0 → 1.0.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -7,10 +7,115 @@ module PlanMyStuff
7
7
  # Provides shared persistence predicates and utility helpers.
8
8
  class ApplicationRecord
9
9
  include ActiveModel::Model
10
+ include ActiveModel::Attributes
11
+ include ActiveModel::Dirty
12
+ include ActiveModel::Serializers::JSON
13
+
14
+ # @return [Object, nil] raw GitHub API response this record was hydrated from.
15
+ # Escape hatch for consuming apps to access fields the gem doesn't expose.
16
+ attr_reader :github_response
17
+
18
+ class << self
19
+ # Reads a field from an object that may respond to method calls or hash access.
20
+ #
21
+ # @param obj [Object]
22
+ # @param field [Symbol]
23
+ #
24
+ # @return [Object]
25
+ #
26
+ def read_field(obj, field)
27
+ obj.respond_to?(field) ? obj.public_send(field) : obj[field]
28
+ end
29
+
30
+ private
31
+
32
+ # @param client [PlanMyStuff::Client]
33
+ #
34
+ # @return [Boolean]
35
+ #
36
+ def not_modified?(client)
37
+ response = client.last_response
38
+ return false if response.nil?
39
+
40
+ response.status == 304
41
+ end
42
+
43
+ # Captures the +ETag+ header from the most recent REST response and
44
+ # forwards it to +cache_writer+.
45
+ #
46
+ # @param client [PlanMyStuff::Client]
47
+ # @param repo [String]
48
+ # @param id [Integer, nil]
49
+ # @param body [Object] parsed GitHub response
50
+ # @param cache_writer [Symbol] +PlanMyStuff::Cache+ method name, e.g. +:write_issue+
51
+ #
52
+ # @return [void]
53
+ #
54
+ def store_etag_to_cache(client, repo, id, body, cache_writer:)
55
+ return if id.nil?
56
+
57
+ response = client.last_response
58
+ return if response.nil?
59
+
60
+ etag = response.headers && response.headers['etag']
61
+ return if etag.blank?
62
+
63
+ PlanMyStuff::Cache.public_send(cache_writer, repo, id, etag: etag, body: body)
64
+ end
65
+
66
+ # Captures the +ETag+ header from the most recent REST response and
67
+ # forwards it to +Cache.write_list+.
68
+ #
69
+ # @param client [PlanMyStuff::Client]
70
+ # @param resource [Symbol] :issue or :comment
71
+ # @param repo [String]
72
+ # @param params [Hash] query params that identify this list
73
+ # @param body [Object] parsed GitHub list response
74
+ #
75
+ # @return [void]
76
+ #
77
+ def store_list_etag_to_cache(client, resource, repo, params, body)
78
+ response = client.last_response
79
+ return if response.nil?
80
+
81
+ etag = response.headers && response.headers['etag']
82
+ return if etag.blank?
83
+
84
+ PlanMyStuff::Cache.write_list(resource, repo, params, etag: etag, body: body)
85
+ end
86
+
87
+ # Reads a REST resource through the ETag cache.
88
+ #
89
+ # On a cache hit sends +If-None-Match+; a 304 returns the cached
90
+ # body. A 200 stores the new ETag via +cache_writer+ and returns
91
+ # the fresh result.
92
+ #
93
+ # @param client [PlanMyStuff::Client]
94
+ # @param repo [String]
95
+ # @param id [Integer]
96
+ # @param rest_method [Symbol] Octokit method name
97
+ # @param cache_reader [Symbol] +PlanMyStuff::Cache+ method name, e.g. +:read_issue+
98
+ # @param cache_writer [Symbol] +PlanMyStuff::Cache+ method name, e.g. +:write_issue+
99
+ #
100
+ # @return [Object] parsed GitHub response
101
+ #
102
+ def fetch_with_etag_cache(client, repo, id, rest_method:, cache_reader:, cache_writer:)
103
+ cached = PlanMyStuff::Cache.public_send(cache_reader, repo, id)
104
+ options = cached ? { headers: { 'If-None-Match' => cached[:etag] } } : {}
105
+
106
+ result = client.rest(rest_method, repo, id, **options)
107
+
108
+ return cached[:body] if cached && not_modified?(client)
109
+
110
+ store_etag_to_cache(client, repo, id, result, cache_writer: cache_writer)
111
+ result
112
+ end
113
+ end
10
114
 
11
115
  def initialize(**)
12
116
  super
13
117
  @persisted = false
118
+ @destroyed = false
14
119
  end
15
120
 
16
121
  # @return [Boolean]
@@ -23,8 +128,37 @@ module PlanMyStuff
23
128
  !@persisted
24
129
  end
25
130
 
131
+ # @return [Boolean]
132
+ def destroyed?
133
+ @destroyed
134
+ end
135
+
26
136
  private
27
137
 
138
+ # Marks this record as persisted. Subclasses call this after a
139
+ # successful create/find/update against the underlying GitHub
140
+ # resource. Also applies any pending dirty changes so the record
141
+ # is clean after hydration and +#previous_changes+ reflects the
142
+ # values that were just loaded.
143
+ #
144
+ # @return [void]
145
+ #
146
+ def persisted!
147
+ @persisted = true
148
+ changes_applied
149
+ end
150
+
151
+ # Marks this record as destroyed. Subclasses call this from their
152
+ # +destroy!+ implementations after the underlying remote resource
153
+ # has been deleted.
154
+ #
155
+ # @return [void]
156
+ #
157
+ def destroyed!
158
+ @destroyed = true
159
+ @persisted = false
160
+ end
161
+
28
162
  # Reads a field from an object that may respond to method calls or hash access.
29
163
  #
30
164
  # @param obj [Object]
@@ -33,7 +167,30 @@ module PlanMyStuff
33
167
  # @return [Object]
34
168
  #
35
169
  def read_field(obj, field)
36
- obj.respond_to?(field) ? obj.public_send(field) : obj[field]
170
+ self.class.read_field(obj, field)
171
+ end
172
+
173
+ # Reads a field from an object, returning nil if the field does not exist.
174
+ #
175
+ # @param obj [Object]
176
+ # @param field [Symbol]
177
+ #
178
+ # @return [Object, nil]
179
+ #
180
+ def safe_read_field(obj, field)
181
+ read_field(obj, field)
182
+ rescue NameError
183
+ nil
184
+ end
185
+
186
+ # @return [Time, nil]
187
+ def parse_github_time(value)
188
+ return if value.nil?
189
+ return value.utc if value.is_a?(Time)
190
+
191
+ Time.parse(value.to_s).utc
192
+ rescue ArgumentError
193
+ nil
37
194
  end
38
195
  end
39
196
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module PlanMyStuff
6
+ # Value object representing a single manager approval on an +Issue+.
7
+ # Persisted in +IssueMetadata#approvals+ and returned from
8
+ # +Issue.request_approvals!+, +Issue.approve!+, +Issue.reject!+, and
9
+ # +Issue.revoke_approval!+.
10
+ #
11
+ # Mirrors +PlanMyStuff::Link+: +ActiveModel::Attributes+-backed, with
12
+ # +Serializers::JSON+ for round-trip through the metadata blob.
13
+ #
14
+ class Approval
15
+ STATUSES = %w[pending approved rejected].freeze
16
+
17
+ include ActiveModel::Model
18
+ include ActiveModel::Attributes
19
+ include ActiveModel::Serializers::JSON
20
+
21
+ # @return [Integer] app-side user id of the required approver
22
+ attribute :user_id, :integer
23
+ # @return [String] +"pending"+, +"approved"+, or +"rejected"+
24
+ attribute :status, :string, default: 'pending'
25
+ # @return [DateTime, nil] timestamp when status flipped to +"approved"+
26
+ attribute :approved_at, :datetime
27
+ # @return [DateTime, nil] timestamp when status flipped to +"rejected"+
28
+ attribute :rejected_at, :datetime
29
+
30
+ validates :user_id, presence: true, numericality: { greater_than: 0, only_integer: true }
31
+ validates :status, inclusion: { in: STATUSES }
32
+
33
+ # @return [Boolean]
34
+ def pending?
35
+ status == 'pending'
36
+ end
37
+
38
+ # @return [Boolean]
39
+ def approved?
40
+ status == 'approved'
41
+ end
42
+
43
+ # @return [Boolean]
44
+ def rejected?
45
+ status == 'rejected'
46
+ end
47
+
48
+ # Lazy-resolves the app-side user for this approval.
49
+ # Not memoized -- +PlanMyStuff::UserResolver+ owns caching.
50
+ #
51
+ # @return [Object, nil]
52
+ #
53
+ def user
54
+ PlanMyStuff::UserResolver.resolve(user_id)
55
+ end
56
+
57
+ # @return [Hash]
58
+ def to_h
59
+ {
60
+ user_id: user_id,
61
+ status: status,
62
+ approved_at: PlanMyStuff.format_time(approved_at),
63
+ rejected_at: PlanMyStuff.format_time(rejected_at),
64
+ }
65
+ end
66
+
67
+ # Two approvals are equal when they track the same user AND carry the
68
+ # same status. A pending and an approved record for the same user are
69
+ # NOT equal -- matters for set arithmetic during state transitions.
70
+ #
71
+ # @param other [Object]
72
+ #
73
+ # @return [Boolean]
74
+ #
75
+ def ==(other)
76
+ return false unless other.is_a?(PlanMyStuff::Approval)
77
+
78
+ user_id == other.user_id && status == other.status
79
+ end
80
+
81
+ alias eql? ==
82
+
83
+ # @return [Integer]
84
+ def hash
85
+ [user_id, status].hash
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Archive
5
+ # Walks a single repo's closed issues, archiving those that have
6
+ # aged past +config.archive_closed_after_days+. Excludes issues
7
+ # auto-closed by +Reminders::Closer+ (+metadata.closed_by_inactivity+)
8
+ # and any that are already archived (marker timestamp or label).
9
+ #
10
+ # Paginates +Issue.list(state: :closed)+ until either an empty page
11
+ # or the hard +MAX_PAGES+ cap. GitHub's +list_issues+ returns in
12
+ # created-desc order, so closed_at is not monotonic across pages;
13
+ # we can't short-circuit on "this page is all within cutoff." Each
14
+ # subsequent sweep skips already-archived issues cheaply via
15
+ # +skip?+, so the steady-state walk is bounded in practice.
16
+ class Sweep
17
+ PAGE_SIZE = 50
18
+ MAX_PAGES = 20
19
+
20
+ # @param repo [Symbol, String] repo key or full name
21
+ # @param now [Time] clock reference
22
+ #
23
+ def initialize(repo:, now: Time.now.utc)
24
+ @repo = repo
25
+ @now = now.utc
26
+ end
27
+
28
+ # Runs the sweep. No-op when +config.archiving_enabled+ is false.
29
+ #
30
+ # @return [void]
31
+ #
32
+ def call
33
+ return unless PlanMyStuff.configuration.archiving_enabled
34
+
35
+ (1..MAX_PAGES).each do |page|
36
+ issues = PlanMyStuff::Issue.list(
37
+ repo: @repo,
38
+ state: :closed,
39
+ page: page,
40
+ per_page: PAGE_SIZE,
41
+ )
42
+
43
+ break if issues.empty?
44
+
45
+ process(issues)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Per-issue exceptions are swallowed so a single bad archive
52
+ # doesn't halt the whole sweep.
53
+ #
54
+ # @param issues [Array<PlanMyStuff::Issue>]
55
+ #
56
+ # @return [void]
57
+ #
58
+ def process(issues)
59
+ issues.each do |issue|
60
+ next if skip?(issue)
61
+
62
+ issue.archive!(now: @now)
63
+ rescue => e
64
+ warn("[PlanMyStuff::Archive::Sweep] #{issue.repo}##{issue.number} failed: #{e.class}: #{e.message}")
65
+ end
66
+ end
67
+
68
+ # @param issue [PlanMyStuff::Issue]
69
+ #
70
+ # @return [Boolean]
71
+ #
72
+ def skip?(issue)
73
+ return true unless issue.pms_issue?
74
+
75
+ return true if issue.metadata.closed_by_inactivity
76
+ return true if issue.metadata.archived_at.present?
77
+ return true if issue.labels.include?(PlanMyStuff.configuration.archived_label)
78
+ return true if issue.closed_at.nil?
79
+
80
+ age_days = (@now - issue.closed_at.utc) / 1.day
81
+ age_days < PlanMyStuff.configuration.archive_closed_after_days
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Auto-archive engine. The +Sweep+ class walks closed issues in a repo
5
+ # and delegates to +Issue#archive!+ for each issue whose +closed_at+
6
+ # has aged past +config.archive_closed_after_days+.
7
+ #
8
+ # Entry point for the sweep lives in +RemindersSweepJob+; this module
9
+ # holds the POROs so they can be unit-tested without ActiveJob.
10
+ module Archive
11
+ end
12
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+ require 'tmpdir'
5
+
6
+ module PlanMyStuff
7
+ # Value object representing a single attachment record on a +Comment+.
8
+ # Persisted in +CommentMetadata#attachments+; the gem owns the upload
9
+ # (see +PlanMyStuff::AttachmentUploader+) and stores the structured
10
+ # location (+owner+/+repo+/+sha+/+path+) of the uploaded file so the
11
+ # blob remains addressable across later branch rewrites.
12
+ #
13
+ # Mirrors +PlanMyStuff::Link+ / +PlanMyStuff::Approval+:
14
+ # +ActiveModel::Attributes+-backed, with +Serializers::JSON+ for
15
+ # round-trip through the metadata blob.
16
+ #
17
+ class Attachment
18
+ include ActiveModel::Model
19
+ include ActiveModel::Attributes
20
+ include ActiveModel::Serializers::JSON
21
+
22
+ # @return [String] display filename (user-provided original name)
23
+ attribute :filename, :string
24
+ # @return [String] owner of the attachment repo (e.g. +"BrandsInsurance"+)
25
+ attribute :owner, :string
26
+ # @return [String] attachment repo name
27
+ attribute :repo, :string
28
+ # @return [String] commit SHA pinning the file
29
+ attribute :sha, :string
30
+ # @return [String] path within the attachment repo (no leading slash)
31
+ attribute :path, :string
32
+
33
+ validates :filename, :owner, :repo, :sha, :path, presence: true
34
+
35
+ # @return [String] blob-viewer URL for the pinned file
36
+ def url
37
+ "https://github.com/#{owner}/#{repo}/blob/#{sha}/#{path}"
38
+ end
39
+
40
+ # @return [Hash]
41
+ def to_h
42
+ { filename: filename, owner: owner, repo: repo, sha: sha, path: path }
43
+ end
44
+
45
+ # @param other [Object]
46
+ #
47
+ # @return [Boolean]
48
+ #
49
+ def ==(other)
50
+ return false unless other.is_a?(PlanMyStuff::Attachment)
51
+
52
+ filename == other.filename && url == other.url
53
+ end
54
+
55
+ alias eql? ==
56
+
57
+ # @return [Integer]
58
+ def hash
59
+ [filename, url].hash
60
+ end
61
+
62
+ # Downloads the file via the GitHub Contents API (so it works on private repos) and writes the bytes to disk.
63
+ # Defaults the destination to +Dir.tmpdir+ joined with +File.basename(filename)+ (directory components
64
+ # stripped to prevent path traversal).
65
+ #
66
+ # @param dest [String, nil] destination path; defaults to +File.join(Dir.tmpdir, File.basename(filename))+
67
+ #
68
+ # @return [String] the path the bytes were written to
69
+ #
70
+ def download_to(dest = nil)
71
+ dest ||= File.join(Dir.tmpdir, File.basename(filename.to_s))
72
+ body = PlanMyStuff.client.rest(
73
+ :contents,
74
+ "#{owner}/#{repo}",
75
+ path: path,
76
+ ref: sha,
77
+ accept: 'application/vnd.github.raw',
78
+ )
79
+ File.binwrite(dest, body)
80
+ dest
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'pathname'
5
+ require 'securerandom'
6
+
7
+ module PlanMyStuff
8
+ # Uploads attachment files to a shared attachment repo (+config.attachment_repo+ under +config.organization+) using
9
+ # GitHub's Git Data API, returning +PlanMyStuff::Attachment+ instances populated with the structured
10
+ # +owner+/+repo+/+sha+/+path+ location of each uploaded file.
11
+ #
12
+ # One commit per batch (atomic): all files in a single +upload_all!+ call land in the same tree/commit so partial
13
+ # failures cannot leave +config.main_branch+ in an inconsistent state.
14
+ #
15
+ # The attachment repo is assumed to exist; the uploader does not create it. Per-source-repo attachments are namespaced
16
+ # under +<repo_key>/issue-<number>/<uuid>.<ext>+, where +repo_key+ falls back to +repo.name+ for repos resolved
17
+ # without a +config.repos+ entry.
18
+ #
19
+ # @see PlanMyStuff::Attachment
20
+ #
21
+ class AttachmentUploader
22
+ class << self
23
+ # Uploads each entry in +files+ to the attachment repo and returns the resulting +Attachment+ instances. Order is
24
+ # not preserved: passthrough +Attachment+ entries are returned before newly-uploaded ones. +Attachment+ entries
25
+ # are passed through untouched (no upload), supporting round-trip through +Comment#save!+ without
26
+ # double-uploading.
27
+ #
28
+ # Empty / nil +files+ short-circuits with no API calls.
29
+ #
30
+ # @param repo [PlanMyStuff::Repo] source issues repo (used to namespace the path)
31
+ # @param issue_number [Integer]
32
+ # @param files [Array] mix of already-uploaded +PlanMyStuff::Attachment+ instances, uploaded-file objects
33
+ # responding to +#path+ and +#original_filename+ (e.g. Rails +ActionDispatch::Http::UploadedFile+), and
34
+ # String/Pathname paths to local files
35
+ #
36
+ # @return [Array<PlanMyStuff::Attachment>]
37
+ #
38
+ def upload_all!(repo:, issue_number:, files:)
39
+ entries = Array.wrap(files)
40
+ return [] if entries.empty?
41
+
42
+ slots = entries.map { |entry| classify(entry) }
43
+ attachments = slots.grep(PlanMyStuff::Attachment)
44
+ pending = slots.grep(Hash)
45
+
46
+ return attachments if pending.blank?
47
+
48
+ commit_sha = create_commit!(repo: repo, issue_number: issue_number, pending: pending)
49
+
50
+ pending.each do |slot|
51
+ slot[:attachment] = build_attachment(commit_sha: commit_sha, slot: slot)
52
+ end
53
+
54
+ publish_commit!(commit_sha)
55
+
56
+ attachments + pending.pluck(:attachment)
57
+ end
58
+
59
+ private
60
+
61
+ # Normalizes a single input entry into a slot Hash or itself (an existing Attachment);
62
+ # pending slots carry +{filename:, content:}+ awaiting upload.
63
+ #
64
+ # @param entry [Object]
65
+ #
66
+ # @return [PlanMyStuff::Attachment, Hash]
67
+ #
68
+ def classify(entry)
69
+ if entry.is_a?(PlanMyStuff::Attachment)
70
+ entry
71
+ else
72
+ filename, content = extract_filename_and_content(entry)
73
+ { filename: filename, content: content }
74
+ end
75
+ end
76
+
77
+ # @param entry [Object]
78
+ #
79
+ # @return [Array(String, String)] filename, raw bytes
80
+ #
81
+ def extract_filename_and_content(entry)
82
+ if entry.respond_to?(:path) && entry.respond_to?(:original_filename)
83
+ [leaf_filename(entry.original_filename), File.binread(entry.path)]
84
+ elsif entry.is_a?(String) || entry.is_a?(Pathname)
85
+ [File.basename(entry), File.binread(entry)]
86
+ else
87
+ raise(ArgumentError, "Unsupported attachment entry: #{entry.inspect}")
88
+ end
89
+ end
90
+
91
+ # Strips directory components (both POSIX and Windows separators) from a browser-supplied filename so
92
+ # values like +"../../secret.txt"+ or +"C:\\fakepath\\photo.jpg"+ cannot leak into stored metadata or
93
+ # later download paths.
94
+ #
95
+ # @param raw [Object] +original_filename+ from an uploaded-file-like object
96
+ #
97
+ # @return [String]
98
+ #
99
+ def leaf_filename(raw)
100
+ name = raw.to_s.tr('\\', '/').split('/').last
101
+ raise(ArgumentError, 'Attachment filename cannot be blank') if name.blank?
102
+
103
+ name
104
+ end
105
+
106
+ # Returns the path-segment used to namespace this source repo's attachments inside the shared attachment repo.
107
+ # Prefers +repo.key+ (registered repos); falls back to +repo.name+ for repos resolved by full name only.
108
+ #
109
+ # @param repo [PlanMyStuff::Repo]
110
+ #
111
+ # @return [String]
112
+ #
113
+ def namespace_for(repo)
114
+ (repo.key || repo.name).to_s
115
+ end
116
+
117
+ # Performs the Git Data API dance against the attachment repo and returns the new commit SHA without
118
+ # publishing it (the ref still points at the old head). Callers must invoke +publish_commit!+ after the
119
+ # batch has been converted into +Attachment+ instances so a failure during conversion leaves
120
+ # +config.main_branch+ untouched. Assumes the attachment repo and +config.main_branch+ exist.
121
+ #
122
+ # @param repo [PlanMyStuff::Repo] source issues repo
123
+ # @param issue_number [Integer]
124
+ # @param pending [Array<Hash>] slots awaiting upload
125
+ #
126
+ # @return [String] new commit SHA
127
+ #
128
+ def create_commit!(repo:, issue_number:, pending:)
129
+ client = PlanMyStuff.client
130
+ head_sha, base_tree_sha = fetch_head_and_tree(client)
131
+
132
+ assign_paths!(pending, namespace_for(repo), issue_number)
133
+ tree_items = pending.map { |slot| blob_tree_item(client, slot) }
134
+
135
+ new_tree = client.rest(:create_tree, attachment_repo_full_name, tree_items, base_tree: base_tree_sha)
136
+ new_tree_sha = dig_sha(new_tree, :sha)
137
+
138
+ new_commit = client.rest(
139
+ :create_commit,
140
+ attachment_repo_full_name,
141
+ "task: Add attachments for #{repo.full_name}##{issue_number}",
142
+ new_tree_sha,
143
+ [head_sha],
144
+ )
145
+ dig_sha(new_commit, :sha)
146
+ end
147
+
148
+ # Updates +config.main_branch+ to point at +commit_sha+. Called as the last step of +upload_all!+ so any
149
+ # earlier failure leaves the attachment repo unchanged.
150
+ #
151
+ # @param commit_sha [String]
152
+ #
153
+ # @return [void]
154
+ #
155
+ def publish_commit!(commit_sha)
156
+ PlanMyStuff.client.rest(:update_ref, attachment_repo_full_name, ref, commit_sha)
157
+ end
158
+
159
+ # @param client [PlanMyStuff::Client]
160
+ #
161
+ # @return [Array(String, String)] [head_sha, tree_sha]
162
+ #
163
+ def fetch_head_and_tree(client)
164
+ ref_response = client.rest(:ref, attachment_repo_full_name, ref)
165
+ head_sha = dig_sha(ref_response, :object, :sha)
166
+ commit_response = client.rest(:commit, attachment_repo_full_name, head_sha)
167
+ tree_sha = dig_sha(commit_response, :commit, :tree, :sha)
168
+ [head_sha, tree_sha]
169
+ end
170
+
171
+ # @param pending [Array<Hash>]
172
+ # @param namespace [String]
173
+ # @param issue_number [Integer]
174
+ #
175
+ # @return [void]
176
+ #
177
+ def assign_paths!(pending, namespace, issue_number)
178
+ pending.each do |slot|
179
+ uuid = SecureRandom.uuid
180
+ ext = File.extname(slot[:filename]).delete_prefix('.').downcase
181
+ basename = ext.empty? ? uuid : "#{uuid}.#{ext}"
182
+ slot[:basename] = basename
183
+ slot[:path] = "#{namespace}/issue-#{issue_number}/#{basename}"
184
+ end
185
+ end
186
+
187
+ # @param client [PlanMyStuff::Client]
188
+ # @param slot [Hash]
189
+ #
190
+ # @return [Hash] tree item ready for create_tree
191
+ #
192
+ def blob_tree_item(client, slot)
193
+ blob_sha = client.rest(
194
+ :create_blob, attachment_repo_full_name, Base64.strict_encode64(slot[:content]), 'base64',
195
+ )
196
+ { path: slot[:path], mode: '100644', type: 'blob', sha: blob_sha }
197
+ end
198
+
199
+ # @param commit_sha [String]
200
+ # @param slot [Hash]
201
+ #
202
+ # @return [PlanMyStuff::Attachment]
203
+ #
204
+ def build_attachment(commit_sha:, slot:)
205
+ PlanMyStuff::Attachment.new(
206
+ filename: slot[:filename],
207
+ owner: PlanMyStuff.configuration.organization,
208
+ repo: PlanMyStuff.configuration.attachment_repo,
209
+ sha: commit_sha,
210
+ path: slot[:path],
211
+ )
212
+ end
213
+
214
+ # @return [String] +"<organization>/<attachment_repo>"+
215
+ def attachment_repo_full_name
216
+ "#{PlanMyStuff.configuration.organization}/#{PlanMyStuff.configuration.attachment_repo}"
217
+ end
218
+
219
+ # @return [String] Git ref for the configured main branch (e.g. +"heads/main"+)
220
+ def ref
221
+ "heads/#{PlanMyStuff.configuration.main_branch}"
222
+ end
223
+
224
+ # Walks +keys+ through +obj+, tolerating Sawyer resources (method accessors), Hash with Symbol keys, and
225
+ # Hash with String keys.
226
+ #
227
+ # @param obj [Object]
228
+ # @param keys [Array<Symbol>]
229
+ #
230
+ # @return [Object, nil]
231
+ #
232
+ def dig_sha(obj, *keys)
233
+ keys.reduce(obj) do |acc, key|
234
+ break if acc.nil?
235
+
236
+ if acc.respond_to?(key)
237
+ acc.public_send(key)
238
+ elsif acc.respond_to?(:[])
239
+ acc[key] || acc[key.to_s]
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end