activeproject 0.3.0 → 0.5.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 +4 -4
- data/README.md +201 -55
- data/lib/active_project/adapters/base.rb +154 -14
- data/lib/active_project/adapters/basecamp/comments.rb +34 -0
- data/lib/active_project/adapters/basecamp/connection.rb +6 -24
- data/lib/active_project/adapters/basecamp/issues.rb +6 -5
- data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
- data/lib/active_project/adapters/fizzy/columns.rb +116 -0
- data/lib/active_project/adapters/fizzy/comments.rb +129 -0
- data/lib/active_project/adapters/fizzy/connection.rb +41 -0
- data/lib/active_project/adapters/fizzy/issues.rb +221 -0
- data/lib/active_project/adapters/fizzy/projects.rb +105 -0
- data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
- data/lib/active_project/adapters/github_project/comments.rb +91 -0
- data/lib/active_project/adapters/github_project/connection.rb +58 -0
- data/lib/active_project/adapters/github_project/helpers.rb +100 -0
- data/lib/active_project/adapters/github_project/issues.rb +287 -0
- data/lib/active_project/adapters/github_project/projects.rb +139 -0
- data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
- data/lib/active_project/adapters/github_project.rb +8 -0
- data/lib/active_project/adapters/github_project_adapter.rb +65 -0
- data/lib/active_project/adapters/github_repo/connection.rb +62 -0
- data/lib/active_project/adapters/github_repo/issues.rb +242 -0
- data/lib/active_project/adapters/github_repo/projects.rb +116 -0
- data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
- data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
- data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
- data/lib/active_project/adapters/jira/comments.rb +41 -0
- data/lib/active_project/adapters/jira/connection.rb +15 -15
- data/lib/active_project/adapters/jira/issues.rb +21 -7
- data/lib/active_project/adapters/jira/projects.rb +3 -1
- data/lib/active_project/adapters/jira/transitions.rb +2 -1
- data/lib/active_project/adapters/jira/webhooks.rb +5 -7
- data/lib/active_project/adapters/jira_adapter.rb +23 -3
- data/lib/active_project/adapters/trello/comments.rb +34 -0
- data/lib/active_project/adapters/trello/connection.rb +12 -9
- data/lib/active_project/adapters/trello/issues.rb +7 -5
- data/lib/active_project/adapters/trello/webhooks.rb +5 -7
- data/lib/active_project/adapters/trello_adapter.rb +5 -3
- data/lib/active_project/association_proxy.rb +3 -2
- data/lib/active_project/configuration.rb +6 -3
- data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
- data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
- data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
- data/lib/active_project/configurations/github_configuration.rb +57 -0
- data/lib/active_project/configurations/jira_configuration.rb +54 -0
- data/lib/active_project/configurations/trello_configuration.rb +24 -2
- data/lib/active_project/connections/base.rb +35 -0
- data/lib/active_project/connections/graph_ql.rb +83 -0
- data/lib/active_project/connections/http_client.rb +79 -0
- data/lib/active_project/connections/pagination.rb +44 -0
- data/lib/active_project/connections/rest.rb +33 -0
- data/lib/active_project/error_mapper.rb +38 -0
- data/lib/active_project/errors.rb +13 -0
- data/lib/active_project/railtie.rb +1 -3
- data/lib/active_project/resources/base_resource.rb +13 -14
- data/lib/active_project/resources/comment.rb +46 -2
- data/lib/active_project/resources/issue.rb +106 -18
- data/lib/active_project/resources/persistable_resource.rb +47 -0
- data/lib/active_project/resources/project.rb +1 -1
- data/lib/active_project/status_mapper.rb +145 -0
- data/lib/active_project/version.rb +1 -1
- data/lib/active_project/webhook_event.rb +34 -12
- data/lib/activeproject.rb +9 -6
- metadata +74 -16
- data/lib/active_project/adapters/http_client.rb +0 -71
- data/lib/active_project/adapters/pagination.rb +0 -68
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module ActiveProject
|
|
9
|
+
module Adapters
|
|
10
|
+
# Adapter for interacting with the Fizzy API.
|
|
11
|
+
# Fizzy is a Kanban-style project tracking tool by 37signals.
|
|
12
|
+
# Implements the interface defined in ActiveProject::Adapters::Base.
|
|
13
|
+
# API Docs: https://github.com/basecamp/fizzy (see docs/API.md)
|
|
14
|
+
class FizzyAdapter < Base
|
|
15
|
+
attr_reader :config, :base_url
|
|
16
|
+
|
|
17
|
+
include Fizzy::Connection
|
|
18
|
+
include Fizzy::Projects
|
|
19
|
+
include Fizzy::Issues
|
|
20
|
+
include Fizzy::Comments
|
|
21
|
+
include Fizzy::Columns
|
|
22
|
+
|
|
23
|
+
# --- Resource Factories ---
|
|
24
|
+
|
|
25
|
+
# Returns a factory for Project resources (Boards).
|
|
26
|
+
# @return [ResourceFactory<Resources::Project>]
|
|
27
|
+
def projects
|
|
28
|
+
ResourceFactory.new(adapter: self, resource_class: Resources::Project)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns a factory for Issue resources (Cards).
|
|
32
|
+
# @return [ResourceFactory<Resources::Issue>]
|
|
33
|
+
def issues
|
|
34
|
+
ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# --- Implementation of Base methods ---
|
|
38
|
+
|
|
39
|
+
# Retrieves details for the currently authenticated user.
|
|
40
|
+
# Uses /my/identity endpoint which returns accounts and user info.
|
|
41
|
+
# @return [ActiveProject::Resources::User] The user object.
|
|
42
|
+
# @raise [ActiveProject::AuthenticationError] if authentication fails.
|
|
43
|
+
# @raise [ActiveProject::ApiError] for other API-related errors.
|
|
44
|
+
def get_current_user
|
|
45
|
+
# Fizzy's /my/identity is at the root, not under account_slug
|
|
46
|
+
# We need to make a request to the base URL without the account_slug
|
|
47
|
+
base_without_slug = @base_url.sub(%r{/\d+/$}, "/")
|
|
48
|
+
response = @connection.get("#{base_without_slug}my/identity")
|
|
49
|
+
identity_data = parse_response(response)
|
|
50
|
+
|
|
51
|
+
# Get the first account's user data
|
|
52
|
+
first_account = identity_data["accounts"]&.first
|
|
53
|
+
return nil unless first_account
|
|
54
|
+
|
|
55
|
+
user_data = first_account["user"]
|
|
56
|
+
map_user_data(user_data)
|
|
57
|
+
rescue Faraday::Error => e
|
|
58
|
+
handle_faraday_error(e)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Checks if the adapter can successfully authenticate and connect to the service.
|
|
62
|
+
# @return [Boolean] true if connection is successful, false otherwise.
|
|
63
|
+
def connected?
|
|
64
|
+
get_current_user
|
|
65
|
+
true
|
|
66
|
+
rescue ActiveProject::AuthenticationError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Helper method for making requests.
|
|
73
|
+
def make_request(method, path, body = nil, query = {})
|
|
74
|
+
request(method, path, body: body, query: query)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Parses JSON response body.
|
|
78
|
+
def parse_response(response)
|
|
79
|
+
return {} if response.body.nil? || response.body.empty?
|
|
80
|
+
|
|
81
|
+
JSON.parse(response.body)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
{}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Extracts relative path from full URL.
|
|
87
|
+
def extract_path_from_url(url)
|
|
88
|
+
url.sub(@base_url, "").sub(%r{^/}, "")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Parses the 'next' link URL from the Link header.
|
|
92
|
+
def parse_next_link(link_header)
|
|
93
|
+
return nil unless link_header
|
|
94
|
+
|
|
95
|
+
links = link_header.split(",").map(&:strip)
|
|
96
|
+
next_link = links.find { |link| link.end_with?('rel="next"') }
|
|
97
|
+
return nil unless next_link
|
|
98
|
+
|
|
99
|
+
match = next_link.match(/<([^>]+)>/)
|
|
100
|
+
match ? match[1] : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Handles Faraday errors.
|
|
104
|
+
def handle_faraday_error(error)
|
|
105
|
+
status = error.response_status
|
|
106
|
+
body = error.response_body
|
|
107
|
+
|
|
108
|
+
parsed_body = begin
|
|
109
|
+
JSON.parse(body)
|
|
110
|
+
rescue StandardError
|
|
111
|
+
{ "error" => body }
|
|
112
|
+
end
|
|
113
|
+
message = parsed_body["error"] || parsed_body["message"] || "Unknown Fizzy Error"
|
|
114
|
+
|
|
115
|
+
case status
|
|
116
|
+
when 401, 403
|
|
117
|
+
raise AuthenticationError, "Fizzy authentication/authorization failed (Status: #{status}): #{message}"
|
|
118
|
+
when 404
|
|
119
|
+
raise NotFoundError, "Fizzy resource not found (Status: 404): #{message}"
|
|
120
|
+
when 429
|
|
121
|
+
retry_after = error.response_headers&.dig("Retry-After")
|
|
122
|
+
msg = "Fizzy rate limit exceeded (Status: 429)"
|
|
123
|
+
msg += ". Retry after #{retry_after} seconds." if retry_after
|
|
124
|
+
raise RateLimitError, msg
|
|
125
|
+
when 400, 422
|
|
126
|
+
raise ValidationError.new("Fizzy validation failed (Status: #{status}): #{message}",
|
|
127
|
+
status_code: status, response_body: body)
|
|
128
|
+
else
|
|
129
|
+
raise ApiError.new("Fizzy API error (Status: #{status || 'N/A'}): #{message}",
|
|
130
|
+
original_error: error, status_code: status, response_body: body)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Maps raw Fizzy User data hash to a User resource.
|
|
135
|
+
# @param user_data [Hash, nil] Raw user data from Fizzy API.
|
|
136
|
+
# @return [Resources::User, nil]
|
|
137
|
+
def map_user_data(user_data)
|
|
138
|
+
return nil unless user_data && user_data["id"]
|
|
139
|
+
|
|
140
|
+
Resources::User.new(
|
|
141
|
+
self,
|
|
142
|
+
id: user_data["id"],
|
|
143
|
+
name: user_data["name"],
|
|
144
|
+
email: user_data["email_address"],
|
|
145
|
+
adapter_source: :fizzy,
|
|
146
|
+
raw_data: user_data
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Adapters
|
|
5
|
+
module GithubProject
|
|
6
|
+
module Comments
|
|
7
|
+
#
|
|
8
|
+
# Add a comment to the underlying Issue or PR of a ProjectV2Item.
|
|
9
|
+
# For draft items (no linked content) this raises NotImplementedError,
|
|
10
|
+
# because GitHub does not expose a comment thread for drafts.
|
|
11
|
+
#
|
|
12
|
+
# @param item_id [String] ProjectV2Item node-ID
|
|
13
|
+
# @param body [String] Markdown text
|
|
14
|
+
# @param ctx [Hash] MUST include :content_node_id for speed,
|
|
15
|
+
# otherwise we’ll query.
|
|
16
|
+
#
|
|
17
|
+
def add_comment(item_id, body, ctx = {})
|
|
18
|
+
content_id =
|
|
19
|
+
ctx[:content_node_id] ||
|
|
20
|
+
begin
|
|
21
|
+
q = <<~GQL
|
|
22
|
+
query($id:ID!){
|
|
23
|
+
node(id:$id){
|
|
24
|
+
... on ProjectV2Item{ content{ __typename ... on Issue{id} ... on PullRequest{id} } }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
GQL
|
|
28
|
+
request_gql(query: q, variables: { id: item_id })
|
|
29
|
+
.dig("node", "content", "id")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
raise NotImplementedError, "Draft cards cannot receive comments" unless content_id
|
|
33
|
+
|
|
34
|
+
mutation = <<~GQL
|
|
35
|
+
mutation($subject:ID!, $body:String!){
|
|
36
|
+
addComment(input:{subjectId:$subject, body:$body}){ commentEdge{ node{ id body author{login}
|
|
37
|
+
createdAt updatedAt } } }
|
|
38
|
+
}
|
|
39
|
+
GQL
|
|
40
|
+
comment_node = request_gql(query: mutation,
|
|
41
|
+
variables: { subject: content_id, body: body })
|
|
42
|
+
.dig("addComment", "commentEdge", "node")
|
|
43
|
+
|
|
44
|
+
map_comment(comment_node, item_id)
|
|
45
|
+
end
|
|
46
|
+
alias create_comment add_comment
|
|
47
|
+
|
|
48
|
+
def update_comment(comment_id, body)
|
|
49
|
+
mutation = <<~GQL
|
|
50
|
+
mutation($id:ID!, $body:String!){
|
|
51
|
+
updateIssueComment(input:{id:$id, body:$body}){
|
|
52
|
+
issueComment { id body updatedAt }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
GQL
|
|
56
|
+
node = request_gql(query: mutation,
|
|
57
|
+
variables: { id: comment_id, body: body })
|
|
58
|
+
.dig("updateIssueComment", "issueComment")
|
|
59
|
+
|
|
60
|
+
map_comment(node, node["id"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def delete_comment(comment_id)
|
|
64
|
+
mutation = <<~GQL
|
|
65
|
+
mutation($id:ID!){
|
|
66
|
+
deleteIssueComment(input:{id:$id}){ clientMutationId }
|
|
67
|
+
}
|
|
68
|
+
GQL
|
|
69
|
+
request_gql(query: mutation, variables: { id: comment_id })
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def map_comment(node, item_id)
|
|
76
|
+
Resources::Comment.new(
|
|
77
|
+
self,
|
|
78
|
+
id: node["id"],
|
|
79
|
+
body: node["body"],
|
|
80
|
+
author: map_user(node["author"]),
|
|
81
|
+
created_at: Time.parse(node["createdAt"]),
|
|
82
|
+
updated_at: Time.parse(node["updatedAt"]),
|
|
83
|
+
issue_id: item_id,
|
|
84
|
+
adapter_source: :github,
|
|
85
|
+
raw_data: node
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Adapters
|
|
5
|
+
module GithubProject
|
|
6
|
+
module Connection
|
|
7
|
+
include Connections::GraphQl
|
|
8
|
+
|
|
9
|
+
ENDPOINT = "https://api.github.com/graphql"
|
|
10
|
+
|
|
11
|
+
def initialize(config:)
|
|
12
|
+
super(config: config)
|
|
13
|
+
token = @config.options.fetch(:access_token)
|
|
14
|
+
|
|
15
|
+
init_graphql(
|
|
16
|
+
endpoint: ENDPOINT,
|
|
17
|
+
token: token,
|
|
18
|
+
extra_headers: {
|
|
19
|
+
"X-Github-Next-Global-ID" => "1"
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# monkey-patch method for this instance only
|
|
24
|
+
class << self
|
|
25
|
+
prepend InstanceGraphqlPatcher
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module InstanceGraphqlPatcher
|
|
30
|
+
def request_gql(query:, variables: {})
|
|
31
|
+
payload = { query: query, variables: variables }.to_json
|
|
32
|
+
res = request(:post, "", body: payload)
|
|
33
|
+
handle_deprecation_warnings!(res)
|
|
34
|
+
raise_graphql_errors!(res)
|
|
35
|
+
res["data"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_deprecation_warnings!(res)
|
|
39
|
+
warnings = res.dig("extensions", "warnings") || []
|
|
40
|
+
warnings.each do |w|
|
|
41
|
+
next unless w["type"] == "DEPRECATION"
|
|
42
|
+
|
|
43
|
+
legacy = w.dig("data", "legacy_global_id")
|
|
44
|
+
updated = w.dig("data", "next_global_id")
|
|
45
|
+
next unless legacy && updated
|
|
46
|
+
|
|
47
|
+
(@_deprecation_map ||= {})[legacy] = updated
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def upgraded_id(legacy_id)
|
|
52
|
+
@_deprecation_map&.fetch(legacy_id, legacy_id)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Adapters
|
|
5
|
+
module GithubProject
|
|
6
|
+
module Helpers
|
|
7
|
+
#
|
|
8
|
+
# Cursor-based pagination wrapper for GraphQL connections.
|
|
9
|
+
#
|
|
10
|
+
# @param query [String] the GraphQL query with $after variable
|
|
11
|
+
# @param variables [Hash] initial variables (without :after)
|
|
12
|
+
# @param connection_path [Array<String>] JSON path to the connection hash
|
|
13
|
+
# @yield [vars] yields the variables hash for each page so caller can execute the request
|
|
14
|
+
# @return [Array<Hash>] all nodes from every page
|
|
15
|
+
#
|
|
16
|
+
def fetch_all_pages(query, variables:, connection_path:, &request_block)
|
|
17
|
+
# turn the (possibly-nil) block into something callable
|
|
18
|
+
request_fn =
|
|
19
|
+
request_block ||
|
|
20
|
+
->(v) { request_gql(query: query, variables: v) }
|
|
21
|
+
|
|
22
|
+
after = nil
|
|
23
|
+
nodes = []
|
|
24
|
+
|
|
25
|
+
loop do
|
|
26
|
+
data = request_fn.call(variables.merge(after: after))
|
|
27
|
+
conn = data.dig(*connection_path)
|
|
28
|
+
nodes.concat(conn["nodes"])
|
|
29
|
+
break unless conn["pageInfo"]["hasNextPage"]
|
|
30
|
+
|
|
31
|
+
after = conn["pageInfo"]["endCursor"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
nodes
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#
|
|
38
|
+
# Resolve a user/org login → GraphQL node-ID (memoised).
|
|
39
|
+
#
|
|
40
|
+
def owner_node_id(login)
|
|
41
|
+
@owner_id_cache ||= {}
|
|
42
|
+
return @owner_id_cache[login] if @owner_id_cache.key?(login)
|
|
43
|
+
|
|
44
|
+
q = <<~GQL
|
|
45
|
+
query($login:String!){
|
|
46
|
+
organization(login:$login){ id }
|
|
47
|
+
user(login:$login){ id }
|
|
48
|
+
}
|
|
49
|
+
GQL
|
|
50
|
+
|
|
51
|
+
data = request_gql(query: q, variables: { login: login })
|
|
52
|
+
id = data.dig("organization", "id") || data.dig("user", "id")
|
|
53
|
+
raise ActiveProject::NotFoundError, "GitHub owner “#{login}” not found" unless id
|
|
54
|
+
|
|
55
|
+
id = upgraded_id(id) if respond_to?(:upgraded_id)
|
|
56
|
+
|
|
57
|
+
@owner_id_cache[login] = id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#
|
|
61
|
+
# Convert a compact user hash returned by GraphQL into Resources::User.
|
|
62
|
+
#
|
|
63
|
+
def map_user(u)
|
|
64
|
+
return nil unless u
|
|
65
|
+
|
|
66
|
+
Resources::User.new(
|
|
67
|
+
self,
|
|
68
|
+
id: u["id"] || u["login"],
|
|
69
|
+
name: u["login"],
|
|
70
|
+
email: u["email"],
|
|
71
|
+
adapter_source: :github,
|
|
72
|
+
raw_data: u
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def project_field_ids(project_id)
|
|
77
|
+
@field_cache ||= {}
|
|
78
|
+
@field_cache[project_id] ||= begin
|
|
79
|
+
q = <<~GQL
|
|
80
|
+
query($id:ID!){
|
|
81
|
+
node(id:$id){
|
|
82
|
+
... on ProjectV2{
|
|
83
|
+
fields(first:50){
|
|
84
|
+
nodes{
|
|
85
|
+
... on ProjectV2FieldCommon{ id name }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
GQL
|
|
92
|
+
nodes = request_gql(query: q, variables: { id: project_id })
|
|
93
|
+
.dig("node", "fields", "nodes")
|
|
94
|
+
nodes.to_h { |f| [ f["name"], f["id"] ] }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Adapters
|
|
5
|
+
module GithubProject
|
|
6
|
+
#
|
|
7
|
+
# Project-item CRUD operations for GitHub Projects v2.
|
|
8
|
+
#
|
|
9
|
+
# This module exists because GitHub decided that "issues," "drafts," and "project items"
|
|
10
|
+
# are three different species, and we have to *unify the galaxy.*
|
|
11
|
+
#
|
|
12
|
+
module Issues
|
|
13
|
+
include Helpers
|
|
14
|
+
# Like everything on GitHub: decent default, weird edge cases. Why not 25 like a normal person?
|
|
15
|
+
DEFAULT_ITEM_PAGE_SIZE = 50
|
|
16
|
+
|
|
17
|
+
#
|
|
18
|
+
# Lists *every* issue or draft in a GitHub Project.
|
|
19
|
+
# Handles pagination the GitHub way: manually, painfully, endlessly.
|
|
20
|
+
#
|
|
21
|
+
# options[:page_size] → control how much data you want per jump into hyperspace.
|
|
22
|
+
#
|
|
23
|
+
def list_issues(project_id, options = {})
|
|
24
|
+
page_size = options.fetch(:page_size, DEFAULT_ITEM_PAGE_SIZE)
|
|
25
|
+
query = <<~GQL
|
|
26
|
+
query($id:ID!, $first:Int!, $after:String){
|
|
27
|
+
node(id:$id){
|
|
28
|
+
... on ProjectV2{
|
|
29
|
+
items(first:$first, after:$after){
|
|
30
|
+
nodes{
|
|
31
|
+
id type content{__typename ... on Issue{ id number title body state
|
|
32
|
+
assignees(first:10){nodes{login id}}
|
|
33
|
+
reporter:author{login} } }
|
|
34
|
+
createdAt updatedAt
|
|
35
|
+
}
|
|
36
|
+
pageInfo{hasNextPage endCursor}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
GQL
|
|
42
|
+
|
|
43
|
+
nodes = fetch_all_pages(
|
|
44
|
+
query,
|
|
45
|
+
variables: { id: project_id, first: page_size },
|
|
46
|
+
connection_path: %w[node items]
|
|
47
|
+
) { |vars| request_gql(query: query, variables: vars) }
|
|
48
|
+
|
|
49
|
+
nodes.map { |n| map_item_to_issue(n, project_id) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#
|
|
53
|
+
# Fetch a single issue or draft item by its mysterious GraphQL node ID.
|
|
54
|
+
# If it's missing, you get a 404 so you can take a day off.
|
|
55
|
+
#
|
|
56
|
+
def find_issue(item_id, _ctx = {})
|
|
57
|
+
query = <<~GQL
|
|
58
|
+
query($id:ID!){
|
|
59
|
+
node(id:$id){
|
|
60
|
+
... on ProjectV2Item{
|
|
61
|
+
id
|
|
62
|
+
type
|
|
63
|
+
fieldValues(first:20){
|
|
64
|
+
nodes{
|
|
65
|
+
... on ProjectV2ItemFieldTextValue{
|
|
66
|
+
text
|
|
67
|
+
field { ... on ProjectV2FieldCommon { name } }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
content{
|
|
72
|
+
__typename
|
|
73
|
+
... on Issue{
|
|
74
|
+
id number title body state
|
|
75
|
+
assignees(first:10){nodes{login id}}
|
|
76
|
+
reporter:author{login}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
createdAt
|
|
80
|
+
updatedAt
|
|
81
|
+
project { id }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
GQL
|
|
86
|
+
node = request_gql(query: query, variables: { id: item_id })["node"]
|
|
87
|
+
raise NotFoundError, "Project item #{item_id} not found" unless node
|
|
88
|
+
|
|
89
|
+
map_item_to_issue(node, node.dig("project", "id"))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#
|
|
93
|
+
# Create a new issue in the project.
|
|
94
|
+
#
|
|
95
|
+
# Choose your destiny:
|
|
96
|
+
# - Pass :content_id → links an existing GitHub Issue or PR into the project.
|
|
97
|
+
|
|
98
|
+
def create_issue(project_id, attrs)
|
|
99
|
+
content_id = attrs[:content_id] or
|
|
100
|
+
raise ArgumentError, "DraftIssues not supported—pass :content_id of a real Issue or PR"
|
|
101
|
+
|
|
102
|
+
mutation = <<~GQL
|
|
103
|
+
mutation($project:ID!, $content:ID!) {
|
|
104
|
+
addProjectV2ItemById(input:{projectId:$project, contentId:$content}) {
|
|
105
|
+
item { id }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
GQL
|
|
109
|
+
|
|
110
|
+
data = request_gql(
|
|
111
|
+
query: mutation,
|
|
112
|
+
variables: { project: project_id, content: content_id }
|
|
113
|
+
).dig("addProjectV2ItemById", "item")
|
|
114
|
+
|
|
115
|
+
find_issue(data["id"])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
#
|
|
119
|
+
# Update fields on an existing ProjectV2Item.
|
|
120
|
+
#
|
|
121
|
+
# You can adjust:
|
|
122
|
+
# - Title (text field)
|
|
123
|
+
# - Status (single-select nightmare field)
|
|
124
|
+
#
|
|
125
|
+
# NOTE: Requires you to preload field mappings, because GitHub’s GraphQL API
|
|
126
|
+
# refuses to help unless you memorize all their withcrafts.
|
|
127
|
+
#
|
|
128
|
+
def update_issue_original(project_id, item_id, attrs = {})
|
|
129
|
+
field_ids = project_field_ids(project_id)
|
|
130
|
+
|
|
131
|
+
# -- Update Title (basic) --
|
|
132
|
+
if attrs[:title]
|
|
133
|
+
mutation = <<~GQL
|
|
134
|
+
mutation($proj:ID!, $item:ID!, $field:ID!, $title:String!) {
|
|
135
|
+
updateProjectV2ItemFieldValue(input:{
|
|
136
|
+
projectId:$proj, itemId:$item,
|
|
137
|
+
fieldId:$field, value:{text:$title}
|
|
138
|
+
}) { projectV2Item { id } }
|
|
139
|
+
}
|
|
140
|
+
GQL
|
|
141
|
+
request_gql(query: mutation,
|
|
142
|
+
variables: {
|
|
143
|
+
proj: project_id,
|
|
144
|
+
item: item_id,
|
|
145
|
+
field: field_ids.fetch("Title"),
|
|
146
|
+
title: attrs[:title]
|
|
147
|
+
})
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# -- Update Status (dark side difficulty) --
|
|
151
|
+
if attrs[:status]
|
|
152
|
+
status_field_id = field_ids.fetch("Status")
|
|
153
|
+
option_id = status_option_id(project_id, attrs[:status])
|
|
154
|
+
mutation = <<~GQL
|
|
155
|
+
mutation($proj:ID!, $item:ID!, $field:ID!, $opt:String!) {
|
|
156
|
+
updateProjectV2ItemFieldValue(input:{
|
|
157
|
+
projectId:$proj, itemId:$item,
|
|
158
|
+
fieldId:$field, value:{singleSelectOptionId:$opt}
|
|
159
|
+
}) { projectV2Item { id } }
|
|
160
|
+
}
|
|
161
|
+
GQL
|
|
162
|
+
request_gql(query: mutation,
|
|
163
|
+
variables: { proj: project_id, item: item_id,
|
|
164
|
+
field: status_field_id, opt: option_id })
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
find_issue(item_id)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
#
|
|
171
|
+
# Delete a ProjectV2Item from a project.
|
|
172
|
+
# No soft delete, no grace period — just *execute Order 66*.
|
|
173
|
+
#
|
|
174
|
+
def delete_issue_original(project_id, item_id)
|
|
175
|
+
mutation = <<~GQL
|
|
176
|
+
mutation($proj:ID!, $item:ID!){
|
|
177
|
+
deleteProjectV2Item(input:{projectId:$proj, itemId:$item}){deletedItemId}
|
|
178
|
+
}
|
|
179
|
+
GQL
|
|
180
|
+
request_gql(query: mutation,
|
|
181
|
+
variables: { proj: project_id, item: item_id })
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
#
|
|
186
|
+
# Check if a status symbol like :in_progress is known for a project.
|
|
187
|
+
# Avoids exploding like fukushima reactor if you try to set a status that doesn't exist.
|
|
188
|
+
#
|
|
189
|
+
def status_known?(project_id, sym)
|
|
190
|
+
(@status_cache && @status_cache[project_id] || {}).key?(sym)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
#
|
|
196
|
+
# Turn a GraphQL project item node into a clean, beautiful Resources::Issue object.
|
|
197
|
+
#
|
|
198
|
+
# Because the only thing worse than undocumented fields is undocumented *types.*
|
|
199
|
+
#
|
|
200
|
+
def map_item_to_issue(node, project_id)
|
|
201
|
+
content = node["content"] || {}
|
|
202
|
+
typename = content["__typename"]
|
|
203
|
+
title =
|
|
204
|
+
if typename == "Issue"
|
|
205
|
+
content["title"]
|
|
206
|
+
else
|
|
207
|
+
# Draft card – try to pull “Title” field value
|
|
208
|
+
fv = node.dig("fieldValues", "nodes")
|
|
209
|
+
&.find { |n| n.dig("field", "name") == "Title" }
|
|
210
|
+
"(draft) #{fv&.dig('text')}"
|
|
211
|
+
end
|
|
212
|
+
description = typename == "Issue" ? content["body"] : nil
|
|
213
|
+
status = :open
|
|
214
|
+
status = :closed if typename == "Issue" && content["state"] == "CLOSED"
|
|
215
|
+
|
|
216
|
+
assignees = if content["assignees"] && content["assignees"]["nodes"]
|
|
217
|
+
content["assignees"]["nodes"].map { |u| map_user(u) }
|
|
218
|
+
else
|
|
219
|
+
[]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
reporter = map_user(content["reporter"]) if content["reporter"]
|
|
223
|
+
|
|
224
|
+
Resources::Issue.new(
|
|
225
|
+
self,
|
|
226
|
+
id: node["id"],
|
|
227
|
+
key: typename == "Issue" ? content["number"] : nil,
|
|
228
|
+
title: title,
|
|
229
|
+
description: description,
|
|
230
|
+
status: status,
|
|
231
|
+
assignees: assignees,
|
|
232
|
+
reporter: reporter,
|
|
233
|
+
project_id: project_id,
|
|
234
|
+
created_at: Time.parse(node["createdAt"]),
|
|
235
|
+
updated_at: Time.parse(node["updatedAt"]),
|
|
236
|
+
due_on: nil,
|
|
237
|
+
priority: nil,
|
|
238
|
+
adapter_source: :github,
|
|
239
|
+
raw_data: node
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
#
|
|
244
|
+
# Look up the option ID for a given status symbol, or raise a tantrum.
|
|
245
|
+
#
|
|
246
|
+
def status_option_id(project_id, symbol)
|
|
247
|
+
@status_cache ||= Concurrent::Map.new
|
|
248
|
+
cache = (@status_cache[project_id] ||= load_status_options(project_id))
|
|
249
|
+
|
|
250
|
+
return cache[symbol] if cache.key?(symbol)
|
|
251
|
+
|
|
252
|
+
available = cache.keys.map(&:inspect).join(", ")
|
|
253
|
+
raise ArgumentError,
|
|
254
|
+
"No status #{symbol.inspect} in project; valid symbols are: #{available}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
#
|
|
258
|
+
# Load all valid status options for a project’s "Status" field.
|
|
259
|
+
# Only way to win is not to play.
|
|
260
|
+
#
|
|
261
|
+
def load_status_options(project_id)
|
|
262
|
+
q = <<~GQL
|
|
263
|
+
query($id:ID!){
|
|
264
|
+
node(id:$id){
|
|
265
|
+
... on ProjectV2{
|
|
266
|
+
field(name:"Status"){
|
|
267
|
+
... on ProjectV2SingleSelectField{
|
|
268
|
+
options{ id name }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
GQL
|
|
275
|
+
|
|
276
|
+
opts = request_gql(query: q, variables: { id: project_id })
|
|
277
|
+
.dig("node", "field", "options")
|
|
278
|
+
|
|
279
|
+
opts.to_h do |o|
|
|
280
|
+
key = o["name"].downcase.tr(" ", "_").to_sym
|
|
281
|
+
[ key, o["id"] ]
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|