plan_my_stuff 0.3.0 → 0.4.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +569 -38
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
  5. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
  6. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
  7. data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
  9. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
  10. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
  11. data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
  12. data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
  13. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
  14. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
  15. data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
  16. data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
  17. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
  19. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
  20. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
  21. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
  22. data/app/jobs/plan_my_stuff/application_job.rb +9 -0
  23. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
  24. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
  25. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
  26. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
  27. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
  28. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
  30. data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
  31. data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
  32. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  33. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  34. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  35. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  36. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  37. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  38. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  39. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  40. data/config/routes.rb +38 -15
  41. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  42. data/lib/plan_my_stuff/application_record.rb +121 -0
  43. data/lib/plan_my_stuff/approval.rb +80 -0
  44. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  45. data/lib/plan_my_stuff/archive.rb +14 -0
  46. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  47. data/lib/plan_my_stuff/base_project.rb +661 -0
  48. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  49. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  50. data/lib/plan_my_stuff/cache.rb +197 -0
  51. data/lib/plan_my_stuff/client.rb +7 -0
  52. data/lib/plan_my_stuff/comment.rb +171 -50
  53. data/lib/plan_my_stuff/configuration.rb +210 -10
  54. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  55. data/lib/plan_my_stuff/engine.rb +0 -4
  56. data/lib/plan_my_stuff/errors.rb +49 -0
  57. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  58. data/lib/plan_my_stuff/issue.rb +1476 -175
  59. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  60. data/lib/plan_my_stuff/label.rb +82 -11
  61. data/lib/plan_my_stuff/link.rb +144 -0
  62. data/lib/plan_my_stuff/notifications.rb +142 -0
  63. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  64. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  65. data/lib/plan_my_stuff/pipeline.rb +293 -0
  66. data/lib/plan_my_stuff/project.rb +30 -693
  67. data/lib/plan_my_stuff/project_item.rb +3 -417
  68. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  69. data/lib/plan_my_stuff/project_metadata.rb +9 -3
  70. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  71. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  72. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  73. data/lib/plan_my_stuff/reminders.rb +16 -0
  74. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  75. data/lib/plan_my_stuff/testing_project.rb +291 -0
  76. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  77. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  78. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  79. data/lib/plan_my_stuff/version.rb +1 -1
  80. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  81. data/lib/plan_my_stuff.rb +15 -0
  82. data/lib/tasks/plan_my_stuff.rake +163 -0
  83. metadata +50 -2
@@ -1,6 +1,11 @@
1
1
  <h1><%= @project.title %></h1>
2
2
 
3
- <p><%= link_to('Edit', plan_my_stuff.edit_project_path(@project.number)) %></p>
3
+ <p>
4
+ <%= link_to('Edit', plan_my_stuff.edit_project_path(@project.number)) %>
5
+ <% if @support_user && @project.url.present? %>
6
+ <%= link_to('View on GitHub', @project.url, target: '_blank', rel: 'noopener') %>
7
+ <% end %>
8
+ </p>
4
9
 
5
10
  <% if @statuses.any? %>
6
11
  <table>
@@ -29,7 +34,7 @@
29
34
 
30
35
  <%=
31
36
  form_with(
32
- url: plan_my_stuff.move_project_item_path(@project.number, item.id),
37
+ url: plan_my_stuff.project_item_status_path(@project.number, item.id),
33
38
  method: :patch,
34
39
  local: true,
35
40
  ) do |form|
@@ -45,8 +50,8 @@
45
50
  <%= username %>
46
51
  <%=
47
52
  form_with(
48
- url: plan_my_stuff.unassign_project_item_path(@project.number, item.id),
49
- method: :patch,
53
+ url: plan_my_stuff.project_item_assignment_path(@project.number, item.id),
54
+ method: :delete,
50
55
  local: true,
51
56
  html: { style: 'display: inline' },
52
57
  ) do |form|
@@ -61,7 +66,7 @@
61
66
 
62
67
  <%=
63
68
  form_with(
64
- url: plan_my_stuff.assign_project_item_path(@project.number, item.id),
69
+ url: plan_my_stuff.project_item_assignment_path(@project.number, item.id),
65
70
  method: :patch,
66
71
  local: true,
67
72
  ) do |form|
@@ -0,0 +1,12 @@
1
+ <h1>Add Item to <%= @project.title %></h1>
2
+
3
+ <% if flash[:error].present? %>
4
+ <p style="color: red;"><%= flash[:error] %></p>
5
+ <% end %>
6
+
7
+ <%=
8
+ render({
9
+ partial: 'plan_my_stuff/testing_projects/partials/items/form',
10
+ locals: { project: @project },
11
+ })
12
+ %>
@@ -0,0 +1,22 @@
1
+ <h1>Fail Item</h1>
2
+
3
+ <% if flash[:error].present? %>
4
+ <p style="color: red;"><%= flash[:error] %></p>
5
+ <% end %>
6
+
7
+ <%= form_with(
8
+ url: plan_my_stuff.testing_project_item_result_path(@project_number, @item_id),
9
+ method: :post,
10
+ local: true,
11
+ ) do |form| %>
12
+ <%= form.hidden_field(:result, value: 'fail') %>
13
+
14
+ <div>
15
+ <%= form.label(:result_notes, 'Result Notes') %>
16
+ <%= form.text_area(:result_notes, rows: 5, required: true) %>
17
+ </div>
18
+
19
+ <div>
20
+ <%= form.submit('Submit Failure') %>
21
+ </div>
22
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <h1>Edit Testing Project #<%= @project.number %></h1>
2
+
3
+ <% if flash[:error].present? %>
4
+ <p style="color: red;"><%= flash[:error] %></p>
5
+ <% end %>
6
+
7
+ <%= render({ partial: 'plan_my_stuff/testing_projects/partials/form', locals: { project: @project } }) %>
@@ -0,0 +1,7 @@
1
+ <h1>New Testing Project</h1>
2
+
3
+ <% if flash[:error].present? %>
4
+ <p style="color: red;"><%= flash[:error] %></p>
5
+ <% end %>
6
+
7
+ <%= render({ partial: 'plan_my_stuff/testing_projects/partials/form', locals: { project: @project } }) %>
@@ -0,0 +1,39 @@
1
+ <%
2
+ persisted = project.persisted?
3
+ url =
4
+ if persisted
5
+ plan_my_stuff.testing_project_path(project.number)
6
+ else
7
+ plan_my_stuff.testing_projects_path
8
+ end
9
+ %>
10
+ <%= form_with(url: url, method: persisted ? :patch : :post, scope: :testing_project) do |form| %>
11
+ <div>
12
+ <%= form.label(:title, 'Title') %>
13
+ <%= form.text_field(:title, value: project.title, required: true) %>
14
+ </div>
15
+
16
+ <div>
17
+ <%= form.label(:description, 'Description') %>
18
+ <%= form.text_field(:description, value: project.description) %>
19
+ </div>
20
+
21
+ <div>
22
+ <%= form.label(:subject_urls, 'Subject URLs (one per line)') %>
23
+ <%= form.text_area(:subject_urls, rows: 4, value: project.metadata.subject_urls.join("\n")) %>
24
+ </div>
25
+
26
+ <div>
27
+ <%= form.label(:due_date, 'Due Date') %>
28
+ <%= form.date_field(:due_date, value: project.metadata.due_date) %>
29
+ </div>
30
+
31
+ <div>
32
+ <%= form.label(:deadline_miss_reason, 'Deadline Miss Reason') %>
33
+ <%= form.text_field(:deadline_miss_reason, value: project.metadata.deadline_miss_reason) %>
34
+ </div>
35
+
36
+ <div>
37
+ <%= form.submit(persisted ? 'Update Testing Project' : 'Create Testing Project') %>
38
+ </div>
39
+ <% end %>
@@ -0,0 +1,51 @@
1
+ <div style="border: 1px solid black; margin: 1em; padding: 0.5em">
2
+ <% if item.draft? %>
3
+ <strong><%= item.title %></strong>
4
+ <% else %>
5
+ <strong><%= link_to(item.title, plan_my_stuff.issue_path(item.number, repo: item.repo.full_name)) %></strong>
6
+ <small>#<%= item.number %></small>
7
+ <% end %>
8
+
9
+ <%=
10
+ form_with(
11
+ url: plan_my_stuff.testing_project_item_status_path(project.number, item.id),
12
+ method: :patch,
13
+ local: true,
14
+ ) do |form|
15
+ %>
16
+ <%= form.select(:status, statuses, { selected: item.status }, { onchange: 'this.form.requestSubmit();' }) %>
17
+ <% end %>
18
+
19
+ <% if item.field_values['Testers'].present? %>
20
+ <div><strong>Testers:</strong> <%= item.field_values['Testers'] %></div>
21
+ <% end %>
22
+
23
+ <% if item.field_values['Watchers'].present? %>
24
+ <div><strong>Watchers:</strong> <%= item.field_values['Watchers'] %></div>
25
+ <% end %>
26
+
27
+ <% if item.field_values['Due Date'].present? %>
28
+ <div><strong>Due:</strong> <%= item.field_values['Due Date'] %></div>
29
+ <% end %>
30
+
31
+ <% if item.field_values['Result Notes'].present? %>
32
+ <div><strong>Notes:</strong> <%= item.field_values['Result Notes'] %></div>
33
+ <% end %>
34
+
35
+ <% if item.field_values['Passed At'].present? %>
36
+ <div><strong>Passed at:</strong> <%= item.field_values['Passed At'] %></div>
37
+ <% end %>
38
+
39
+ <div>
40
+ <%=
41
+ button_to(
42
+ 'Pass',
43
+ plan_my_stuff.testing_project_item_result_path(project.number, item.id),
44
+ method: :post,
45
+ params: { result: 'pass' },
46
+ )
47
+ %>
48
+
49
+ <%= link_to('Fail', plan_my_stuff.new_testing_project_item_result_path(project.number, item.id)) %>
50
+ </div>
51
+ </div>
@@ -0,0 +1,35 @@
1
+ <%= form_with(url: plan_my_stuff.testing_project_items_path(project.number), method: :post, local: true) do |form| %>
2
+ <div>
3
+ <%= form.label(:title, 'Title') %>
4
+ <%= form.text_field(:title, required: true) %>
5
+ </div>
6
+
7
+ <div>
8
+ <%= form.label(:body, 'Body') %>
9
+ <%= form.text_area(:body, rows: 4) %>
10
+ </div>
11
+
12
+ <div>
13
+ <%= form.label(:testers, 'Testers (user ids, comma separated)') %>
14
+ <%= form.text_field(:testers) %>
15
+ </div>
16
+
17
+ <div>
18
+ <%= form.label(:watchers, 'Watchers (user ids, comma separated)') %>
19
+ <%= form.text_field(:watchers) %>
20
+ </div>
21
+
22
+ <div>
23
+ <%= form.label(:pass_mode, 'Pass Mode') %>
24
+ <%= form.select(:pass_mode, [['All testers must pass', 'all'], ['Any tester can pass', 'any']], { selected: 'all' }) %>
25
+ </div>
26
+
27
+ <div>
28
+ <%= form.label(:due_date, 'Due Date') %>
29
+ <%= form.date_field(:due_date) %>
30
+ </div>
31
+
32
+ <div>
33
+ <%= form.submit('Add Item') %>
34
+ </div>
35
+ <% end %>
@@ -0,0 +1,65 @@
1
+ <h1><%= @project.title %></h1>
2
+
3
+ <p>
4
+ <%= link_to('Edit', plan_my_stuff.edit_testing_project_path(@project.number)) %>
5
+ <% if @support_user && @project.url.present? %>
6
+ <%= link_to('View on GitHub', @project.url, target: '_blank', rel: 'noopener') %>
7
+ <% end %>
8
+ </p>
9
+
10
+ <% if @support_user && @project.metadata.subject_urls.any? %>
11
+ <p>
12
+ <strong>Subject URLs:</strong>
13
+ <% @project.metadata.subject_urls.each do |url| %>
14
+ <%= link_to(url, url, target: '_blank', rel: 'noopener') %>
15
+ <% end %>
16
+ </p>
17
+ <% end %>
18
+
19
+ <% if @project.metadata.due_date.present? %>
20
+ <p><strong>Due:</strong> <%= @project.metadata.due_date %></p>
21
+ <% end %>
22
+
23
+ <% if @statuses.any? %>
24
+ <table>
25
+ <thead>
26
+ <tr>
27
+ <% @statuses.each do |status| %>
28
+ <th><%= status %></th>
29
+ <% end %>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <tr>
34
+ <% @statuses.each do |status| %>
35
+ <td>
36
+ <% items = @items_by_status[status] || [] %>
37
+ <% items.each do |item| %>
38
+ <%= render({
39
+ partial: 'plan_my_stuff/testing_projects/partials/item',
40
+ locals: { item: item, project: @project, statuses: @statuses },
41
+ }) %>
42
+ <% end %>
43
+ <% if items.empty? %>
44
+ <em>No items</em>
45
+ <% end %>
46
+ </td>
47
+ <% end %>
48
+ </tr>
49
+ </tbody>
50
+ </table>
51
+ <% else %>
52
+ <p>No statuses configured for this project.</p>
53
+ <% end %>
54
+
55
+ <h2>Add Item</h2>
56
+
57
+ <p><%= link_to('Add item with details', plan_my_stuff.new_testing_project_item_path(@project.number)) %></p>
58
+
59
+ <%= form_with(url: plan_my_stuff.testing_project_items_path(@project.number), method: :post, local: true) do |form| %>
60
+ <%= form.label(:title, 'Title') %>
61
+ <%= form.text_field(:title, required: true) %>
62
+ <%= form.label(:body, 'Body') %>
63
+ <%= form.text_area(:body) %>
64
+ <%= form.submit('Add Item') %>
65
+ <% end %>
data/config/routes.rb CHANGED
@@ -1,24 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  PlanMyStuff::Engine.routes.draw do
4
- resources :issues, except: %i[destroy] do
5
- member do
6
- patch :close
7
- patch :reopen
8
- post :add_viewers
9
- delete :remove_viewer
4
+ config = PlanMyStuff.configuration
5
+ mount_groups = config.mount_groups
6
+
7
+ if mount_groups.fetch(:issues, true)
8
+ resources :issues, except: %i[destroy], controller: config.controller_for(:issues) do
9
+ resource :closure, only: %i[create destroy], controller: config.controller_for(:'issues/closures')
10
+ resource :waiting, only: %i[create destroy], controller: config.controller_for(:'issues/waitings')
11
+ resources :viewers, only: %i[create destroy], controller: config.controller_for(:'issues/viewers')
12
+ if config.pipeline_enabled
13
+ resource(
14
+ :take,
15
+ only: :create,
16
+ controller: config.controller_for(:'issues/takes'),
17
+ )
18
+ end
19
+ resources :comments, only: %i[create edit update], controller: config.controller_for(:comments)
20
+ resources :labels, only: %i[create destroy], controller: config.controller_for(:labels)
21
+ resources :links, only: %i[create destroy], controller: config.controller_for(:'issues/links')
22
+ resources :approvals, only: %i[create update destroy], controller: config.controller_for(:'issues/approvals')
23
+ end
24
+ end
25
+
26
+ if mount_groups.fetch(:webhooks, true) && config.pipeline_enabled
27
+ namespace :webhooks do
28
+ resource :github, only: %i[create], controller: config.controller_for(:'webhooks/github')
29
+ resource :aws, only: %i[create], controller: config.controller_for(:'webhooks/aws')
10
30
  end
11
- resources :comments, only: %i[create edit update]
12
- post 'labels', to: 'labels#add_to_issue', as: :add_label
13
- delete 'labels/:name', to: 'labels#remove_from_issue', as: :remove_label
14
31
  end
15
32
 
16
- resources :projects, except: %i[destroy] do
17
- resources :items, only: %i[create], controller: 'project_items' do
18
- member do
19
- patch :move
20
- patch :assign
21
- patch :unassign
33
+ if mount_groups.fetch(:projects, true)
34
+ resources :projects, except: %i[destroy], controller: config.controller_for(:projects) do
35
+ resources :items, only: %i[create], controller: config.controller_for(:project_items) do
36
+ resource :status, only: %i[update], controller: config.controller_for(:'project_items/statuses')
37
+ resource :assignment, only: %i[update destroy], controller: config.controller_for(:'project_items/assignments')
38
+ end
39
+ end
40
+
41
+ resources :testing_projects, except: %i[destroy index], controller: config.controller_for(:testing_projects) do
42
+ resources :items, only: %i[new create], controller: config.controller_for(:testing_project_items) do
43
+ resource :status, only: %i[update], controller: config.controller_for(:'project_items/statuses')
44
+ resource :result, only: %i[new create], controller: config.controller_for(:'testing_project_items/results')
22
45
  end
23
46
  end
24
47
  end
@@ -26,6 +26,13 @@ PMS.configure do |config|
26
26
  # Default GitHub Projects V2 number for add_to_project calls.
27
27
  # config.default_project_number = 14
28
28
 
29
+ # Template project to clone when creating new Testing Projects.
30
+ # When set, TestingProject.create! copies this project (preserving its
31
+ # custom fields and board layout) instead of bootstrapping fields from scratch.
32
+ # The template should be a pre-configured testing project with the correct
33
+ # fields and board view already set up in the GitHub UI.
34
+ # config.testing_template_project_number = 42
35
+
29
36
  # --------------------------------------------------------------------------
30
37
  # App identity
31
38
  # --------------------------------------------------------------------------
@@ -85,11 +92,9 @@ PMS.configure do |config|
85
92
  # update_status: 'PmsUpdateStatusJob'
86
93
  # }
87
94
 
88
- # Custom notifier for deferred requests (proc/class), or nil to use
89
- # the built-in DeferredMailer.
90
- # config.deferred_notifier = nil
91
- # config.deferred_email_from = 'noreply@example.com'
92
- # config.deferred_email_to = 'it-support@example.com'
95
+ # Fallback actor for notification events (plan_my_stuff.*) when a caller
96
+ # does not pass an explicit user: kwarg. Proc/lambda called at event time.
97
+ # config.current_user = -> { Current.user }
93
98
 
94
99
  # --------------------------------------------------------------------------
95
100
  # Custom fields
@@ -112,4 +117,132 @@ PMS.configure do |config|
112
117
 
113
118
  # Comment-only fields (merged on top of shared, context wins on conflicts):
114
119
  # config.comment_custom_fields = {}
120
+
121
+ # --------------------------------------------------------------------------
122
+ # Release pipeline
123
+ # --------------------------------------------------------------------------
124
+ # config.pipeline_enabled = true
125
+ # config.pipeline_project_number = 14
126
+
127
+ # GitHub webhook HMAC-SHA256 secret (required when webhook routes are mounted).
128
+ # config.webhook_secret = Rails.application.credentials.dig(:plan_my_stuff, :webhook_secret)
129
+
130
+ # Configurable display aliases for pipeline statuses.
131
+ # config.pipeline_statuses = {
132
+ # 'Submitted' => 'Triaged',
133
+ # 'Completed' => 'Done',
134
+ # }
135
+
136
+ # config.main_branch = 'main'
137
+ # config.production_branch = 'production'
138
+
139
+ # --------------------------------------------------------------------------
140
+ # Follow-up reminders
141
+ # --------------------------------------------------------------------------
142
+ # Daily sweep that fires `plan_my_stuff.issue.reminder_due` events on
143
+ # waiting issues and auto-closes issues that exceed the inactivity
144
+ # ceiling. Consuming app is responsible for the initial enqueue:
145
+ #
146
+ # # Enqueue one sweep job per configured repo:
147
+ # rake plan_my_stuff:reminders:sweep
148
+ #
149
+ # # Or a single repo:
150
+ # rake plan_my_stuff:reminders:sweep REPO=element
151
+ #
152
+ # Each job self-requeues after perform (default: 6:30am ET next day);
153
+ # running the rake task once on app boot is enough. Override
154
+ # `RemindersSweepJob.next_run` on a subclass to change the cadence.
155
+ #
156
+ # config.reminders_enabled = true
157
+ #
158
+ # Days-since-waiting at which reminders fire. Per-issue override via
159
+ # `issue.metadata.reminder_days = [...]`.
160
+ # config.reminder_days = [1, 3, 7, 10, 14, 18]
161
+ #
162
+ # Days of continuous waiting before the sweep auto-closes the issue
163
+ # (emits `issue.closed_inactive` instead of the regular `issue.closed`).
164
+ # config.inactivity_close_days = 30
165
+ #
166
+ # Label names the gem creates and manages on waiting/inactive issues.
167
+ # config.waiting_on_user_label = 'waiting-on-user'
168
+ # config.waiting_on_approval_label = 'waiting-on-approval'
169
+ # config.user_inactive_label = 'user-inactive'
170
+
171
+ # --------------------------------------------------------------------------
172
+ # Auto-archiving of aged-closed issues
173
+ # --------------------------------------------------------------------------
174
+ # Piggybacks the RemindersSweepJob. For every issue closed more than
175
+ # `archive_closed_after_days` ago (based on GitHub's `closed_at`) the
176
+ # sweep: adds the configured label, locks the conversation, removes
177
+ # the issue from every Projects V2 board, stamps `metadata.archived_at`,
178
+ # and emits `plan_my_stuff.issue.archived` with `reason: :aged_closed`.
179
+ # Issues auto-closed by the inactivity sweep (closed_by_inactivity) are
180
+ # excluded. Non-PMS issues are excluded.
181
+ #
182
+ # config.archiving_enabled = true
183
+ # config.archive_closed_after_days = 90
184
+ # config.archived_label = 'archived'
185
+
186
+ # --------------------------------------------------------------------------
187
+ # AWS webhook (ECS deployments via SNS)
188
+ # --------------------------------------------------------------------------
189
+ # Expected SNS topic ARN for webhook validation.
190
+ # config.sns_topic_arn = 'arn:aws:sns:us-east-1:123456:ecs-deploy-topic'
191
+
192
+ # ECS service identifier - suffix matched against resource ARNs in events.
193
+ # config.aws_service_identifier = 'myapp-production-web'
194
+
195
+ # Commit hash of the currently deploying build. Prefix-matched against
196
+ # issue metadata commit_sha on SERVICE_DEPLOYMENT_COMPLETED events.
197
+ # config.production_commit_sha = Rails.configuration.x.image_tag
198
+
199
+ # Toggle for processing AWS webhook events (default: Rails.env.production?).
200
+ # config.process_aws_webhooks = Rails.env.production?
201
+
202
+ # SNS signature verifier (default: Aws::SNS::MessageVerifier). Must respond
203
+ # to authenticate!(raw_body). Provide aws-sdk-sns in your Gemfile.
204
+ # config.sns_verifier_class = Aws::SNS::MessageVerifier
205
+ # config.sns_verifier_error = Aws::SNS::MessageVerifier::VerificationError
206
+
207
+ # --------------------------------------------------------------------------
208
+ # Route mounting
209
+ # --------------------------------------------------------------------------
210
+ # Per-group toggles for which engine route groups to mount.
211
+ # config.mount_groups = { webhooks: true, issues: true, projects: true }
212
+
213
+ # --------------------------------------------------------------------------
214
+ # Controller overrides (Devise-style)
215
+ # --------------------------------------------------------------------------
216
+ # Swap in your own controller for any mounted route group. Typical use is
217
+ # to subclass the gem controller and slot the subclass in here:
218
+ #
219
+ # # app/controllers/issues_controller.rb
220
+ # class IssuesController < PlanMyStuff::IssuesController
221
+ # before_action :require_admin
222
+ # end
223
+ #
224
+ # # config/initializers/plan_my_stuff.rb
225
+ # config.controllers = { issues: 'issues' }
226
+ #
227
+ # Unset keys fall back to the gem default.
228
+ #
229
+ # Controllable keys (with gem defaults):
230
+ # :issues => 'plan_my_stuff/issues'
231
+ # :comments => 'plan_my_stuff/comments'
232
+ # :labels => 'plan_my_stuff/labels'
233
+ # :projects => 'plan_my_stuff/projects'
234
+ # :project_items => 'plan_my_stuff/project_items'
235
+ # :testing_projects => 'plan_my_stuff/testing_projects'
236
+ # :testing_project_items => 'plan_my_stuff/testing_project_items'
237
+ # :'issues/closures' => 'plan_my_stuff/issues/closures'
238
+ # :'issues/viewers' => 'plan_my_stuff/issues/viewers'
239
+ # :'issues/takes' => 'plan_my_stuff/issues/takes'
240
+ # :'issues/waitings' => 'plan_my_stuff/issues/waitings'
241
+ # :'issues/links' => 'plan_my_stuff/issues/links'
242
+ # :'issues/approvals' => 'plan_my_stuff/issues/approvals'
243
+ # :'project_items/statuses' => 'plan_my_stuff/project_items/statuses'
244
+ # :'project_items/assignments' => 'plan_my_stuff/project_items/assignments'
245
+ # :'testing_project_items/results' => 'plan_my_stuff/testing_project_items/results'
246
+ # :'webhooks/github' => 'plan_my_stuff/webhooks/github'
247
+ # :'webhooks/aws' => 'plan_my_stuff/webhooks/aws'
115
248
  end
@@ -7,6 +7,13 @@ 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
10
17
 
11
18
  class << self
12
19
  # Reads a field from an object that may respond to method calls or hash access.
@@ -19,11 +26,96 @@ module PlanMyStuff
19
26
  def read_field(obj, field)
20
27
  obj.respond_to?(field) ? obj.public_send(field) : obj[field]
21
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
22
113
  end
23
114
 
24
115
  def initialize(**)
25
116
  super
26
117
  @persisted = false
118
+ @destroyed = false
27
119
  end
28
120
 
29
121
  # @return [Boolean]
@@ -36,8 +128,37 @@ module PlanMyStuff
36
128
  !@persisted
37
129
  end
38
130
 
131
+ # @return [Boolean]
132
+ def destroyed?
133
+ @destroyed
134
+ end
135
+
39
136
  private
40
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
+
41
162
  # Reads a field from an object that may respond to method calls or hash access.
42
163
  #
43
164
  # @param obj [Object]