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
@@ -1,46 +1,123 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- # Wraps a GitHub label with a reference to its parent issue.
5
- # Class methods provide the public API for add/remove operations.
4
+ # Wraps a GitHub label with a reference to its parent issue. Class methods provide the public API for add/remove
5
+ # operations.
6
6
  class Label < PlanMyStuff::ApplicationRecord
7
- # @return [String] label name
8
- attr_accessor :name
9
- # @return [PlanMyStuff::Issue] parent issue
10
- attr_accessor :issue
7
+ # @return [String, nil]
8
+ attribute :name, :string
9
+ # @return [PlanMyStuff::Issue, nil]
10
+ attribute :issue
11
11
 
12
12
  class << self
13
13
  # Adds labels to a GitHub issue.
14
14
  #
15
15
  # @param issue [PlanMyStuff::Issue] parent issue
16
16
  # @param labels [Array<String>]
17
+ # @param user [Object, nil] actor for the notification event
17
18
  #
18
19
  # @return [Array<PlanMyStuff::Label>]
19
20
  #
20
- def add(issue:, labels:)
21
+ def add!(issue:, labels:, user: nil)
22
+ label_names = Array.wrap(labels)
23
+
21
24
  result = PlanMyStuff.client.rest(
22
- :add_labels_to_an_issue, issue.repo, issue.number, labels,
25
+ :add_labels_to_an_issue, issue.repo, issue.number, label_names,
26
+ )
27
+
28
+ PlanMyStuff::Cache.delete_issue(issue.repo, issue.number)
29
+
30
+ PlanMyStuff::Notifications.instrument(
31
+ 'label_added', issue, user: user, labels: label_names,
23
32
  )
24
33
 
25
34
  result.map { |gh_label| build(gh_label, issue: issue) }
26
35
  end
27
36
 
37
+ # Ensures a label exists on the given repo, creating it if missing. Idempotent: a 404 from +label+ triggers
38
+ # creation; a 422 from +add_label+ (concurrent-creation race) is treated as success.
39
+ #
40
+ # @param repo [String, Symbol] repo name or key
41
+ # @param name [String] label name
42
+ # @param color [String] hex color without +#+
43
+ # @param description [String, nil]
44
+ #
45
+ # @return [void]
46
+ #
47
+ def ensure!(repo:, name:, color: 'fbca04', description: nil)
48
+ client = PlanMyStuff.client
49
+ client.rest(:label, repo, name)
50
+ rescue PlanMyStuff::APIError => e
51
+ raise(e) unless e.status == 404
52
+
53
+ create_label!(client, repo, name, color, description)
54
+ end
55
+
56
+ # Returns whether a label exists in the repo.
57
+ #
58
+ # @raise [PlanMyStuff::APIError] if the GET fails with any status other than 404
59
+ #
60
+ # @param repo [String, Symbol, PlanMyStuff::Repo] repo name or key
61
+ # @param name [String] label name
62
+ #
63
+ # @return [Boolean]
64
+ #
65
+ def exists?(repo:, name:, do_raise: true)
66
+ PlanMyStuff.client.rest(:label, repo, name)
67
+ true
68
+ rescue PlanMyStuff::APIError => e
69
+ raise(e) if do_raise && e.status != 404
70
+
71
+ false
72
+ end
73
+
28
74
  # Removes labels from a GitHub issue.
29
75
  #
30
76
  # @param issue [PlanMyStuff::Issue] parent issue
31
77
  # @param labels [Array<String>]
78
+ # @param user [Object, nil] actor for the notification event
32
79
  #
33
80
  # @return [Array<Array<PlanMyStuff::Label>>] results of each removal
34
81
  #
35
- def remove(issue:, labels:)
36
- Array.wrap(labels).map do |label|
82
+ def remove!(issue:, labels:, user: nil)
83
+ label_names = Array.wrap(labels)
84
+
85
+ results = label_names.map do |label|
37
86
  result = PlanMyStuff.client.rest(:remove_label, issue.repo, issue.number, label)
38
87
  result.map { |gh_label| build(gh_label, issue: issue) }
39
88
  end
89
+
90
+ PlanMyStuff::Cache.delete_issue(issue.repo, issue.number)
91
+
92
+ PlanMyStuff::Notifications.instrument(
93
+ 'label_removed', issue, user: user, labels: label_names,
94
+ )
95
+
96
+ results
40
97
  end
41
98
 
42
99
  private
43
100
 
101
+ # Creates a label, tolerating the 422 "already exists" race that occurs when a concurrent ensure! slipped in
102
+ # between our 404 read and this write.
103
+ #
104
+ # @param client [PlanMyStuff::Client]
105
+ # @param repo [String, Symbol]
106
+ # @param name [String]
107
+ # @param color [String]
108
+ # @param description [String, nil]
109
+ #
110
+ # @return [void]
111
+ #
112
+ def create_label!(client, repo, name, color, description)
113
+ options = {}
114
+ options[:description] = description if description
115
+
116
+ client.rest(:add_label, repo, name, color, **options)
117
+ rescue PlanMyStuff::APIError => e
118
+ raise(e) unless e.status == 422
119
+ end
120
+
44
121
  # Hydrates a Label from a GitHub API response.
45
122
  #
46
123
  # @param github_label [Object] Octokit label response
@@ -49,11 +126,21 @@ module PlanMyStuff
49
126
  # @return [PlanMyStuff::Label]
50
127
  #
51
128
  def build(github_label, issue:)
52
- label_name = github_label.respond_to?(:name) ? github_label.name : github_label[:name]
53
- label = new(name: label_name, issue: issue)
54
- label.instance_variable_set(:@persisted, true)
129
+ label = new(name: read_field(github_label, :name), issue: issue)
130
+ label.instance_variable_set(:@github_response, github_label)
131
+ label.__send__(:persisted!)
55
132
  label
56
133
  end
57
134
  end
135
+
136
+ # Serializes the label to a JSON-safe hash, excluding the back-reference to the parent issue to prevent
137
+ # recursive serialization cycles.
138
+ #
139
+ # @return [Hash]
140
+ #
141
+ def as_json(options = {})
142
+ merged_except = Array.wrap(options[:except]) + ['issue']
143
+ super(options.merge(except: merged_except)).merge('issue_number' => issue&.number)
144
+ end
58
145
  end
59
146
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module PlanMyStuff
6
+ # Value object representing a typed relationship between two issues. Built by +Issue#add_related!+,
7
+ # +#add_blocker!+, +#add_sub_issue!+, +#set_parent!+, and +#mark_duplicate!+; also returned from their +remove_*+
8
+ # counterparts so callers can render activity-log lines like +"Added parent issue: owner/repo#42"+.
9
+ #
10
+ # Metadata-backed types live in +IssueMetadata#links+; native types are routed through GitHub APIs and never
11
+ # persisted in our metadata.
12
+ class Link
13
+ METADATA_TYPES = %w[related].freeze
14
+ NATIVE_TYPES = %w[blocking blocked_by parent sub_ticket duplicate_of].freeze
15
+ ALL_TYPES = (METADATA_TYPES + NATIVE_TYPES).freeze
16
+
17
+ include ActiveModel::Model
18
+ include ActiveModel::Attributes
19
+ include ActiveModel::Serializers::JSON
20
+
21
+ # @return [String] one of ALL_TYPES
22
+ attribute :type, :string
23
+ # @return [Integer] target issue's GitHub issue number
24
+ attribute :issue_number, :integer
25
+ # @return [String] full "owner/name" path of the target issue's repo
26
+ attribute :repo, :string
27
+
28
+ validates :type, presence: true, inclusion: { in: ALL_TYPES }
29
+ validates :issue_number, presence: true, numericality: { greater_than: 0, only_integer: true }
30
+ validates :repo, presence: true
31
+
32
+ class << self
33
+ # Builds and validates a +Link+ from an +Issue+-like object, another +Link+, or a hash. +type:+ fills in when
34
+ # the input does not carry one; +source_repo+ fills in when the hash input does not carry one. Raises
35
+ # +ActiveModel::ValidationError+ on missing/invalid fields, +ArgumentError+ when +input+ is an unsupported
36
+ # shape.
37
+ #
38
+ # @param input [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
39
+ # @param type [String, Symbol, nil]
40
+ # @param source_repo [String, PlanMyStuff::Repo, nil]
41
+ #
42
+ # @return [PlanMyStuff::Link]
43
+ #
44
+ def build!(input, type: nil, source_repo: nil)
45
+ return input if input.is_a?(PlanMyStuff::Link)
46
+
47
+ link =
48
+ if input.is_a?(Hash)
49
+ build_from_hash(input, type: type, source_repo: source_repo)
50
+ else
51
+ build_from_issue_like!(input, type: type)
52
+ end
53
+
54
+ link.validate!
55
+ link
56
+ end
57
+
58
+ private
59
+
60
+ # @raise [ArgumentError] if input does not respond to #number and #repo
61
+ #
62
+ # @return [PlanMyStuff::Link]
63
+ #
64
+ def build_from_issue_like!(input, type:)
65
+ if !input.respond_to?(:number) || !input.respond_to?(:repo)
66
+ raise(ArgumentError, "Cannot build Link from #{input.class}")
67
+ end
68
+
69
+ new(
70
+ type: type&.to_s,
71
+ issue_number: input.number,
72
+ repo: repo_string(input.repo),
73
+ )
74
+ end
75
+
76
+ # @return [PlanMyStuff::Link]
77
+ def build_from_hash(hash, type:, source_repo:)
78
+ data = hash.transform_keys(&:to_sym)
79
+ new(
80
+ type: (data[:type] || type)&.to_s,
81
+ issue_number: data[:issue_number],
82
+ repo: repo_string(data[:repo] || source_repo),
83
+ )
84
+ end
85
+
86
+ # @return [String, nil]
87
+ def repo_string(value)
88
+ return if value.nil?
89
+ return value.full_name if value.is_a?(PlanMyStuff::Repo)
90
+
91
+ value.to_s
92
+ end
93
+ end
94
+
95
+ # @return [String] e.g. "owner/repo#42"
96
+ def to_s
97
+ "#{repo}##{issue_number}"
98
+ end
99
+
100
+ # @return [Hash]
101
+ def to_h
102
+ {
103
+ type: type,
104
+ issue_number: issue_number,
105
+ repo: repo,
106
+ }
107
+ end
108
+
109
+ # @param other_repo [String, PlanMyStuff::Repo, nil]
110
+ #
111
+ # @return [Boolean]
112
+ #
113
+ def same_repo?(other_repo)
114
+ return false if other_repo.nil?
115
+
116
+ repo == self.class.__send__(:repo_string, other_repo)
117
+ end
118
+
119
+ # Lazy-fetches and memoizes the target +Issue+.
120
+ #
121
+ # @return [PlanMyStuff::Issue]
122
+ #
123
+ def issue
124
+ @issue ||= PlanMyStuff::Issue.find(issue_number, repo: repo)
125
+ end
126
+
127
+ # @param other [Object]
128
+ #
129
+ # @return [Boolean]
130
+ #
131
+ def ==(other)
132
+ return false unless other.is_a?(PlanMyStuff::Link)
133
+
134
+ type == other.type && issue_number == other.issue_number && repo == other.repo
135
+ end
136
+
137
+ alias eql? ==
138
+
139
+ # @return [Integer]
140
+ def hash
141
+ [type, issue_number, repo].hash
142
+ end
143
+ end
144
+ end
@@ -5,8 +5,10 @@ require 'active_support/core_ext/hash/deep_merge'
5
5
  module PlanMyStuff
6
6
  module Markdown
7
7
  class << self
8
- # Renders markdown text to HTML using the configured renderer.
9
- # Per-call options are deep-merged on top of config.markdown_options.
8
+ # Renders markdown text to HTML using the configured renderer. Per-call options are deep-merged on top of
9
+ # config.markdown_options.
10
+ #
11
+ # @raise [ArgumentError] if the configured renderer is not an option
10
12
  #
11
13
  # @param text [String] raw markdown text
12
14
  # @param options [Hash] renderer-specific options (merged over config defaults)
@@ -22,11 +24,11 @@ module PlanMyStuff
22
24
 
23
25
  case config.markdown_renderer
24
26
  when :commonmarker
25
- render_commonmarker(text, merged)
27
+ render_commonmarker!(text, merged)
26
28
  when :redcarpet
27
- render_redcarpet(text, merged)
29
+ render_redcarpet!(text, merged)
28
30
  when nil
29
- "<code>#{text}</code>"
31
+ "<code>#{ERB::Util.html_escape(text)}</code>"
30
32
  else
31
33
  raise(
32
34
  ArgumentError,
@@ -37,12 +39,14 @@ module PlanMyStuff
37
39
 
38
40
  private
39
41
 
42
+ # @raise [PlanMyStuff::Error] if commonmarker gem is not available
43
+ #
40
44
  # @param text [String]
41
45
  # @param options [Hash]
42
46
  #
43
47
  # @return [String]
44
48
  #
45
- def render_commonmarker(text, options)
49
+ def render_commonmarker!(text, options)
46
50
  require('commonmarker') unless defined?(Commonmarker)
47
51
 
48
52
  if options.empty?
@@ -58,12 +62,14 @@ module PlanMyStuff
58
62
  )
59
63
  end
60
64
 
65
+ # @raise [PlanMyStuff::Error] if redcarpet gem is not available
66
+ #
61
67
  # @param text [String]
62
68
  # @param options [Hash]
63
69
  #
64
70
  # @return [String]
65
71
  #
66
- def render_redcarpet(text, options)
72
+ def render_redcarpet!(text, options)
67
73
  require('redcarpet') unless defined?(Redcarpet)
68
74
 
69
75
  options = options.dup
@@ -4,7 +4,22 @@ require 'json'
4
4
 
5
5
  module PlanMyStuff
6
6
  module MetadataParser
7
- METADATA_PATTERN = /\A<!-- pms-metadata:(.*?) -->\n*/m
7
+ # Collapsible <details> block containing a JSON code fence. Renders visibly on
8
+ # GitHub (issue #58) instead of being hidden in an HTML comment.
9
+ METADATA_PATTERN = %r{
10
+ \A<details><summary>pms-metadata</summary>\n\n
11
+ ```json\n(.*?)\n```\n\n
12
+ </details>\n*
13
+ }mx
14
+
15
+ # Visible attachments block emitted after the metadata block when the
16
+ # metadata carries non-empty +attachments+ (issue #70). Parse strips it
17
+ # so round-trips stay clean.
18
+ ATTACHMENTS_PATTERN = %r{
19
+ \A<details><summary>attachments\ \(\d+\)</summary>\n\n
20
+ .*?\n\n
21
+ </details>\n*
22
+ }mx
8
23
 
9
24
  module_function
10
25
 
@@ -16,12 +31,11 @@ module PlanMyStuff
16
31
  #
17
32
  def parse(raw_body)
18
33
  return { metadata: {}, body: '' } if raw_body.blank?
34
+ return { metadata: {}, body: raw_body } unless raw_body.match?(METADATA_PATTERN)
19
35
 
20
36
  match = raw_body.match(METADATA_PATTERN)
21
- return { metadata: {}, body: raw_body } if match.nil?
22
-
23
37
  metadata = JSON.parse(match[1], symbolize_names: true)
24
- body = raw_body.sub(METADATA_PATTERN, '')
38
+ body = raw_body.sub(METADATA_PATTERN, '').sub(ATTACHMENTS_PATTERN, '')
25
39
 
26
40
  { metadata: metadata, body: body }
27
41
  rescue JSON::ParserError
@@ -30,24 +44,49 @@ module PlanMyStuff
30
44
 
31
45
  # Serializes a metadata hash and body into the stored format
32
46
  #
47
+ # @raise [ArgumentError] if metadata is not a Hash or PlanMyStuff::CustomFields
48
+ #
33
49
  # @param metadata [Hash, PlanMyStuff::CustomFields]
34
50
  # @param body [String]
35
51
  #
36
52
  # @return [String]
37
53
  #
38
- def serialize(metadata, body)
54
+ def serialize!(metadata, body)
39
55
  if !metadata.is_a?(Hash) && !metadata.is_a?(PlanMyStuff::CustomFields)
40
56
  raise(ArgumentError, "metadata must be a Hash or PlanMyStuff::CustomFields, got #{metadata.class}")
41
57
  end
42
58
 
43
- json =
44
- if metadata.is_a?(PlanMyStuff::CustomFields)
45
- metadata.to_json
46
- else
47
- JSON.pretty_generate(metadata)
48
- end
59
+ hash = metadata.is_a?(PlanMyStuff::CustomFields) ? metadata.to_h : metadata
60
+ json = JSON.pretty_generate(hash)
61
+
62
+ "<details><summary>pms-metadata</summary>\n\n```json\n#{json}\n```\n\n</details>\n\n" \
63
+ "#{attachments_block(hash)}#{body}"
64
+ end
49
65
 
50
- "<!-- pms-metadata:#{json} -->\n\n#{body}"
66
+ # Renders the visible attachments block when +metadata+ carries
67
+ # non-empty +:attachments+, otherwise returns an empty string.
68
+ #
69
+ # @param metadata [Hash]
70
+ #
71
+ # @return [String]
72
+ #
73
+ def attachments_block(metadata)
74
+ attachments = metadata[:attachments]
75
+ return '' if attachments.blank?
76
+
77
+ lines = attachments.map { |a| attachment_line(a) }.join("\n")
78
+ "<details><summary>attachments (#{attachments.size})</summary>\n\n#{lines}\n\n</details>\n\n"
79
+ end
80
+
81
+ # @param attachment [Hash{Symbol=>String}]
82
+ #
83
+ # @return [String]
84
+ #
85
+ def attachment_line(attachment)
86
+ url = "https://github.com/#{attachment[:owner]}/#{attachment[:repo]}" \
87
+ "/blob/#{attachment[:sha]}/#{attachment[:path]}"
88
+ safe_filename = attachment[:filename].to_s.gsub(']', '\]')
89
+ "- [#{safe_filename}](#{url})"
51
90
  end
52
91
  end
53
92
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+
5
+ module PlanMyStuff
6
+ # Central instrumentation helper. Domain classes call
7
+ # +PlanMyStuff::Notifications.instrument+ at mutation points so
8
+ # consuming apps can subscribe for email, webhooks, Slack, etc.
9
+ #
10
+ # Events are fired under the +<event>.plan_my_stuff+ namespace via
11
+ # +ActiveSupport::Notifications+ (Rails convention: event first,
12
+ # library last - matches +sql.active_record+, +deliver.action_mailer+).
13
+ # Subscribers run synchronously.
14
+ #
15
+ module Notifications
16
+ module_function
17
+
18
+ EVENT_SUFFIX = 'plan_my_stuff'
19
+ SKIPPED_LOG_KEYS = %i[user timestamp visibility visibility_allowlist].freeze
20
+
21
+ # Fires +<event>.plan_my_stuff+ with a normalized payload.
22
+ #
23
+ # @param event [String] e.g. +'issue_created'+
24
+ # @param resource [Object] domain object (+Issue+, +Comment+, +ProjectItem+, ...), or an +Array+ of resources for a
25
+ # batch event (keyed by the pluralized element key, e.g. +:project_items+)
26
+ # @param user [Object, nil] explicit actor; falls back to +config.current_user+
27
+ # @param extra [Hash] additional payload entries (+changes:+, +labels:+, +user_ids:+, ...)
28
+ #
29
+ # @return [void]
30
+ #
31
+ def instrument(event, resource, user: nil, **extra)
32
+ actor = user || resolve_current_user
33
+ payload = build_payload(resource, actor, extra)
34
+ log(event, payload)
35
+ ActiveSupport::Notifications.instrument("#{event}.#{EVENT_SUFFIX}", payload)
36
+ end
37
+
38
+ # Invokes +config.current_user+ if it responds to +call+.
39
+ #
40
+ # @return [Object, nil]
41
+ #
42
+ def resolve_current_user
43
+ resolver = PlanMyStuff.configuration.current_user
44
+ return if resolver.nil?
45
+
46
+ resolver.respond_to?(:call) ? resolver.call : resolver
47
+ end
48
+
49
+ # Builds the payload hash for an event.
50
+ #
51
+ # @param resource [Object]
52
+ # @param actor [Object, nil]
53
+ # @param extra [Hash]
54
+ #
55
+ # @return [Hash]
56
+ #
57
+ def build_payload(resource, actor, extra)
58
+ payload = {
59
+ infer_resource_key(resource) => resource,
60
+ :user => actor,
61
+ :timestamp => Time.current,
62
+ }
63
+ payload.merge!(visibility_fields(resource))
64
+ payload.merge(extra)
65
+ end
66
+
67
+ # Maps a resource object to its payload key. An +Array+ recurses on its first element and pluralizes that key, so
68
+ # batch events carry the full set under one key (a batch of project items keys as +:project_items+, a batch of
69
+ # issues as +:issues+, an empty/unknown batch as +:resources+).
70
+ #
71
+ # @param resource [Object]
72
+ #
73
+ # @return [Symbol]
74
+ #
75
+ def infer_resource_key(resource)
76
+ case resource
77
+ when PlanMyStuff::Issue then :issue
78
+ when PlanMyStuff::Comment then :comment
79
+ when PlanMyStuff::BaseProjectItem then :project_item
80
+ when Array then :"#{infer_resource_key(resource.first)}s"
81
+ else :resource
82
+ end
83
+ end
84
+
85
+ # Extracts visibility + allowlist from +Issue+/+Comment+ resources.
86
+ # Returns an empty hash for resources without visibility.
87
+ #
88
+ # @param resource [Object]
89
+ #
90
+ # @return [Hash]
91
+ #
92
+ def visibility_fields(resource)
93
+ case resource
94
+ when PlanMyStuff::Issue
95
+ {
96
+ visibility: resource.metadata.visibility,
97
+ visibility_allowlist: Array.wrap(resource.metadata.visibility_allowlist),
98
+ }
99
+ when PlanMyStuff::Comment
100
+ parent_allowlist = resource.issue ? resource.issue.metadata.visibility_allowlist : []
101
+ {
102
+ visibility: resource.visibility&.to_s,
103
+ visibility_allowlist: Array.wrap(parent_allowlist),
104
+ }
105
+ else
106
+ {}
107
+ end
108
+ end
109
+
110
+ # Emits a debug log line for the event. No-op when no logger is
111
+ # available (e.g. outside Rails).
112
+ #
113
+ # @param event [String]
114
+ # @param payload [Hash]
115
+ #
116
+ # @return [void]
117
+ #
118
+ def log(event, payload)
119
+ logger = rails_logger
120
+ return if logger.nil?
121
+
122
+ logger.debug { "[PlanMyStuff] #{event}.#{EVENT_SUFFIX} #{log_fields(payload)}" }
123
+ end
124
+
125
+ # @return [Logger, nil]
126
+ def rails_logger
127
+ return unless defined?(Rails)
128
+ return unless Rails.respond_to?(:logger)
129
+
130
+ Rails.logger
131
+ end
132
+
133
+ # @param payload [Hash]
134
+ #
135
+ # @return [String]
136
+ #
137
+ def log_fields(payload)
138
+ fields = []
139
+ fields << "user=#{payload[:user].inspect}" if payload.key?(:user)
140
+ payload.each do |key, value|
141
+ next if SKIPPED_LOG_KEYS.include?(key)
142
+
143
+ fields << "#{key}=#{value.inspect}"
144
+ end
145
+ fields.join(' ')
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Pipeline
5
+ # Removes pipeline project items that have been at +Completed+ for
6
+ # longer than the configured TTL.
7
+ #
8
+ # Driven by +RemindersSweepJob+ so the gem ships one scheduled
9
+ # entrypoint. Items are removed via +Pipeline.remove!+ so subscribers
10
+ # see the standard +pipeline_removed.plan_my_stuff+ event.
11
+ #
12
+ module CompletedSweep
13
+ module_function
14
+
15
+ # Runs the sweep. No-op when
16
+ # +configuration.pipeline_completion_purge_enabled+ is false.
17
+ #
18
+ # @param project_number [Integer, nil]
19
+ # @param now [Time]
20
+ #
21
+ # @return [Array<PlanMyStuff::ProjectItem>] removed items
22
+ #
23
+ def perform!(project_number: nil, now: Time.current)
24
+ config = PlanMyStuff.configuration
25
+ return [] unless config.pipeline_completion_purge_enabled
26
+
27
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!(project_number)
28
+ project = PlanMyStuff::Project.find(number)
29
+
30
+ completed_status = PlanMyStuff::Pipeline.resolve_status_name(
31
+ PlanMyStuff::Pipeline::Status::COMPLETED,
32
+ )
33
+ ttl = config.pipeline_completion_ttl_hours.hours
34
+
35
+ candidates = project.items.select do |item|
36
+ item.status == completed_status &&
37
+ item.updated_at.present? &&
38
+ item.updated_at + ttl < now
39
+ end
40
+
41
+ candidates.each { |item| PlanMyStuff::Pipeline.remove!(item) }
42
+ candidates
43
+ end
44
+ end
45
+ end
46
+ end