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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +201 -55
  3. data/lib/active_project/adapters/base.rb +154 -14
  4. data/lib/active_project/adapters/basecamp/comments.rb +34 -0
  5. data/lib/active_project/adapters/basecamp/connection.rb +6 -24
  6. data/lib/active_project/adapters/basecamp/issues.rb +6 -5
  7. data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
  8. data/lib/active_project/adapters/fizzy/columns.rb +116 -0
  9. data/lib/active_project/adapters/fizzy/comments.rb +129 -0
  10. data/lib/active_project/adapters/fizzy/connection.rb +41 -0
  11. data/lib/active_project/adapters/fizzy/issues.rb +221 -0
  12. data/lib/active_project/adapters/fizzy/projects.rb +105 -0
  13. data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
  14. data/lib/active_project/adapters/github_project/comments.rb +91 -0
  15. data/lib/active_project/adapters/github_project/connection.rb +58 -0
  16. data/lib/active_project/adapters/github_project/helpers.rb +100 -0
  17. data/lib/active_project/adapters/github_project/issues.rb +287 -0
  18. data/lib/active_project/adapters/github_project/projects.rb +139 -0
  19. data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
  20. data/lib/active_project/adapters/github_project.rb +8 -0
  21. data/lib/active_project/adapters/github_project_adapter.rb +65 -0
  22. data/lib/active_project/adapters/github_repo/connection.rb +62 -0
  23. data/lib/active_project/adapters/github_repo/issues.rb +242 -0
  24. data/lib/active_project/adapters/github_repo/projects.rb +116 -0
  25. data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
  26. data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
  27. data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
  28. data/lib/active_project/adapters/jira/comments.rb +41 -0
  29. data/lib/active_project/adapters/jira/connection.rb +15 -15
  30. data/lib/active_project/adapters/jira/issues.rb +21 -7
  31. data/lib/active_project/adapters/jira/projects.rb +3 -1
  32. data/lib/active_project/adapters/jira/transitions.rb +2 -1
  33. data/lib/active_project/adapters/jira/webhooks.rb +5 -7
  34. data/lib/active_project/adapters/jira_adapter.rb +23 -3
  35. data/lib/active_project/adapters/trello/comments.rb +34 -0
  36. data/lib/active_project/adapters/trello/connection.rb +12 -9
  37. data/lib/active_project/adapters/trello/issues.rb +7 -5
  38. data/lib/active_project/adapters/trello/webhooks.rb +5 -7
  39. data/lib/active_project/adapters/trello_adapter.rb +5 -3
  40. data/lib/active_project/association_proxy.rb +3 -2
  41. data/lib/active_project/configuration.rb +6 -3
  42. data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
  43. data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
  44. data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
  45. data/lib/active_project/configurations/github_configuration.rb +57 -0
  46. data/lib/active_project/configurations/jira_configuration.rb +54 -0
  47. data/lib/active_project/configurations/trello_configuration.rb +24 -2
  48. data/lib/active_project/connections/base.rb +35 -0
  49. data/lib/active_project/connections/graph_ql.rb +83 -0
  50. data/lib/active_project/connections/http_client.rb +79 -0
  51. data/lib/active_project/connections/pagination.rb +44 -0
  52. data/lib/active_project/connections/rest.rb +33 -0
  53. data/lib/active_project/error_mapper.rb +38 -0
  54. data/lib/active_project/errors.rb +13 -0
  55. data/lib/active_project/railtie.rb +1 -3
  56. data/lib/active_project/resources/base_resource.rb +13 -14
  57. data/lib/active_project/resources/comment.rb +46 -2
  58. data/lib/active_project/resources/issue.rb +106 -18
  59. data/lib/active_project/resources/persistable_resource.rb +47 -0
  60. data/lib/active_project/resources/project.rb +1 -1
  61. data/lib/active_project/status_mapper.rb +145 -0
  62. data/lib/active_project/version.rb +1 -1
  63. data/lib/active_project/webhook_event.rb +34 -12
  64. data/lib/activeproject.rb +9 -6
  65. metadata +74 -16
  66. data/lib/active_project/adapters/http_client.rb +0 -71
  67. 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