activeproject 0.2.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +248 -51
  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 +10 -23
  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/basecamp_adapter.rb +2 -11
  9. data/lib/active_project/adapters/fizzy/columns.rb +116 -0
  10. data/lib/active_project/adapters/fizzy/comments.rb +129 -0
  11. data/lib/active_project/adapters/fizzy/connection.rb +41 -0
  12. data/lib/active_project/adapters/fizzy/issues.rb +221 -0
  13. data/lib/active_project/adapters/fizzy/projects.rb +105 -0
  14. data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
  15. data/lib/active_project/adapters/github_project/comments.rb +91 -0
  16. data/lib/active_project/adapters/github_project/connection.rb +58 -0
  17. data/lib/active_project/adapters/github_project/helpers.rb +100 -0
  18. data/lib/active_project/adapters/github_project/issues.rb +287 -0
  19. data/lib/active_project/adapters/github_project/projects.rb +139 -0
  20. data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
  21. data/lib/active_project/adapters/github_project.rb +8 -0
  22. data/lib/active_project/adapters/github_project_adapter.rb +65 -0
  23. data/lib/active_project/adapters/github_repo/connection.rb +62 -0
  24. data/lib/active_project/adapters/github_repo/issues.rb +242 -0
  25. data/lib/active_project/adapters/github_repo/projects.rb +116 -0
  26. data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
  27. data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
  28. data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
  29. data/lib/active_project/adapters/jira/comments.rb +41 -0
  30. data/lib/active_project/adapters/jira/connection.rb +43 -24
  31. data/lib/active_project/adapters/jira/issues.rb +21 -7
  32. data/lib/active_project/adapters/jira/projects.rb +3 -1
  33. data/lib/active_project/adapters/jira/transitions.rb +2 -1
  34. data/lib/active_project/adapters/jira/webhooks.rb +5 -7
  35. data/lib/active_project/adapters/jira_adapter.rb +23 -30
  36. data/lib/active_project/adapters/trello/comments.rb +34 -0
  37. data/lib/active_project/adapters/trello/connection.rb +28 -21
  38. data/lib/active_project/adapters/trello/issues.rb +7 -5
  39. data/lib/active_project/adapters/trello/webhooks.rb +5 -7
  40. data/lib/active_project/adapters/trello_adapter.rb +5 -25
  41. data/lib/active_project/association_proxy.rb +3 -2
  42. data/lib/active_project/async.rb +9 -0
  43. data/lib/active_project/configuration.rb +6 -3
  44. data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
  45. data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
  46. data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
  47. data/lib/active_project/configurations/github_configuration.rb +57 -0
  48. data/lib/active_project/configurations/jira_configuration.rb +54 -0
  49. data/lib/active_project/configurations/trello_configuration.rb +24 -2
  50. data/lib/active_project/connections/base.rb +35 -0
  51. data/lib/active_project/connections/graph_ql.rb +83 -0
  52. data/lib/active_project/connections/http_client.rb +79 -0
  53. data/lib/active_project/connections/pagination.rb +44 -0
  54. data/lib/active_project/connections/rest.rb +33 -0
  55. data/lib/active_project/error_mapper.rb +38 -0
  56. data/lib/active_project/errors.rb +13 -0
  57. data/lib/active_project/railtie.rb +33 -0
  58. data/lib/active_project/resource_factory.rb +18 -0
  59. data/lib/active_project/resources/base_resource.rb +13 -14
  60. data/lib/active_project/resources/comment.rb +46 -2
  61. data/lib/active_project/resources/issue.rb +106 -18
  62. data/lib/active_project/resources/persistable_resource.rb +47 -0
  63. data/lib/active_project/resources/project.rb +1 -1
  64. data/lib/active_project/status_mapper.rb +145 -0
  65. data/lib/active_project/version.rb +1 -1
  66. data/lib/active_project/webhook_event.rb +34 -12
  67. data/lib/activeproject.rb +11 -6
  68. metadata +107 -6
@@ -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
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubProject
6
+ module Projects
7
+ include Helpers
8
+
9
+ #
10
+ # List all ProjectsV2 for a GitHub user.
11
+ #
12
+ # Because nothing says "weekend hustle" like spinning up yet another project,
13
+ # posting "🚀 Day 1 of #BuildInPublic" on X, and immediately abandoning it by Tuesday.
14
+ #
15
+ def list_projects(options = {})
16
+ owner = options[:owner] || @config.owner
17
+ page_size = options.fetch(:page_size, 50)
18
+
19
+ # ---- build query template ------------------------------------------------
20
+ query_tmpl = lambda { |kind|
21
+ # rubocop:disable Layout/LineLength
22
+ <<~GQL
23
+ query($login:String!, $first:Int!, $after:String){
24
+ #{kind}(login:$login){
25
+ projectsV2(first:$first, after:$after){
26
+ nodes{ id number title }
27
+ pageInfo{ hasNextPage endCursor }
28
+ }
29
+ }
30
+ }
31
+ GQL
32
+ # rubocop:enable Layout/LineLength
33
+ }
34
+
35
+ # ---- fetch pages, trying user first, then organisation -------------------
36
+ begin
37
+ nodes = fetch_all_pages(
38
+ query_tmpl.call("user"),
39
+ variables: { login: owner, first: page_size },
40
+ connection_path: %w[user projectsV2]
41
+ )
42
+ rescue ActiveProject::NotFoundError, ActiveProject::ValidationError
43
+ nodes = fetch_all_pages(
44
+ query_tmpl.call("organization"),
45
+ variables: { login: owner, first: page_size },
46
+ connection_path: %w[organization projectsV2]
47
+ )
48
+ end
49
+
50
+ nodes.map { |proj| build_project_resource(proj) }
51
+ end
52
+
53
+ #
54
+ # Find a project either by its public-facing number or internal node ID.
55
+ #
56
+ # Supports both:
57
+ # - people who proudly know their project number (respect)
58
+ # - and people copy-pasting weird node IDs at 2am on a Saturday.
59
+ #
60
+ def find_project(id_or_number)
61
+ if id_or_number.to_s =~ /^\d+$/
62
+ # UI-visible number path: the civilized way.
63
+ owner = @config.owner
64
+ num = id_or_number.to_i
65
+ q = <<~GQL
66
+ query($login: String!, $num: Int!) {
67
+ user(login: $login) {
68
+ projectV2(number: $num) { id number title }
69
+ }
70
+ }
71
+ GQL
72
+ data = request_gql(query: q, variables: { login: owner, num: num })
73
+ proj = data.dig("user", "projectV2") or raise NotFoundError
74
+ else
75
+ # Node ID path: the "I swear I know what I'm doing" path.
76
+ proj = request_gql(
77
+ query: "query($id:ID!){ node(id:$id){ ... on ProjectV2 { id number title }}}",
78
+ variables: { id: id_or_number }
79
+ )["node"]
80
+ end
81
+ build_project_resource(proj)
82
+ end
83
+
84
+ #
85
+ # Create a shiny new GitHub Project.
86
+ #
87
+ # Required:
88
+ # - :name → preferably a trendy one like "TasklyAgent" or "ZenboardAI"
89
+ #
90
+ # Step 1: create project.
91
+ # Step 2: tweet "Just shipped something huge 🔥 #buildinpublic".
92
+ # Step 3: forget about it.
93
+ #
94
+ def create_project(attributes)
95
+ name = attributes[:name] or raise ArgumentError, "Missing :name"
96
+ owner_id = owner_node_id(@config.owner)
97
+ q = <<~GQL
98
+ mutation($name:String!, $owner:ID!){
99
+ createProjectV2(input:{title:$name,ownerId:$owner}) { projectV2 { id number title } }
100
+ }
101
+ GQL
102
+ proj = request_gql(query: q, variables: { name: name, owner: owner_id })
103
+ .dig("createProjectV2", "projectV2")
104
+ build_project_resource(proj)
105
+ end
106
+
107
+ #
108
+ # Soft-delete a project by "closing" it.
109
+ #
110
+ # GitHub doesn't believe in real deletion yet, only ghosting.
111
+ # Just like that app idea you posted about but never launched.
112
+ #
113
+ def delete_project(project_id)
114
+ q = <<~GQL
115
+ mutation($id:ID!){ updateProjectV2(input:{projectId:$id, closed:true}) { clientMutationId } }
116
+ GQL
117
+ request_gql(query: q, variables: { id: project_id })
118
+ true
119
+ end
120
+
121
+ private
122
+
123
+ #
124
+ # Turn raw GraphQL sludge into a proper Project resource.
125
+ #
126
+ # For when you need your side project to at least *look* real in screenshots.
127
+ #
128
+ def build_project_resource(proj)
129
+ Resources::Project.new(self,
130
+ id: proj["id"],
131
+ key: proj["number"],
132
+ name: proj["title"],
133
+ adapter_source: :github,
134
+ raw_data: proj)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module ActiveProject
7
+ module Adapters
8
+ module GithubProject
9
+ # GitHub Project webhook processing for GitHub Projects V2.
10
+ # Handles project item and project events from GitHub webhooks.
11
+ module Webhooks
12
+ # Verifies GitHub webhook signature using SHA256 HMAC.
13
+ # @param request_body [String] Raw request body
14
+ # @param signature_header [String] Value of X-Hub-Signature-256 header
15
+ # @param webhook_secret [String] GitHub webhook secret
16
+ # @return [Boolean] true if signature is valid
17
+ def verify_webhook_signature(request_body, signature_header, webhook_secret: nil)
18
+ return false unless webhook_secret && signature_header
19
+
20
+ # GitHub sends signature as "sha256=<hash>"
21
+ return false unless signature_header.start_with?("sha256=")
22
+
23
+ expected_signature = signature_header[7..-1] # Remove "sha256=" prefix
24
+ computed_signature = OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, request_body)
25
+
26
+ # Use secure comparison to prevent timing attacks
27
+ secure_compare(expected_signature, computed_signature)
28
+ end
29
+
30
+ # Parses GitHub webhook payload into standardized WebhookEvent.
31
+ # Supports projects_v2_item and projects_v2 events.
32
+ # @param request_body [String] Raw JSON payload
33
+ # @param headers [Hash] Request headers (for X-GitHub-Event)
34
+ # @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unsupported
35
+ def parse_webhook(request_body, headers = {})
36
+ payload = JSON.parse(request_body)
37
+ github_event = headers["X-GitHub-Event"] || headers["x-github-event"]
38
+
39
+ case github_event
40
+ when "projects_v2_item"
41
+ parse_project_item_event(payload)
42
+ when "projects_v2"
43
+ parse_project_event(payload)
44
+ else
45
+ # Return nil for unsupported events (not an error)
46
+ nil
47
+ end
48
+ rescue JSON::ParserError
49
+ # Invalid JSON - return nil
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ # Secure string comparison to prevent timing attacks
56
+ def secure_compare(a, b)
57
+ return false unless a.bytesize == b.bytesize
58
+
59
+ l = a.unpack("C*")
60
+ r = b.unpack("C*")
61
+
62
+ result = 0
63
+ l.zip(r) { |x, y| result |= x ^ y }
64
+ result == 0
65
+ end
66
+
67
+ # Parses projects_v2_item events (item created, edited, deleted, etc.)
68
+ def parse_project_item_event(payload)
69
+ action = payload["action"]
70
+ item = payload["projects_v2_item"]
71
+ return nil unless item
72
+
73
+ # Map GitHub actions to ActiveProject event types
74
+ event_type = case action
75
+ when "created" then "issue_created"
76
+ when "edited" then "issue_updated"
77
+ when "deleted" then "issue_deleted"
78
+ when "archived" then "issue_updated"
79
+ when "restored" then "issue_updated"
80
+ else action # Pass through unknown actions
81
+ end
82
+
83
+ # Extract project info
84
+ project = payload["projects_v2"]
85
+ project_id = project&.dig("node_id")
86
+
87
+ # Extract actor (sender)
88
+ sender = payload["sender"]
89
+ actor = map_user_data(sender) if sender
90
+
91
+ # Build changes hash for updates
92
+ changes = {}
93
+ if action == "edited" && payload["changes"]
94
+ payload["changes"].each do |field, change_data|
95
+ changes[field] = {
96
+ from: change_data["from"],
97
+ to: change_data["to"]
98
+ }
99
+ end
100
+ end
101
+
102
+ # Extract content (linked issue/PR) if available
103
+ content = item["content"]
104
+ object_key = content&.dig("number")&.to_s
105
+ object_data = content || item
106
+
107
+ WebhookEvent.new(
108
+ event_type: event_type,
109
+ object_kind: "issue", # GitHub Project items are treated as issues
110
+ event_object_id: item["node_id"],
111
+ object_key: object_key,
112
+ project_id: project_id,
113
+ actor: actor,
114
+ timestamp: Time.parse(payload["created_at"] || Time.now.iso8601),
115
+ adapter_source: webhook_type,
116
+ changes: changes,
117
+ object_data: object_data,
118
+ raw_data: payload
119
+ )
120
+ end
121
+
122
+ # Parses projects_v2 events (project created, edited, deleted, etc.)
123
+ def parse_project_event(payload)
124
+ action = payload["action"]
125
+ project = payload["projects_v2"]
126
+ return nil unless project
127
+
128
+ # Map GitHub actions to ActiveProject event types
129
+ event_type = case action
130
+ when "created" then "project_created"
131
+ when "edited" then "project_updated"
132
+ when "deleted" then "project_deleted"
133
+ else action # Pass through unknown actions
134
+ end
135
+
136
+ # Extract actor (sender)
137
+ sender = payload["sender"]
138
+ actor = map_user_data(sender) if sender
139
+
140
+ # Build changes hash for updates
141
+ changes = {}
142
+ if action == "edited" && payload["changes"]
143
+ payload["changes"].each do |field, change_data|
144
+ changes[field] = {
145
+ from: change_data["from"],
146
+ to: change_data["to"]
147
+ }
148
+ end
149
+ end
150
+
151
+ WebhookEvent.new(
152
+ event_type: event_type,
153
+ object_kind: "project",
154
+ event_object_id: project["node_id"],
155
+ object_key: project["number"]&.to_s,
156
+ project_id: project["node_id"],
157
+ actor: actor,
158
+ timestamp: Time.parse(payload["created_at"] || Time.now.iso8601),
159
+ adapter_source: webhook_type,
160
+ changes: changes,
161
+ object_data: project,
162
+ raw_data: payload
163
+ )
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubProject
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ class GithubProjectAdapter < Base
6
+ include GithubProject::Connection
7
+ include GithubProject::Projects
8
+ include GithubProject::Issues
9
+ include GithubProject::Comments
10
+ include GithubProject::Webhooks
11
+
12
+ def projects = ResourceFactory.new(adapter: self, resource_class: Resources::Project)
13
+ def issues = ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
14
+
15
+ def get_current_user
16
+ q = "query{viewer{ id login name email }}"
17
+ data = request_gql(query: q)
18
+ map_user_data(data["viewer"])
19
+ end
20
+
21
+ def connected? = begin
22
+ !get_current_user.nil?
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ def adapter_type
28
+ :github_project
29
+ end
30
+
31
+ def update_issue(id, attributes, context = {})
32
+ project_id = context[:project_id] || raise(ArgumentError,
33
+ "GithubProjectAdapter requires :project_id in context")
34
+ update_issue_internal(project_id, id, attributes)
35
+ end
36
+
37
+ def delete_issue(id, context = {})
38
+ project_id = context[:project_id] || raise(ArgumentError,
39
+ "GithubProjectAdapter requires :project_id in context")
40
+ delete_issue_internal(project_id, id)
41
+ end
42
+
43
+ private
44
+
45
+ def map_user_data(person_data)
46
+ return nil unless person_data && person_data["id"]
47
+
48
+ Resources::User.new(self, # Pass adapter instance
49
+ id: person_data["id"],
50
+ name: person_data["name"],
51
+ email: person_data["email"],
52
+ adapter_source: :github_project,
53
+ raw_data: person_data)
54
+ end
55
+
56
+ def update_issue_internal(project_id, item_id, attrs = {})
57
+ update_issue_original(project_id, item_id, attrs)
58
+ end
59
+
60
+ def delete_issue_internal(project_id, item_id)
61
+ delete_issue_original(project_id, item_id)
62
+ end
63
+ end
64
+ end
65
+ end