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,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/hash/deep_merge'
|
|
4
|
+
|
|
5
|
+
module PlanMyStuff
|
|
6
|
+
module Markdown
|
|
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.
|
|
10
|
+
#
|
|
11
|
+
# @param text [String] raw markdown text
|
|
12
|
+
# @param options [Hash] renderer-specific options (merged over config defaults)
|
|
13
|
+
# For :commonmarker - passed as `options:` to `Commonmarker.to_html`
|
|
14
|
+
# For :redcarpet - :render_options and :renderer are extracted for the HTML renderer;
|
|
15
|
+
# remaining keys are passed as extensions to `Redcarpet::Markdown.new`
|
|
16
|
+
#
|
|
17
|
+
# @return [String] rendered HTML
|
|
18
|
+
#
|
|
19
|
+
def render(text, options = {})
|
|
20
|
+
config = PlanMyStuff.configuration
|
|
21
|
+
merged = config.markdown_options.deep_merge(options)
|
|
22
|
+
|
|
23
|
+
case config.markdown_renderer
|
|
24
|
+
when :commonmarker
|
|
25
|
+
render_commonmarker(text, merged)
|
|
26
|
+
when :redcarpet
|
|
27
|
+
render_redcarpet(text, merged)
|
|
28
|
+
when nil
|
|
29
|
+
"<code>#{text}</code>"
|
|
30
|
+
else
|
|
31
|
+
raise(
|
|
32
|
+
ArgumentError,
|
|
33
|
+
"Unknown markdown_renderer: #{config.markdown_renderer.inspect}. Use :commonmarker, :redcarpet, or nil.",
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @param text [String]
|
|
41
|
+
# @param options [Hash]
|
|
42
|
+
#
|
|
43
|
+
# @return [String]
|
|
44
|
+
#
|
|
45
|
+
def render_commonmarker(text, options)
|
|
46
|
+
require('commonmarker') unless defined?(Commonmarker)
|
|
47
|
+
|
|
48
|
+
if options.empty?
|
|
49
|
+
Commonmarker.to_html(text)
|
|
50
|
+
else
|
|
51
|
+
Commonmarker.to_html(text, options: options)
|
|
52
|
+
end
|
|
53
|
+
rescue LoadError
|
|
54
|
+
raise(
|
|
55
|
+
PlanMyStuff::Error,
|
|
56
|
+
'commonmarker gem is required when markdown_renderer is :commonmarker. ' \
|
|
57
|
+
"Add gem 'commonmarker' to your Gemfile.",
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param text [String]
|
|
62
|
+
# @param options [Hash]
|
|
63
|
+
#
|
|
64
|
+
# @return [String]
|
|
65
|
+
#
|
|
66
|
+
def render_redcarpet(text, options)
|
|
67
|
+
require('redcarpet') unless defined?(Redcarpet)
|
|
68
|
+
|
|
69
|
+
options = options.dup
|
|
70
|
+
render_options = options.delete(:render_options) || {}
|
|
71
|
+
renderer_class = options.delete(:renderer) || Redcarpet::Render::HTML
|
|
72
|
+
|
|
73
|
+
Redcarpet::Markdown.new(renderer_class.new(render_options), options).render(text)
|
|
74
|
+
rescue LoadError
|
|
75
|
+
raise(
|
|
76
|
+
PlanMyStuff::Error,
|
|
77
|
+
'redcarpet gem is required when markdown_renderer is :redcarpet. ' \
|
|
78
|
+
"Add gem 'redcarpet' to your Gemfile.",
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PlanMyStuff
|
|
6
|
+
module MetadataParser
|
|
7
|
+
METADATA_PATTERN = /\A<!-- pms-metadata:(.*?) -->\n*/m
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Extracts metadata JSON from the raw body
|
|
12
|
+
#
|
|
13
|
+
# @param raw_body [String, nil]
|
|
14
|
+
#
|
|
15
|
+
# @return [Hash{Symbol => Hash, String}] :metadata (Hash, empty when absent) and :body (String)
|
|
16
|
+
#
|
|
17
|
+
def parse(raw_body)
|
|
18
|
+
return { metadata: {}, body: '' } if raw_body.blank?
|
|
19
|
+
|
|
20
|
+
match = raw_body.match(METADATA_PATTERN)
|
|
21
|
+
return { metadata: {}, body: raw_body } if match.nil?
|
|
22
|
+
|
|
23
|
+
metadata = JSON.parse(match[1], symbolize_names: true)
|
|
24
|
+
body = raw_body.sub(METADATA_PATTERN, '')
|
|
25
|
+
|
|
26
|
+
{ metadata: metadata, body: body }
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
{ metadata: {}, body: raw_body }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Serializes a metadata hash and body into the stored format
|
|
32
|
+
#
|
|
33
|
+
# @param metadata [Hash, PlanMyStuff::CustomFields]
|
|
34
|
+
# @param body [String]
|
|
35
|
+
#
|
|
36
|
+
# @return [String]
|
|
37
|
+
#
|
|
38
|
+
def serialize(metadata, body)
|
|
39
|
+
if !metadata.is_a?(Hash) && !metadata.is_a?(PlanMyStuff::CustomFields)
|
|
40
|
+
raise(ArgumentError, "metadata must be a Hash or PlanMyStuff::CustomFields, got #{metadata.class}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
json =
|
|
44
|
+
if metadata.is_a?(PlanMyStuff::CustomFields)
|
|
45
|
+
metadata.to_json
|
|
46
|
+
else
|
|
47
|
+
JSON.pretty_generate(metadata)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
"<!-- pms-metadata:#{json} -->\n\n#{body}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Wraps a GitHub Projects V2 project with its statuses, items, and fields.
|
|
5
|
+
# Class methods provide the public API for CRUD and query operations.
|
|
6
|
+
#
|
|
7
|
+
# Follows an ActiveRecord-style pattern:
|
|
8
|
+
# - `Project.find` / `Project.list` return persisted instances
|
|
9
|
+
# - `ProjectItem.add_item` / `ProjectItem.add_draft_item` for adding items
|
|
10
|
+
# - `ProjectItem.move_item` / `ProjectItem.assign` for item mutations
|
|
11
|
+
class Project < PlanMyStuff::ApplicationRecord
|
|
12
|
+
MAX_AUTO_PAGINATE_ITEMS = 500
|
|
13
|
+
ITEMS_PER_PAGE = 100
|
|
14
|
+
|
|
15
|
+
# @return [String] GitHub node ID
|
|
16
|
+
attr_reader :id
|
|
17
|
+
# @return [Integer] project number
|
|
18
|
+
attr_reader :number
|
|
19
|
+
# @return [Boolean] whether the project is closed
|
|
20
|
+
attr_reader :closed
|
|
21
|
+
|
|
22
|
+
# @return [String] project title
|
|
23
|
+
attr_accessor :title
|
|
24
|
+
# @return [String] project URL
|
|
25
|
+
attr_accessor :url
|
|
26
|
+
# @return [Array<Hash>] status options ({id:, name:})
|
|
27
|
+
attr_accessor :statuses
|
|
28
|
+
# @return [Array<Hash>] all field definitions
|
|
29
|
+
attr_accessor :fields
|
|
30
|
+
# @return [Array<PlanMyStuff::ProjectItem>] project items
|
|
31
|
+
attr_accessor :items
|
|
32
|
+
# @return [String, nil] cursor for next page (only in cursor mode)
|
|
33
|
+
attr_accessor :next_cursor
|
|
34
|
+
# @return [Boolean, nil] whether more pages exist (only in cursor mode)
|
|
35
|
+
attr_accessor :has_next_page
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Creates a new project in the configured organization.
|
|
39
|
+
#
|
|
40
|
+
# @param title [String]
|
|
41
|
+
#
|
|
42
|
+
# @return [Object]
|
|
43
|
+
#
|
|
44
|
+
def create(title:)
|
|
45
|
+
raise(NotImplementedError, "#{name}.create is not yet implemented")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Updates an existing project.
|
|
49
|
+
#
|
|
50
|
+
# @param project_number [Integer]
|
|
51
|
+
# @param title [String, nil]
|
|
52
|
+
#
|
|
53
|
+
# @return [Object]
|
|
54
|
+
#
|
|
55
|
+
def update(project_number:, title: nil)
|
|
56
|
+
raise(NotImplementedError, "#{name}.update is not yet implemented")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Lists all projects in the configured organization.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array<PlanMyStuff::Project>]
|
|
62
|
+
#
|
|
63
|
+
def list
|
|
64
|
+
org = PlanMyStuff.configuration.organization
|
|
65
|
+
data = PlanMyStuff.client.graphql(list_query, variables: { org: org })
|
|
66
|
+
|
|
67
|
+
nodes = data.dig(:organization, :projectsV2, :nodes) || []
|
|
68
|
+
|
|
69
|
+
nodes.map { |node| build_summary(node) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Finds a project by number with its statuses, items, and fields.
|
|
73
|
+
#
|
|
74
|
+
# @param number [Integer]
|
|
75
|
+
# @param paginate [Symbol] :auto (default) or :cursor
|
|
76
|
+
# @param cursor [String, nil] pagination cursor for :cursor mode (from a previous call's next_cursor)
|
|
77
|
+
#
|
|
78
|
+
# @return [PlanMyStuff::Project]
|
|
79
|
+
#
|
|
80
|
+
def find(number, paginate: :auto, cursor: nil)
|
|
81
|
+
org = PlanMyStuff.configuration.organization
|
|
82
|
+
|
|
83
|
+
case paginate
|
|
84
|
+
when :auto
|
|
85
|
+
find_auto_paginated(org, number)
|
|
86
|
+
when :cursor
|
|
87
|
+
find_with_cursor(org, number, cursor: cursor)
|
|
88
|
+
else
|
|
89
|
+
raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Resolves a project number, falling back to config.default_project_number.
|
|
96
|
+
#
|
|
97
|
+
# @param project_number [Integer, nil]
|
|
98
|
+
#
|
|
99
|
+
# @return [Integer]
|
|
100
|
+
#
|
|
101
|
+
def resolve_default_project_number(project_number)
|
|
102
|
+
return project_number if project_number.present?
|
|
103
|
+
|
|
104
|
+
PlanMyStuff.configuration.default_project_number ||
|
|
105
|
+
raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [String]
|
|
109
|
+
def list_query
|
|
110
|
+
<<~GRAPHQL
|
|
111
|
+
query($org: String!) {
|
|
112
|
+
organization(login: $org) {
|
|
113
|
+
projectsV2(first: 100) {
|
|
114
|
+
nodes {
|
|
115
|
+
id
|
|
116
|
+
number
|
|
117
|
+
title
|
|
118
|
+
url
|
|
119
|
+
closed
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
GRAPHQL
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [String]
|
|
128
|
+
def find_query
|
|
129
|
+
<<~GRAPHQL
|
|
130
|
+
query($org: String!, $number: Int!, $cursor: String) {
|
|
131
|
+
organization(login: $org) {
|
|
132
|
+
projectV2(number: $number) {
|
|
133
|
+
id
|
|
134
|
+
number
|
|
135
|
+
title
|
|
136
|
+
url
|
|
137
|
+
closed
|
|
138
|
+
fields(first: 50) {
|
|
139
|
+
nodes {
|
|
140
|
+
... on ProjectV2SingleSelectField {
|
|
141
|
+
id
|
|
142
|
+
name
|
|
143
|
+
options {
|
|
144
|
+
id
|
|
145
|
+
name
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
... on ProjectV2Field {
|
|
149
|
+
id
|
|
150
|
+
name
|
|
151
|
+
}
|
|
152
|
+
... on ProjectV2IterationField {
|
|
153
|
+
id
|
|
154
|
+
name
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
items(first: #{ITEMS_PER_PAGE}, after: $cursor) {
|
|
159
|
+
pageInfo {
|
|
160
|
+
hasNextPage
|
|
161
|
+
endCursor
|
|
162
|
+
}
|
|
163
|
+
nodes {
|
|
164
|
+
id
|
|
165
|
+
type
|
|
166
|
+
content {
|
|
167
|
+
... on Issue {
|
|
168
|
+
id
|
|
169
|
+
title
|
|
170
|
+
number
|
|
171
|
+
url
|
|
172
|
+
state
|
|
173
|
+
}
|
|
174
|
+
... on PullRequest {
|
|
175
|
+
id
|
|
176
|
+
title
|
|
177
|
+
number
|
|
178
|
+
url
|
|
179
|
+
state
|
|
180
|
+
}
|
|
181
|
+
... on DraftIssue {
|
|
182
|
+
id
|
|
183
|
+
title
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
fieldValues(first: 20) {
|
|
187
|
+
nodes {
|
|
188
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
189
|
+
name
|
|
190
|
+
field {
|
|
191
|
+
... on ProjectV2SingleSelectField {
|
|
192
|
+
name
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
... on ProjectV2ItemFieldTextValue {
|
|
197
|
+
text
|
|
198
|
+
field {
|
|
199
|
+
... on ProjectV2Field {
|
|
200
|
+
name
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
... on ProjectV2ItemFieldUserValue {
|
|
205
|
+
users(first: 10) {
|
|
206
|
+
nodes {
|
|
207
|
+
login
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
field {
|
|
211
|
+
... on ProjectV2Field {
|
|
212
|
+
name
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
GRAPHQL
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# @return [String]
|
|
227
|
+
def project_id_query
|
|
228
|
+
<<~GRAPHQL
|
|
229
|
+
query($org: String!, $number: Int!) {
|
|
230
|
+
organization(login: $org) {
|
|
231
|
+
projectV2(number: $number) {
|
|
232
|
+
id
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
GRAPHQL
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Builds a summary Project from a list query node.
|
|
240
|
+
#
|
|
241
|
+
# @param node [Hash]
|
|
242
|
+
#
|
|
243
|
+
# @return [PlanMyStuff::Project]
|
|
244
|
+
#
|
|
245
|
+
def build_summary(node)
|
|
246
|
+
project = new
|
|
247
|
+
project.__send__(:hydrate_summary, node)
|
|
248
|
+
project
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Builds a detailed Project from a find query response.
|
|
252
|
+
#
|
|
253
|
+
# @param graphql_project [Hash]
|
|
254
|
+
# @param items [Array<Hash>]
|
|
255
|
+
# @param next_cursor [String, nil]
|
|
256
|
+
# @param has_next_page [Boolean, nil]
|
|
257
|
+
#
|
|
258
|
+
# @return [PlanMyStuff::Project]
|
|
259
|
+
#
|
|
260
|
+
def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
|
|
261
|
+
project = new
|
|
262
|
+
project.__send__(
|
|
263
|
+
:hydrate_detail,
|
|
264
|
+
graphql_project,
|
|
265
|
+
items: items,
|
|
266
|
+
next_cursor: next_cursor,
|
|
267
|
+
has_next_page: has_next_page,
|
|
268
|
+
)
|
|
269
|
+
project
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @param org [String]
|
|
273
|
+
# @param number [Integer]
|
|
274
|
+
#
|
|
275
|
+
# @return [PlanMyStuff::Project]
|
|
276
|
+
#
|
|
277
|
+
def find_auto_paginated(org, number)
|
|
278
|
+
all_items = []
|
|
279
|
+
cursor = nil
|
|
280
|
+
raw_project = nil
|
|
281
|
+
|
|
282
|
+
loop do
|
|
283
|
+
page = fetch_project_page(org, number, cursor)
|
|
284
|
+
raw_project ||= page[:raw]
|
|
285
|
+
all_items.concat(page[:items])
|
|
286
|
+
|
|
287
|
+
break if !page[:has_next_page] || all_items.length >= MAX_AUTO_PAGINATE_ITEMS
|
|
288
|
+
|
|
289
|
+
cursor = page[:next_cursor]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
build_detail(raw_project, items: all_items)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @param org [String]
|
|
296
|
+
# @param number [Integer]
|
|
297
|
+
# @param cursor [String, nil]
|
|
298
|
+
#
|
|
299
|
+
# @return [PlanMyStuff::Project]
|
|
300
|
+
#
|
|
301
|
+
def find_with_cursor(org, number, cursor:)
|
|
302
|
+
page = fetch_project_page(org, number, cursor)
|
|
303
|
+
build_detail(
|
|
304
|
+
page[:raw],
|
|
305
|
+
items: page[:items],
|
|
306
|
+
next_cursor: page[:next_cursor],
|
|
307
|
+
has_next_page: page[:has_next_page],
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Fetches a single page of project data. Returns a lightweight hash
|
|
312
|
+
# for pagination loop consumption (not a Project instance).
|
|
313
|
+
#
|
|
314
|
+
# @param org [String]
|
|
315
|
+
# @param number [Integer]
|
|
316
|
+
# @param cursor [String, nil]
|
|
317
|
+
#
|
|
318
|
+
# @return [Hash] with :raw, :items, :next_cursor, :has_next_page
|
|
319
|
+
#
|
|
320
|
+
def fetch_project_page(org, number, cursor)
|
|
321
|
+
variables = { org: org, number: number }
|
|
322
|
+
variables[:cursor] = cursor if cursor
|
|
323
|
+
|
|
324
|
+
data = PlanMyStuff.client.graphql(find_query, variables: variables)
|
|
325
|
+
|
|
326
|
+
raw_project = data.dig(:organization, :projectV2)
|
|
327
|
+
page_info = raw_project.dig(:items, :pageInfo) || {}
|
|
328
|
+
items_data = raw_project.dig(:items, :nodes) || []
|
|
329
|
+
|
|
330
|
+
{
|
|
331
|
+
raw: raw_project,
|
|
332
|
+
items: items_data.map { |item| parse_project_item(item) },
|
|
333
|
+
next_cursor: page_info[:endCursor],
|
|
334
|
+
has_next_page: page_info[:hasNextPage],
|
|
335
|
+
}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# @param item [Hash] raw GraphQL project item node
|
|
339
|
+
#
|
|
340
|
+
# @return [Hash]
|
|
341
|
+
#
|
|
342
|
+
def parse_project_item(item)
|
|
343
|
+
content = item[:content] || {}
|
|
344
|
+
field_values = item.dig(:fieldValues, :nodes) || []
|
|
345
|
+
|
|
346
|
+
{
|
|
347
|
+
id: item[:id],
|
|
348
|
+
type: item[:type],
|
|
349
|
+
content_node_id: content[:id],
|
|
350
|
+
title: content[:title],
|
|
351
|
+
number: content[:number],
|
|
352
|
+
url: content[:url],
|
|
353
|
+
state: content[:state],
|
|
354
|
+
status: extract_item_status(field_values),
|
|
355
|
+
field_values: parse_field_values(field_values),
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# @param field_values [Array<Hash>]
|
|
360
|
+
#
|
|
361
|
+
# @return [String, nil]
|
|
362
|
+
#
|
|
363
|
+
def extract_item_status(field_values)
|
|
364
|
+
status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
|
|
365
|
+
|
|
366
|
+
status_value&.dig(:name)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# @param field_values [Array<Hash>]
|
|
370
|
+
#
|
|
371
|
+
# @return [Hash]
|
|
372
|
+
#
|
|
373
|
+
def parse_field_values(field_values)
|
|
374
|
+
result = {}
|
|
375
|
+
|
|
376
|
+
field_values.each do |fv|
|
|
377
|
+
field_name = fv.dig(:field, :name)
|
|
378
|
+
next unless field_name
|
|
379
|
+
|
|
380
|
+
value = fv[:name] || fv[:text]
|
|
381
|
+
users_node = fv[:users]
|
|
382
|
+
if users_node
|
|
383
|
+
value = (users_node[:nodes] || []).map { |u| u[:login] }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
result[field_name] = value
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
result
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Resolves a project number to its node ID.
|
|
393
|
+
#
|
|
394
|
+
# @param org [String]
|
|
395
|
+
# @param project_number [Integer]
|
|
396
|
+
#
|
|
397
|
+
# @return [String]
|
|
398
|
+
#
|
|
399
|
+
def resolve_project_id(org, project_number)
|
|
400
|
+
data = PlanMyStuff.client.graphql(
|
|
401
|
+
project_id_query,
|
|
402
|
+
variables: { org: org, number: project_number },
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
data.dig(:organization, :projectV2, :id)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# @see super
|
|
410
|
+
def initialize(**attrs)
|
|
411
|
+
@id = attrs.delete(:id)
|
|
412
|
+
@number = attrs.delete(:number)
|
|
413
|
+
@closed = attrs.delete(:closed)
|
|
414
|
+
super
|
|
415
|
+
@statuses ||= []
|
|
416
|
+
@fields ||= []
|
|
417
|
+
@items ||= []
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Returns the Status single-select field definition.
|
|
421
|
+
#
|
|
422
|
+
# @return [Hash] with :id and :options keys
|
|
423
|
+
#
|
|
424
|
+
# @raise [PlanMyStuff::APIError] if no Status field exists
|
|
425
|
+
#
|
|
426
|
+
def status_field
|
|
427
|
+
field = fields.find { |f| f[:name] == 'Status' && f[:options] }
|
|
428
|
+
|
|
429
|
+
raise(APIError, "No 'Status' field found on project ##{number}") unless field
|
|
430
|
+
|
|
431
|
+
field
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private
|
|
435
|
+
|
|
436
|
+
# Populates this instance from a list query node (summary only).
|
|
437
|
+
#
|
|
438
|
+
# @param node [Hash]
|
|
439
|
+
#
|
|
440
|
+
# @return [void]
|
|
441
|
+
#
|
|
442
|
+
def hydrate_summary(node)
|
|
443
|
+
@id = node[:id]
|
|
444
|
+
@number = node[:number]
|
|
445
|
+
@title = node[:title]
|
|
446
|
+
@url = node[:url]
|
|
447
|
+
@closed = node[:closed]
|
|
448
|
+
@persisted = true
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Populates this instance from a detailed find query response.
|
|
452
|
+
#
|
|
453
|
+
# @param graphql_project [Hash]
|
|
454
|
+
# @param items [Array<Hash>]
|
|
455
|
+
# @param next_cursor [String, nil]
|
|
456
|
+
# @param has_next_page [Boolean, nil]
|
|
457
|
+
#
|
|
458
|
+
# @return [void]
|
|
459
|
+
#
|
|
460
|
+
def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
|
|
461
|
+
@id = graphql_project[:id]
|
|
462
|
+
@number = graphql_project[:number]
|
|
463
|
+
@title = graphql_project[:title]
|
|
464
|
+
@url = graphql_project[:url]
|
|
465
|
+
@closed = graphql_project[:closed]
|
|
466
|
+
|
|
467
|
+
fields_nodes = graphql_project.dig(:fields, :nodes) || []
|
|
468
|
+
@statuses = extract_statuses(fields_nodes)
|
|
469
|
+
@fields = extract_fields(fields_nodes)
|
|
470
|
+
@items = items.map { |item_hash| ProjectItem.build(item_hash, project: self) }
|
|
471
|
+
@next_cursor = next_cursor
|
|
472
|
+
@has_next_page = has_next_page
|
|
473
|
+
@persisted = true
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Extracts status options from the "Status" single-select field.
|
|
477
|
+
#
|
|
478
|
+
# @param fields_nodes [Array<Hash>]
|
|
479
|
+
#
|
|
480
|
+
# @return [Array<Hash>]
|
|
481
|
+
#
|
|
482
|
+
def extract_statuses(fields_nodes)
|
|
483
|
+
status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
|
|
484
|
+
|
|
485
|
+
return [] unless status_field
|
|
486
|
+
|
|
487
|
+
(status_field[:options] || []).map do |opt|
|
|
488
|
+
{ id: opt[:id], name: opt[:name] }
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# @param fields_nodes [Array<Hash>]
|
|
493
|
+
#
|
|
494
|
+
# @return [Array<Hash>]
|
|
495
|
+
#
|
|
496
|
+
def extract_fields(fields_nodes)
|
|
497
|
+
fields_nodes.map do |f|
|
|
498
|
+
field = { id: f[:id], name: f[:name] }
|
|
499
|
+
field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
|
|
500
|
+
field
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|