plan_my_stuff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +28 -0
- data/README.md +284 -0
- data/app/controllers/plan_my_stuff/application_controller.rb +76 -0
- data/app/controllers/plan_my_stuff/comments_controller.rb +82 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +145 -0
- data/app/controllers/plan_my_stuff/labels_controller.rb +30 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +93 -0
- data/app/controllers/plan_my_stuff/projects_controller.rb +17 -0
- data/app/views/plan_my_stuff/comments/edit.html.erb +16 -0
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +32 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +12 -0
- data/app/views/plan_my_stuff/issues/index.html.erb +37 -0
- data/app/views/plan_my_stuff/issues/new.html.erb +7 -0
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +41 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +23 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +32 -0
- data/app/views/plan_my_stuff/issues/show.html.erb +58 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +13 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +101 -0
- data/config/routes.rb +25 -0
- data/lib/generators/plan_my_stuff/install/install_generator.rb +38 -0
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +106 -0
- data/lib/generators/plan_my_stuff/views/views_generator.rb +22 -0
- data/lib/plan_my_stuff/application_record.rb +39 -0
- data/lib/plan_my_stuff/base_metadata.rb +136 -0
- data/lib/plan_my_stuff/client.rb +143 -0
- data/lib/plan_my_stuff/comment.rb +360 -0
- data/lib/plan_my_stuff/comment_metadata.rb +56 -0
- data/lib/plan_my_stuff/configuration.rb +139 -0
- data/lib/plan_my_stuff/custom_fields.rb +65 -0
- data/lib/plan_my_stuff/engine.rb +11 -0
- data/lib/plan_my_stuff/errors.rb +87 -0
- data/lib/plan_my_stuff/issue.rb +486 -0
- data/lib/plan_my_stuff/issue_metadata.rb +111 -0
- data/lib/plan_my_stuff/label.rb +59 -0
- data/lib/plan_my_stuff/markdown.rb +83 -0
- data/lib/plan_my_stuff/metadata_parser.rb +53 -0
- data/lib/plan_my_stuff/project.rb +504 -0
- data/lib/plan_my_stuff/project_item.rb +414 -0
- data/lib/plan_my_stuff/test_helpers.rb +501 -0
- data/lib/plan_my_stuff/user_resolver.rb +61 -0
- data/lib/plan_my_stuff/verifier.rb +102 -0
- data/lib/plan_my_stuff/version.rb +19 -0
- data/lib/plan_my_stuff.rb +69 -0
- data/lib/tasks/plan_my_stuff.rake +23 -0
- metadata +126 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Generators
|
|
5
|
+
class ViewsGenerator < Rails::Generators::Base
|
|
6
|
+
source_root PlanMyStuff::Engine.root.join('app', 'views', 'plan_my_stuff')
|
|
7
|
+
|
|
8
|
+
# @return [void]
|
|
9
|
+
def copy_views
|
|
10
|
+
directory('.', 'app/views/plan_my_stuff')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [void]
|
|
14
|
+
def show_done
|
|
15
|
+
say('')
|
|
16
|
+
say('PlanMyStuff views copied to app/views/plan_my_stuff/.', :green)
|
|
17
|
+
say('You can now customize these templates to match your app.', :yellow)
|
|
18
|
+
say('')
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
5
|
+
module PlanMyStuff
|
|
6
|
+
# Base class for all PMS domain objects backed by GitHub resources.
|
|
7
|
+
# Provides shared persistence predicates and utility helpers.
|
|
8
|
+
class ApplicationRecord
|
|
9
|
+
include ActiveModel::Model
|
|
10
|
+
|
|
11
|
+
def initialize(**)
|
|
12
|
+
super
|
|
13
|
+
@persisted = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def persisted?
|
|
18
|
+
@persisted
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def new_record?
|
|
23
|
+
!@persisted
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Reads a field from an object that may respond to method calls or hash access.
|
|
29
|
+
#
|
|
30
|
+
# @param obj [Object]
|
|
31
|
+
# @param field [Symbol]
|
|
32
|
+
#
|
|
33
|
+
# @return [Object]
|
|
34
|
+
#
|
|
35
|
+
def read_field(obj, field)
|
|
36
|
+
obj.respond_to?(field) ? obj.public_send(field) : obj[field]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module PlanMyStuff
|
|
7
|
+
class BaseMetadata
|
|
8
|
+
SCHEMA_VERSION = '1'
|
|
9
|
+
|
|
10
|
+
# @return [String] schema version for forward compatibility (starts at "1")
|
|
11
|
+
attr_accessor :schema_version
|
|
12
|
+
# @return [String] gem version that created this metadata
|
|
13
|
+
attr_accessor :gem_version
|
|
14
|
+
# @return [String, nil] Rails environment
|
|
15
|
+
attr_accessor :rails_env
|
|
16
|
+
# @return [String, nil] consuming app name from config
|
|
17
|
+
attr_accessor :app_name
|
|
18
|
+
# @return [Time, nil] timestamp of creation
|
|
19
|
+
attr_accessor :created_at
|
|
20
|
+
# @return [Time, nil] timestamp of last update
|
|
21
|
+
attr_accessor :updated_at
|
|
22
|
+
# @return [Integer, nil] consuming app's user ID of the creator
|
|
23
|
+
attr_accessor :created_by
|
|
24
|
+
# @return [String] "public" or "internal"
|
|
25
|
+
attr_accessor :visibility
|
|
26
|
+
# @return [PlanMyStuff::CustomFields, nil] app-defined custom field values
|
|
27
|
+
attr_accessor :custom_fields
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Sets common fields on a metadata instance from a parsed hash
|
|
33
|
+
#
|
|
34
|
+
# @param metadata [BaseMetadata]
|
|
35
|
+
# @param hash [Hash]
|
|
36
|
+
#
|
|
37
|
+
# @return [void]
|
|
38
|
+
#
|
|
39
|
+
def apply_common_from_hash(metadata, hash)
|
|
40
|
+
metadata.schema_version = hash[:schema_version]
|
|
41
|
+
metadata.gem_version = hash[:gem_version]
|
|
42
|
+
metadata.rails_env = hash[:rails_env]
|
|
43
|
+
metadata.app_name = hash[:app_name]
|
|
44
|
+
metadata.created_at = parse_time(hash[:created_at])
|
|
45
|
+
metadata.updated_at = parse_time(hash[:updated_at])
|
|
46
|
+
metadata.created_by = hash[:created_by]
|
|
47
|
+
metadata.visibility = hash.fetch(:visibility, 'internal')
|
|
48
|
+
metadata.custom_fields = CustomFields.new(
|
|
49
|
+
PlanMyStuff.configuration.custom_fields,
|
|
50
|
+
hash[:custom_fields] || {},
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sets common fields on a metadata instance for new creation
|
|
55
|
+
#
|
|
56
|
+
# @param metadata [BaseMetadata]
|
|
57
|
+
# @param user [Object, Integer] user object or user_id
|
|
58
|
+
# @param visibility [String] "public" or "internal"
|
|
59
|
+
# @param custom_fields_data [Hash]
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
#
|
|
63
|
+
def apply_common_build(metadata, user:, visibility: 'internal', custom_fields_data: {})
|
|
64
|
+
config = PlanMyStuff.configuration
|
|
65
|
+
now = Time.now.utc
|
|
66
|
+
|
|
67
|
+
metadata.schema_version = self::SCHEMA_VERSION
|
|
68
|
+
metadata.gem_version = PlanMyStuff::VERSION::STRING
|
|
69
|
+
metadata.rails_env = (defined?(Rails) && Rails.respond_to?(:env)) ? Rails.env.to_s : nil
|
|
70
|
+
metadata.app_name = config.app_name
|
|
71
|
+
metadata.created_at = now
|
|
72
|
+
metadata.updated_at = now
|
|
73
|
+
resolved = UserResolver.resolve(user)
|
|
74
|
+
metadata.created_by = resolved.present? ? UserResolver.user_id(resolved) : nil
|
|
75
|
+
metadata.visibility = visibility
|
|
76
|
+
metadata.custom_fields = CustomFields.new(
|
|
77
|
+
config.custom_fields,
|
|
78
|
+
custom_fields_data,
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Time, nil]
|
|
83
|
+
def parse_time(value)
|
|
84
|
+
return if value.nil?
|
|
85
|
+
return value.utc if value.is_a?(Time)
|
|
86
|
+
|
|
87
|
+
Time.parse(value.to_s).utc
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initialize
|
|
94
|
+
@visibility = 'internal'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Hash]
|
|
98
|
+
def to_h
|
|
99
|
+
{
|
|
100
|
+
schema_version: schema_version,
|
|
101
|
+
gem_version: gem_version,
|
|
102
|
+
rails_env: rails_env,
|
|
103
|
+
app_name: app_name,
|
|
104
|
+
created_at: format_time(created_at),
|
|
105
|
+
updated_at: format_time(updated_at),
|
|
106
|
+
created_by: created_by,
|
|
107
|
+
visibility: visibility,
|
|
108
|
+
custom_fields: custom_fields.to_h,
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def internal?
|
|
114
|
+
visibility == 'internal'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def public?
|
|
119
|
+
visibility == 'public'
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [String]
|
|
123
|
+
def to_json(...)
|
|
124
|
+
to_h.to_json(...)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# @return [String, nil]
|
|
130
|
+
def format_time(time)
|
|
131
|
+
return if time.nil?
|
|
132
|
+
|
|
133
|
+
time.utc.iso8601
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/hash/keys'
|
|
4
|
+
require 'octokit'
|
|
5
|
+
|
|
6
|
+
module PlanMyStuff
|
|
7
|
+
# Infrastructure wrapper around Octokit. Handles auth, error normalization,
|
|
8
|
+
# and repo resolution. Domain modules (Issues, Projects, etc.) use this
|
|
9
|
+
# internally via PlanMyStuff.client.
|
|
10
|
+
class Client
|
|
11
|
+
# @return [Octokit::Client]
|
|
12
|
+
attr_reader :octokit
|
|
13
|
+
|
|
14
|
+
# @return [Client]
|
|
15
|
+
def initialize
|
|
16
|
+
PlanMyStuff.configuration.validate!
|
|
17
|
+
|
|
18
|
+
@octokit = Octokit::Client.new(access_token: PlanMyStuff.configuration.access_token)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Delegates a REST API call to Octokit, normalizing errors.
|
|
22
|
+
#
|
|
23
|
+
# @param method [Symbol] Octokit method name (e.g. :create_issue)
|
|
24
|
+
# @param args [Array] positional arguments
|
|
25
|
+
# @param kwargs [Hash] keyword arguments
|
|
26
|
+
#
|
|
27
|
+
# @return [Object] Octokit response
|
|
28
|
+
#
|
|
29
|
+
def rest(method, *, **kwargs, &)
|
|
30
|
+
if kwargs.empty?
|
|
31
|
+
octokit.public_send(method, *, &)
|
|
32
|
+
else
|
|
33
|
+
octokit.public_send(method, *, **kwargs, &)
|
|
34
|
+
end
|
|
35
|
+
rescue Octokit::TooManyRequests => e
|
|
36
|
+
raise_rate_limit_error(e)
|
|
37
|
+
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
38
|
+
raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Executes a GraphQL query against GitHub's /graphql endpoint.
|
|
42
|
+
#
|
|
43
|
+
# @param query [String] GraphQL query string
|
|
44
|
+
# @param variables [Hash] GraphQL variables
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash] parsed response data
|
|
47
|
+
#
|
|
48
|
+
def graphql(query, variables: {})
|
|
49
|
+
payload = { query: query }
|
|
50
|
+
payload[:variables] = variables unless variables.empty?
|
|
51
|
+
|
|
52
|
+
response = octokit.post('/graphql', payload.to_json)
|
|
53
|
+
data =
|
|
54
|
+
if response.is_a?(Hash)
|
|
55
|
+
response
|
|
56
|
+
else
|
|
57
|
+
(response.respond_to?(:to_h) ? response.to_h : response)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
|
|
61
|
+
|
|
62
|
+
check_graphql_errors!(data)
|
|
63
|
+
|
|
64
|
+
data[:data]
|
|
65
|
+
rescue Octokit::TooManyRequests => e
|
|
66
|
+
raise_rate_limit_error(e)
|
|
67
|
+
rescue Octokit::ClientError, Octokit::ServerError => e
|
|
68
|
+
raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Resolves a repo param to a full "Org/Repo" string.
|
|
72
|
+
#
|
|
73
|
+
# @param repo [Symbol, String, nil] repo key, full string, or nil for default
|
|
74
|
+
#
|
|
75
|
+
# @return [String] full repo path (e.g. "BrandsInsurance/Element")
|
|
76
|
+
#
|
|
77
|
+
# @raise [ArgumentError] if repo cannot be resolved
|
|
78
|
+
#
|
|
79
|
+
def resolve_repo(repo = nil)
|
|
80
|
+
repo ||= PlanMyStuff.configuration.default_repo
|
|
81
|
+
|
|
82
|
+
if repo.nil?
|
|
83
|
+
raise(
|
|
84
|
+
PlanMyStuff::ConfigurationError,
|
|
85
|
+
'No repo provided and config.default_repo is not set. ' \
|
|
86
|
+
'Either pass repo: explicitly or set config.default_repo in your initializer.',
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
case repo
|
|
91
|
+
when Symbol
|
|
92
|
+
resolved = PlanMyStuff.configuration.repos[repo]
|
|
93
|
+
raise(ArgumentError, "Unknown repo key: #{repo.inspect}") if resolved.nil?
|
|
94
|
+
|
|
95
|
+
resolved
|
|
96
|
+
when String
|
|
97
|
+
repo
|
|
98
|
+
else
|
|
99
|
+
raise(ArgumentError, "Cannot resolve repo: #{repo.inspect}")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# @param data [Hash] parsed GraphQL response
|
|
106
|
+
#
|
|
107
|
+
# @raise [GraphQLError] if response contains errors
|
|
108
|
+
#
|
|
109
|
+
# @return [void]
|
|
110
|
+
#
|
|
111
|
+
def check_graphql_errors!(data)
|
|
112
|
+
errors = data[:errors]
|
|
113
|
+
return if errors.blank?
|
|
114
|
+
|
|
115
|
+
messages = errors.filter_map { |e| e[:message] }
|
|
116
|
+
raise(GraphQLError.new(messages.join('; '), errors: errors))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param exception [Octokit::TooManyRequests]
|
|
120
|
+
#
|
|
121
|
+
# @raise [RateLimitError]
|
|
122
|
+
#
|
|
123
|
+
def raise_rate_limit_error(exception)
|
|
124
|
+
retry_after = parse_retry_after(exception)
|
|
125
|
+
raise(RateLimitError.new(exception.message, retry_after: retry_after))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param exception [Octokit::TooManyRequests]
|
|
129
|
+
#
|
|
130
|
+
# @return [Time, nil]
|
|
131
|
+
#
|
|
132
|
+
def parse_retry_after(exception)
|
|
133
|
+
headers = exception.response_headers
|
|
134
|
+
return unless headers
|
|
135
|
+
|
|
136
|
+
if headers['retry-after']
|
|
137
|
+
Time.now.utc + headers['retry-after'].to_i
|
|
138
|
+
elsif headers['x-ratelimit-reset']
|
|
139
|
+
Time.at(headers['x-ratelimit-reset'].to_i).utc
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Wraps a GitHub comment with parsed PMS metadata.
|
|
5
|
+
# Class methods provide the public API for CRUD operations.
|
|
6
|
+
#
|
|
7
|
+
# Follows an ActiveRecord-style pattern:
|
|
8
|
+
# - `Comment.new(**attrs)` creates an unpersisted instance
|
|
9
|
+
# - `Comment.create!` / `Comment.list` return persisted instances
|
|
10
|
+
# - `comment.save!` / `comment.update!` / `comment.reload` for persistence
|
|
11
|
+
class Comment < PlanMyStuff::ApplicationRecord
|
|
12
|
+
# @return [Integer] GitHub comment ID
|
|
13
|
+
attr_reader :id
|
|
14
|
+
# @return [String] full body as stored on GitHub
|
|
15
|
+
attr_reader :raw_body
|
|
16
|
+
# @return [PlanMyStuff::CommentMetadata] parsed metadata (empty when no PMS metadata present)
|
|
17
|
+
attr_reader :metadata
|
|
18
|
+
|
|
19
|
+
# @return [String] comment body without the metadata HTML comment
|
|
20
|
+
attr_accessor :body
|
|
21
|
+
# @return [PlanMyStuff::Issue] parent issue
|
|
22
|
+
attr_accessor :issue
|
|
23
|
+
# @param value [Symbol, String, nil]
|
|
24
|
+
attr_writer :visibility
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Creates a comment on a GitHub issue with PMS metadata and a visible header.
|
|
28
|
+
#
|
|
29
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
30
|
+
# @param body [String]
|
|
31
|
+
# @param user [Object, Integer] user object or user_id
|
|
32
|
+
# @param visibility [Symbol] :public or :internal
|
|
33
|
+
# @param custom_fields [Hash]
|
|
34
|
+
# @param issue_body [Boolean] whether this comment holds the issue body
|
|
35
|
+
#
|
|
36
|
+
# @return [PlanMyStuff::Comment]
|
|
37
|
+
#
|
|
38
|
+
def create!(
|
|
39
|
+
issue:,
|
|
40
|
+
body:,
|
|
41
|
+
user: nil,
|
|
42
|
+
visibility: :public,
|
|
43
|
+
custom_fields: {},
|
|
44
|
+
skip_responded: false,
|
|
45
|
+
issue_body: false
|
|
46
|
+
)
|
|
47
|
+
resolved_user = UserResolver.resolve(user)
|
|
48
|
+
visibility = resolve_visibility(visibility, resolved_user)
|
|
49
|
+
comment_metadata = CommentMetadata.build(
|
|
50
|
+
user: resolved_user,
|
|
51
|
+
visibility: visibility.to_s,
|
|
52
|
+
custom_fields: custom_fields,
|
|
53
|
+
issue_body: issue_body,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
header = build_header(resolved_user)
|
|
57
|
+
full_body = "#{header}\n\n#{body}"
|
|
58
|
+
serialized_body = MetadataParser.serialize(comment_metadata.to_h, full_body)
|
|
59
|
+
|
|
60
|
+
result = PlanMyStuff.client.rest(:add_comment, issue.repo, issue.number, serialized_body)
|
|
61
|
+
|
|
62
|
+
mark_issue_responded_if_first_support_comment(issue, resolved_user) unless skip_responded
|
|
63
|
+
|
|
64
|
+
build(result, issue: issue)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Updates an existing GitHub comment body.
|
|
68
|
+
#
|
|
69
|
+
# @param id [Integer] comment ID
|
|
70
|
+
# @param repo [String] repo path
|
|
71
|
+
# @param body [String] new serialized body
|
|
72
|
+
#
|
|
73
|
+
# @return [Object] Octokit response
|
|
74
|
+
#
|
|
75
|
+
def update!(id:, repo:, body:)
|
|
76
|
+
PlanMyStuff.client.rest(:update_comment, repo, id, body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Finds a single comment by ID, given its parent issue.
|
|
80
|
+
#
|
|
81
|
+
# @param id [Integer] GitHub comment ID
|
|
82
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
83
|
+
#
|
|
84
|
+
# @return [PlanMyStuff::Comment]
|
|
85
|
+
#
|
|
86
|
+
def find(id, issue:)
|
|
87
|
+
github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
|
|
88
|
+
build(github_comment, issue: issue)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Lists comments on a GitHub issue, optionally filtering to PMS-only comments.
|
|
92
|
+
#
|
|
93
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
94
|
+
# @param pms_only [Boolean]
|
|
95
|
+
#
|
|
96
|
+
# @return [Array<PlanMyStuff::Comment>]
|
|
97
|
+
#
|
|
98
|
+
def list(issue:, pms_only: false)
|
|
99
|
+
github_comments = PlanMyStuff.client.rest(:issue_comments, issue.repo, issue.number)
|
|
100
|
+
comments = github_comments.map { |gc| build(gc, issue: issue) }
|
|
101
|
+
|
|
102
|
+
pms_only ? comments.select(&:pms_comment?) : comments
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Hydrates a Comment from a GitHub API response.
|
|
108
|
+
#
|
|
109
|
+
# @param github_comment [Object] Octokit comment response
|
|
110
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
111
|
+
#
|
|
112
|
+
# @return [PlanMyStuff::Comment]
|
|
113
|
+
#
|
|
114
|
+
def build(github_comment, issue:)
|
|
115
|
+
comment = new
|
|
116
|
+
comment.__send__(:hydrate_from_github, github_comment, issue: issue)
|
|
117
|
+
comment
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Builds the visible header for a comment.
|
|
121
|
+
#
|
|
122
|
+
# @param user [Object, nil] resolved user object
|
|
123
|
+
#
|
|
124
|
+
# @return [String]
|
|
125
|
+
#
|
|
126
|
+
def build_header(user)
|
|
127
|
+
display_name =
|
|
128
|
+
if user.present?
|
|
129
|
+
UserResolver.display_name(user)
|
|
130
|
+
else
|
|
131
|
+
'Unknown'
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
timestamp = Time.now.utc.strftime('%m/%d/%Y %H:%M')
|
|
135
|
+
"### #{display_name} at #{timestamp}:"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Coerces visibility to :public unless the user is support.
|
|
139
|
+
#
|
|
140
|
+
# @param visibility [Symbol] requested visibility
|
|
141
|
+
# @param user [Object, nil] resolved user object
|
|
142
|
+
#
|
|
143
|
+
# @return [Symbol] :public or :internal
|
|
144
|
+
#
|
|
145
|
+
def resolve_visibility(visibility, user)
|
|
146
|
+
return :public unless visibility.to_sym == :internal
|
|
147
|
+
|
|
148
|
+
return :public if user.blank?
|
|
149
|
+
|
|
150
|
+
return :public unless UserResolver.support?(user)
|
|
151
|
+
|
|
152
|
+
:internal
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Sets responded_at on the issue metadata if this is the first support
|
|
156
|
+
# comment and the issue hasn't been responded to yet.
|
|
157
|
+
#
|
|
158
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
159
|
+
# @param user [Object, nil] resolved user object
|
|
160
|
+
#
|
|
161
|
+
# @return [void]
|
|
162
|
+
#
|
|
163
|
+
def mark_issue_responded_if_first_support_comment(issue, user)
|
|
164
|
+
return if user.nil?
|
|
165
|
+
|
|
166
|
+
return unless UserResolver.support?(user)
|
|
167
|
+
return unless issue.pms_issue?
|
|
168
|
+
|
|
169
|
+
return if issue.metadata.responded?
|
|
170
|
+
|
|
171
|
+
Issue.update!(
|
|
172
|
+
number: issue.number,
|
|
173
|
+
repo: issue.repo,
|
|
174
|
+
metadata: { responded_at: Time.now.utc.iso8601 },
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def initialize(**attrs)
|
|
180
|
+
@id = attrs.delete(:id)
|
|
181
|
+
@raw_body = nil
|
|
182
|
+
@metadata = CommentMetadata.new
|
|
183
|
+
super
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Persists the comment. Creates if new, updates if persisted.
|
|
187
|
+
#
|
|
188
|
+
# @raise [PlanMyStuff::StaleObjectError] on update if stale
|
|
189
|
+
#
|
|
190
|
+
# @return [self]
|
|
191
|
+
#
|
|
192
|
+
def save!
|
|
193
|
+
if new_record?
|
|
194
|
+
created = self.class.create!(
|
|
195
|
+
issue: issue,
|
|
196
|
+
body: body,
|
|
197
|
+
visibility: visibility || :public,
|
|
198
|
+
)
|
|
199
|
+
hydrate_from_comment(created)
|
|
200
|
+
else
|
|
201
|
+
update!(body: body)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Updates this comment on GitHub. Raises StaleObjectError if the remote
|
|
208
|
+
# has been modified since this instance was loaded.
|
|
209
|
+
#
|
|
210
|
+
# @param attrs [Hash] attributes to update (body:, visibility:)
|
|
211
|
+
#
|
|
212
|
+
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
213
|
+
#
|
|
214
|
+
# @return [self]
|
|
215
|
+
#
|
|
216
|
+
def update!(**attrs)
|
|
217
|
+
raise_if_stale!
|
|
218
|
+
|
|
219
|
+
new_body = attrs[:body] || body
|
|
220
|
+
meta_hash = metadata.to_h
|
|
221
|
+
|
|
222
|
+
if attrs.key?(:visibility)
|
|
223
|
+
new_visibility = attrs[:visibility].to_s
|
|
224
|
+
meta_hash[:visibility] = new_visibility
|
|
225
|
+
meta_hash[:updated_at] = Time.now.utc.iso8601
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
serialized = MetadataParser.serialize(meta_hash, new_body)
|
|
229
|
+
self.class.update!(id: id, repo: issue.repo, body: serialized)
|
|
230
|
+
|
|
231
|
+
reload
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Re-fetches this comment from GitHub and updates all local attributes.
|
|
235
|
+
#
|
|
236
|
+
# @return [self]
|
|
237
|
+
#
|
|
238
|
+
def reload
|
|
239
|
+
github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
|
|
240
|
+
hydrate_from_github(github_comment, issue: issue)
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @return [Boolean]
|
|
245
|
+
def pms_comment?
|
|
246
|
+
metadata.schema_version.present?
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Returns the comment visibility as a symbol.
|
|
250
|
+
# Uses the locally set value if present, otherwise falls back to metadata.
|
|
251
|
+
#
|
|
252
|
+
# @return [Symbol, nil] :public or :internal
|
|
253
|
+
#
|
|
254
|
+
def visibility
|
|
255
|
+
@visibility || metadata.visibility&.to_sym
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Checks if the comment is visible to the given user.
|
|
259
|
+
# Public PMS comments: visible to everyone the parent issue is visible to.
|
|
260
|
+
# Internal PMS comments: visible only to support users.
|
|
261
|
+
# Non-PMS comments: visible only to support users.
|
|
262
|
+
#
|
|
263
|
+
# @param user [Object, Integer] user object or user_id
|
|
264
|
+
#
|
|
265
|
+
# @return [Boolean]
|
|
266
|
+
#
|
|
267
|
+
def visible_to?(user)
|
|
268
|
+
resolved = PMS::UserResolver.resolve(user)
|
|
269
|
+
|
|
270
|
+
if pms_comment?
|
|
271
|
+
issue.visible_to?(resolved) && (visibility != :internal || PMS::UserResolver.support?(resolved))
|
|
272
|
+
else
|
|
273
|
+
PMS::UserResolver.support?(resolved)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Extracts the `### Name at timestamp:` header line from the comment body.
|
|
278
|
+
#
|
|
279
|
+
# @return [String, nil]
|
|
280
|
+
#
|
|
281
|
+
def header
|
|
282
|
+
match = (body || '').match(/\A(###\s.+?:\s*)\n/)
|
|
283
|
+
match&.captures&.first&.strip
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Returns the comment body with the header stripped.
|
|
287
|
+
#
|
|
288
|
+
# @return [String]
|
|
289
|
+
#
|
|
290
|
+
def body_without_header
|
|
291
|
+
(body || '').sub(/\A###\s.+?:\s*\n\n/, '')
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
# Populates this instance from a GitHub API response.
|
|
297
|
+
#
|
|
298
|
+
# @param github_comment [Object] Octokit comment response
|
|
299
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
300
|
+
#
|
|
301
|
+
# @return [void]
|
|
302
|
+
#
|
|
303
|
+
def hydrate_from_github(github_comment, issue:)
|
|
304
|
+
@id = read_field(github_comment, :id)
|
|
305
|
+
@raw_body = read_field(github_comment, :body)
|
|
306
|
+
@issue = issue
|
|
307
|
+
|
|
308
|
+
parsed = MetadataParser.parse(@raw_body)
|
|
309
|
+
@metadata = CommentMetadata.from_hash(parsed[:metadata])
|
|
310
|
+
@body = parsed[:body]
|
|
311
|
+
@visibility = @metadata.visibility&.to_sym
|
|
312
|
+
@persisted = true
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Copies attributes from another Comment instance into self.
|
|
316
|
+
#
|
|
317
|
+
# @param other [PlanMyStuff::Comment]
|
|
318
|
+
#
|
|
319
|
+
# @return [void]
|
|
320
|
+
#
|
|
321
|
+
def hydrate_from_comment(other)
|
|
322
|
+
@id = other.id
|
|
323
|
+
@body = other.body
|
|
324
|
+
@raw_body = other.raw_body
|
|
325
|
+
@issue = other.issue
|
|
326
|
+
@metadata = other.metadata
|
|
327
|
+
@visibility = other.visibility
|
|
328
|
+
@persisted = true
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Raises StaleObjectError if the remote comment has been modified
|
|
332
|
+
# since this instance was loaded.
|
|
333
|
+
#
|
|
334
|
+
# @raise [PlanMyStuff::StaleObjectError]
|
|
335
|
+
#
|
|
336
|
+
# @return [void]
|
|
337
|
+
#
|
|
338
|
+
def raise_if_stale!
|
|
339
|
+
return if new_record?
|
|
340
|
+
return if metadata.updated_at.nil?
|
|
341
|
+
|
|
342
|
+
github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
|
|
343
|
+
parsed = MetadataParser.parse(
|
|
344
|
+
github_comment.respond_to?(:body) ? github_comment.body : github_comment[:body],
|
|
345
|
+
)
|
|
346
|
+
remote_metadata = CommentMetadata.from_hash(parsed[:metadata])
|
|
347
|
+
remote_time = remote_metadata.updated_at
|
|
348
|
+
local_time = metadata.updated_at
|
|
349
|
+
|
|
350
|
+
return if remote_time.nil?
|
|
351
|
+
return if local_time && remote_time.to_i == local_time.to_i
|
|
352
|
+
|
|
353
|
+
raise(StaleObjectError.new(
|
|
354
|
+
"Comment ##{id} has been modified remotely",
|
|
355
|
+
local_updated_at: local_time,
|
|
356
|
+
remote_updated_at: remote_time,
|
|
357
|
+
))
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|