activeproject 0.1.0 → 0.1.1

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +4 -2
  3. data/lib/active_project/adapters/base.rb +3 -7
  4. data/lib/active_project/adapters/basecamp/comments.rb +27 -0
  5. data/lib/active_project/adapters/basecamp/connection.rb +49 -0
  6. data/lib/active_project/adapters/basecamp/issues.rb +139 -0
  7. data/lib/active_project/adapters/basecamp/lists.rb +54 -0
  8. data/lib/active_project/adapters/basecamp/projects.rb +110 -0
  9. data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
  10. data/lib/active_project/adapters/basecamp_adapter.rb +46 -437
  11. data/lib/active_project/adapters/jira/comments.rb +28 -0
  12. data/lib/active_project/adapters/jira/connection.rb +47 -0
  13. data/lib/active_project/adapters/jira/issues.rb +132 -0
  14. data/lib/active_project/adapters/jira/projects.rb +100 -0
  15. data/lib/active_project/adapters/jira/transitions.rb +68 -0
  16. data/lib/active_project/adapters/jira/webhooks.rb +89 -0
  17. data/lib/active_project/adapters/jira_adapter.rb +57 -483
  18. data/lib/active_project/adapters/trello/comments.rb +21 -0
  19. data/lib/active_project/adapters/trello/connection.rb +37 -0
  20. data/lib/active_project/adapters/trello/issues.rb +117 -0
  21. data/lib/active_project/adapters/trello/lists.rb +27 -0
  22. data/lib/active_project/adapters/trello/projects.rb +82 -0
  23. data/lib/active_project/adapters/trello/webhooks.rb +91 -0
  24. data/lib/active_project/adapters/trello_adapter.rb +54 -376
  25. data/lib/active_project/association_proxy.rb +9 -2
  26. data/lib/active_project/configuration.rb +1 -3
  27. data/lib/active_project/configurations/trello_configuration.rb +1 -3
  28. data/lib/active_project/resource_factory.rb +20 -10
  29. data/lib/active_project/resources/issue.rb +0 -3
  30. data/lib/active_project/resources/project.rb +0 -1
  31. data/lib/active_project/version.rb +3 -1
  32. data/lib/activeproject.rb +2 -2
  33. metadata +20 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b69a05303fa485956e8bdf7e56984aa5690df8f4bb7b9119253d760f0940916
4
- data.tar.gz: 45dab21a69a42325b067f01510f47e16f9b8c9ebb312b11793e1b0c447f563a6
3
+ metadata.gz: 46d0e95fea9e696d7f35f2f8ad2cc32a82f9c068aaf834a9daa2650824650620
4
+ data.tar.gz: 709c98e0c3bab903db0447c7d53559c55e5c7c98cffc511a771c3b9f08cfa2fc
5
5
  SHA512:
6
- metadata.gz: fcd6726cf79cfa6819ecfe3de62d5502d94317bfbb824a4f5e4c3579b836fa65a06ee408ff69f601d1e9ba47b7d3d45848e326e48608b413a7431b87e208dbff
7
- data.tar.gz: 23191e09bd239c8fc21552c64520de969e70875fbedcb514449dbe1fe4f157cc204b150eb45a9b71ddb0af0edf0774fc4934ba1deb9cafe5202ca64b1245d7e2
6
+ metadata.gz: 7fb8db4e4f952b10364e91727ebec93e6f0e65f6ebf7d5bd331dab77013bd3fc34d100f522a76d5f8d7014d947081a16fdee34535a6327cbabfe6bb74cbfc00b
7
+ data.tar.gz: 15231c9e5b94a103f21b643f219ffebf4747223f2662a410389ecbf142cc1f618c96384dd5314ba56d00afa6bdf4d0f81cfc0ba33bd580f2fa4999dae8a954eb
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
- require "bundler/setup"
1
+ # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
3
+ require 'bundler/setup'
4
+
5
+ require 'bundler/gem_tasks'
@@ -18,7 +18,6 @@ module ActiveProject
18
18
  raise NotImplementedError, "#{self.class.name} must implement #find_project"
19
19
  end
20
20
 
21
-
22
21
  # Creates a new project.
23
22
  # @param attributes [Hash] Project attributes (platform-specific).
24
23
  # @return [ActiveProject::Project] The created project object.
@@ -35,7 +34,6 @@ module ActiveProject
35
34
  raise NotImplementedError, "#{self.class.name} does not support #create_list or must implement #create_list"
36
35
  end
37
36
 
38
-
39
37
  # Deletes a project. Use with caution.
40
38
  # @param project_id [String, Integer] The ID or key of the project to delete.
41
39
  # @return [Boolean] true if deletion was successful (or accepted), false otherwise.
@@ -44,7 +42,6 @@ module ActiveProject
44
42
  raise NotImplementedError, "#{self.class.name} does not support #delete_project or must implement it"
45
43
  end
46
44
 
47
-
48
45
  # Lists issues within a specific project.
49
46
  # @param project_id [String, Integer] The ID or key of the project.
50
47
  # @param options [Hash] Optional filtering/pagination options.
@@ -88,11 +85,11 @@ module ActiveProject
88
85
  end
89
86
 
90
87
  # Verifies the signature of an incoming webhook request, if supported by the platform.
91
- # @param request_body [String] The raw request body.
92
- # @param signature_header [String] The value of the platform-specific signature header (e.g., 'X-Trello-Webhook').
88
+ # @param _request_body [String] The raw request body.
89
+ # @param _signature_header [String] The value of the platform-specific signature header (e.g., 'X-Trello-Webhook').
93
90
  # @return [Boolean] true if the signature is valid or verification is not supported/needed, false otherwise.
94
91
  # @raise [NotImplementedError] if verification is applicable but not implemented by a subclass.
95
- def verify_webhook_signature(request_body, signature_header)
92
+ def verify_webhook_signature(_request_body, _signature_header)
96
93
  # Default implementation assumes no verification needed or supported.
97
94
  # Adapters supporting verification should override this.
98
95
  true
@@ -107,7 +104,6 @@ module ActiveProject
107
104
  raise NotImplementedError, "#{self.class.name} must implement #parse_webhook"
108
105
  end
109
106
 
110
-
111
107
  # Retrieves details for the currently authenticated user.
112
108
  # @return [ActiveProject::Resources::User] The user object.
113
109
  # @raise [ActiveProject::AuthenticationError] if authentication fails.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Comments
7
+ # Adds a comment to a To-do in Basecamp.
8
+ # @param todo_id [String, Integer] The ID of the Basecamp To-do.
9
+ # @param comment_body [String] The comment text (HTML).
10
+ # @param context [Hash] Required context: { project_id: '...' }.
11
+ # @return [ActiveProject::Resources::Comment] The created comment resource.
12
+ def add_comment(todo_id, comment_body, context = {})
13
+ project_id = context[:project_id]
14
+ unless project_id
15
+ raise ArgumentError,
16
+ "Missing required context: :project_id must be provided for BasecampAdapter#add_comment"
17
+ end
18
+
19
+ path = "buckets/#{project_id}/recordings/#{todo_id}/comments.json"
20
+ payload = { content: comment_body }.to_json
21
+ comment_data = make_request(:post, path, payload)
22
+ map_comment_data(comment_data, todo_id.to_i)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Connection
7
+ BASE_URL_TEMPLATE = "https://3.basecampapi.com/%<account_id>s/"
8
+ # Initializes the Basecamp Adapter.
9
+ # @param config [Configurations::BaseAdapterConfiguration] The configuration object for Basecamp.
10
+ # @raise [ArgumentError] if required configuration options (:account_id, :access_token) are missing.
11
+ def initialize(config:)
12
+ # For now, Basecamp uses the base config. If specific Basecamp options are added,
13
+ # create BasecampConfiguration and check for that type.
14
+ unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
15
+ raise ArgumentError, "BasecampAdapter requires a BaseAdapterConfiguration object"
16
+ end
17
+
18
+ @config = config
19
+
20
+ account_id = @config.options[:account_id].to_s # Ensure it's a string
21
+ access_token = @config.options[:access_token]
22
+
23
+ unless account_id && !account_id.empty? && access_token && !access_token.empty?
24
+ raise ArgumentError, "BasecampAdapter configuration requires :account_id and :access_token"
25
+ end
26
+
27
+ @base_url = format(BASE_URL_TEMPLATE, account_id: account_id)
28
+ @connection = initialize_connection
29
+ end
30
+
31
+ private
32
+
33
+ # Initializes the Faraday connection object.
34
+ def initialize_connection
35
+ access_token = @config.options[:access_token]
36
+
37
+ Faraday.new(url: @base_url) do |conn|
38
+ conn.request :authorization, :bearer, access_token
39
+ conn.request :retry
40
+ conn.response :raise_error
41
+ conn.headers["Content-Type"] = "application/json"
42
+ conn.headers["Accept"] = "application/json"
43
+ conn.headers["User-Agent"] = ActiveProject.user_agent
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Issues
7
+ # Lists To-dos within a specific project.
8
+ # @param project_id [String, Integer] The ID of the Basecamp project.
9
+ # @param options [Hash] Optional options. Accepts :todolist_id.
10
+ # @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
11
+ def list_issues(project_id, options = {})
12
+ all_todos = []
13
+ todolist_id = options[:todolist_id]
14
+
15
+ unless todolist_id
16
+ todolist_id = find_first_todolist_id(project_id)
17
+ return [] unless todolist_id
18
+ end
19
+
20
+ path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
21
+
22
+ loop do
23
+ response = @connection.get(path)
24
+ todos_data = begin
25
+ JSON.parse(response.body)
26
+ rescue StandardError
27
+ []
28
+ end
29
+ break if todos_data.empty?
30
+
31
+ todos_data.each do |todo_data|
32
+ all_todos << map_todo_data(todo_data, project_id)
33
+ end
34
+
35
+ link_header = response.headers["Link"]
36
+ next_url = parse_next_link(link_header)
37
+ break unless next_url
38
+
39
+ path = next_url.sub(@base_url, "").sub(%r{^/}, "")
40
+ end
41
+
42
+ all_todos
43
+ rescue Faraday::Error => e
44
+ handle_faraday_error(e)
45
+ end
46
+
47
+ # Finds a specific To-do by its ID.
48
+ # @param todo_id [String, Integer] The ID of the Basecamp To-do.
49
+ # @param context [Hash] Required context: { project_id: '...' }.
50
+ # @return [ActiveProject::Resources::Issue] The issue resource.
51
+ def find_issue(todo_id, context = {})
52
+ project_id = context[:project_id]
53
+ unless project_id
54
+ raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#find_issue"
55
+ end
56
+
57
+ path = "buckets/#{project_id}/todos/#{todo_id}.json"
58
+ todo_data = make_request(:get, path)
59
+ map_todo_data(todo_data, project_id)
60
+ end
61
+
62
+ # Creates a new To-do in Basecamp.
63
+ # @param project_id [String, Integer] The ID of the Basecamp project.
64
+ # @param attributes [Hash] To-do attributes. Required: :todolist_id, :title. Optional: :description, :due_on, :assignee_ids.
65
+ # @return [ActiveProject::Resources::Issue] The created issue resource.
66
+ def create_issue(project_id, attributes)
67
+ todolist_id = attributes[:todolist_id]
68
+ title = attributes[:title]
69
+
70
+ unless todolist_id && title && !title.empty?
71
+ raise ArgumentError, "Missing required attributes for Basecamp to-do creation: :todolist_id, :title"
72
+ end
73
+
74
+ path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
75
+
76
+ payload = {
77
+ content: title,
78
+ description: attributes[:description],
79
+ due_on: attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on],
80
+ assignee_ids: attributes[:assignee_ids]
81
+ }.compact
82
+
83
+ todo_data = make_request(:post, path, payload.to_json)
84
+ map_todo_data(todo_data, project_id)
85
+ end
86
+
87
+ # Updates an existing To-do in Basecamp.
88
+ # Handles updates to standard fields via PUT and status changes via POST/DELETE completion endpoints.
89
+ # @param todo_id [String, Integer] The ID of the Basecamp To-do.
90
+ # @param attributes [Hash] Attributes to update (e.g., :title, :description, :status, :assignee_ids, :due_on).
91
+ # @param context [Hash] Required context: { project_id: '...' }.
92
+ # @return [ActiveProject::Resources::Issue] The updated issue resource (fetched after updates).
93
+ def update_issue(todo_id, attributes, context = {})
94
+ project_id = context[:project_id]
95
+ unless project_id
96
+ raise ArgumentError,
97
+ "Missing required context: :project_id must be provided for BasecampAdapter#update_issue"
98
+ end
99
+
100
+ put_payload = {}
101
+ put_payload[:content] = attributes[:title] if attributes.key?(:title)
102
+ put_payload[:description] = attributes[:description] if attributes.key?(:description)
103
+ if attributes.key?(:due_on)
104
+ due_on_val = attributes[:due_on]
105
+ put_payload[:due_on] = due_on_val.respond_to?(:strftime) ? due_on_val.strftime("%Y-%m-%d") : due_on_val
106
+ end
107
+ put_payload[:assignee_ids] = attributes[:assignee_ids] if attributes.key?(:assignee_ids)
108
+
109
+ status_change_required = attributes.key?(:status)
110
+ target_status = attributes[:status] if status_change_required
111
+
112
+ unless !put_payload.empty? || status_change_required
113
+ raise ArgumentError, "No attributes provided to update for BasecampAdapter#update_issue"
114
+ end
115
+
116
+ unless put_payload.empty?
117
+ put_path = "buckets/#{project_id}/todos/#{todo_id}.json"
118
+ make_request(:put, put_path, put_payload.compact.to_json)
119
+ end
120
+
121
+ if status_change_required
122
+ completion_path = "buckets/#{project_id}/todos/#{todo_id}/completion.json"
123
+ begin
124
+ if target_status == :closed
125
+ make_request(:post, completion_path)
126
+ elsif target_status == :open
127
+ make_request(:delete, completion_path)
128
+ end
129
+ rescue NotFoundError
130
+ raise unless target_status == :open
131
+ end
132
+ end
133
+
134
+ find_issue(todo_id, context)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Lists
7
+ # Creates a new Todolist within a project.
8
+ # @param project_id [String, Integer] The ID of the Basecamp project (bucket).
9
+ # @param attributes [Hash] Todolist attributes. Required: :name. Optional: :description.
10
+ # @return [Hash] The raw data hash of the created todolist.
11
+ def create_list(project_id, attributes)
12
+ unless attributes[:name] && !attributes[:name].empty?
13
+ raise ArgumentError, "Missing required attribute for Basecamp todolist creation: :name"
14
+ end
15
+
16
+ project_data = make_request(:get, "projects/#{project_id}.json")
17
+ todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
18
+ todoset_url = todoset_dock_entry&.dig("url")
19
+ raise ApiError, "Could not find todoset URL for project #{project_id}" unless todoset_url
20
+
21
+ todoset_id = todoset_url.match(%r{todosets/(\d+)\.json$})&.captures&.first
22
+ raise ApiError, "Could not extract todoset ID from URL: #{todoset_url}" unless todoset_id
23
+
24
+ path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
25
+ payload = {
26
+ name: attributes[:name],
27
+ description: attributes[:description]
28
+ }.compact
29
+
30
+ make_request(:post, path, payload.to_json)
31
+ end
32
+
33
+ # Finds the ID of the first todolist in a project.
34
+ # @param project_id [String, Integer]
35
+ # @return [String, nil]
36
+ def find_first_todolist_id(project_id)
37
+ project_data = make_request(:get, "projects/#{project_id}.json")
38
+ todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
39
+ todoset_url = todoset_dock_entry&.dig("url")
40
+ return nil unless todoset_url
41
+
42
+ todoset_id = todoset_url.match(%r{todosets/(\d+)\.json$})&.captures&.first
43
+ return nil unless todoset_id
44
+
45
+ todolists_url_path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
46
+ todolists_data = make_request(:get, todolists_url_path)
47
+ todolists_data&.first&.dig("id")
48
+ rescue NotFoundError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Projects
7
+ # Lists projects accessible by the configured credentials.
8
+ # Handles pagination automatically using the Link header.
9
+ # @return [Array<ActiveProject::Resources::Project>] An array of project resources.
10
+ def list_projects
11
+ all_projects = []
12
+ path = "projects.json"
13
+
14
+ loop do
15
+ response = @connection.get(path)
16
+ projects_data = begin
17
+ JSON.parse(response.body)
18
+ rescue StandardError
19
+ []
20
+ end
21
+ break if projects_data.empty?
22
+
23
+ projects_data.each do |project_data|
24
+ all_projects << Resources::Project.new(self,
25
+ id: project_data["id"],
26
+ key: nil,
27
+ name: project_data["name"],
28
+ adapter_source: :basecamp,
29
+ raw_data: project_data)
30
+ end
31
+
32
+ link_header = response.headers["Link"]
33
+ next_url = parse_next_link(link_header)
34
+ break unless next_url
35
+
36
+ path = next_url.sub(@base_url, "").sub(%r{^/}, "")
37
+ end
38
+
39
+ all_projects
40
+ rescue Faraday::Error => e
41
+ handle_faraday_error(e)
42
+ end
43
+
44
+ # Finds a specific project by its ID.
45
+ # @param project_id [String, Integer] The ID of the Basecamp project.
46
+ # @return [ActiveProject::Resources::Project] The project resource.
47
+ def find_project(project_id)
48
+ path = "projects/#{project_id}.json"
49
+ project_data = make_request(:get, path)
50
+ return nil unless project_data
51
+
52
+ raise NotFoundError, "Basecamp project ID #{project_id} is trashed." if project_data["status"] == "trashed"
53
+
54
+ Resources::Project.new(self,
55
+ id: project_data["id"],
56
+ key: nil,
57
+ name: project_data["name"],
58
+ adapter_source: :basecamp,
59
+ raw_data: project_data)
60
+ end
61
+
62
+ # Creates a new project in Basecamp.
63
+ # @param attributes [Hash] Project attributes. Required: :name. Optional: :description.
64
+ # @return [ActiveProject::Resources::Project] The created project resource.
65
+ def create_project(attributes)
66
+ unless attributes[:name] && !attributes[:name].empty?
67
+ raise ArgumentError, "Missing required attribute for Basecamp project creation: :name"
68
+ end
69
+
70
+ path = "projects.json"
71
+ payload = {
72
+ name: attributes[:name],
73
+ description: attributes[:description]
74
+ }.compact
75
+
76
+ project_data = make_request(:post, path, payload.to_json)
77
+
78
+ Resources::Project.new(self,
79
+ id: project_data["id"],
80
+ key: nil,
81
+ name: project_data["name"],
82
+ adapter_source: :basecamp,
83
+ raw_data: project_data)
84
+ end
85
+
86
+ # Recovers a trashed project in Basecamp.
87
+ # @param project_id [String, Integer] The ID of the project to recover.
88
+ # @return [Boolean] true if recovery was successful (API returns 204).
89
+ def untrash_project(project_id)
90
+ path = "projects/#{project_id}.json"
91
+ make_request(:put, path, { "status": "active" }.to_json)
92
+ true
93
+ end
94
+
95
+ # Archives (trashes) a project in Basecamp.
96
+ # Note: Basecamp API doesn't offer permanent deletion via this endpoint.
97
+ # @param project_id [String, Integer] The ID of the project to trash.
98
+ # @return [Boolean] true if trashing was successful (API returns 204).
99
+ # @raise [NotFoundError] if the project is not found.
100
+ # @raise [AuthenticationError] if credentials lack permission.
101
+ # @raise [ApiError] for other errors.
102
+ def delete_project(project_id)
103
+ path = "projects/#{project_id}.json"
104
+ make_request(:delete, path)
105
+ true
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Webhooks
7
+ # Parses an incoming Basecamp webhook payload.
8
+ # @param request_body [String] The raw JSON request body.
9
+ # @param headers [Hash] Request headers (unused).
10
+ # @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
11
+ def parse_webhook(request_body, _headers = {})
12
+ payload = begin
13
+ JSON.parse(request_body)
14
+ rescue StandardError
15
+ nil
16
+ end
17
+ return nil unless payload.is_a?(Hash)
18
+
19
+ kind = payload["kind"]
20
+ recording = payload["recording"]
21
+ creator = payload["creator"]
22
+ timestamp = begin
23
+ Time.parse(payload["created_at"])
24
+ rescue StandardError
25
+ nil
26
+ end
27
+ return nil unless recording && kind
28
+
29
+ event_type = nil
30
+ object_kind = nil
31
+ event_object_id = recording["id"]
32
+ object_key = nil
33
+ project_id = recording.dig("bucket", "id")
34
+ changes = nil
35
+ object_data = nil
36
+
37
+ case kind
38
+ when /todo_created$/
39
+ event_type = :issue_created
40
+ object_kind = :issue
41
+ when /todo_assignment_changed$/, /todo_completion_changed$/, /todo_content_updated$/, /todo_description_changed$/, /todo_due_on_changed$/
42
+ event_type = :issue_updated
43
+ object_kind = :issue
44
+ when /comment_created$/
45
+ event_type = :comment_added
46
+ object_kind = :comment
47
+ when /comment_content_changed$/
48
+ event_type = :comment_updated
49
+ object_kind = :comment
50
+ else
51
+ return nil
52
+ end
53
+
54
+ WebhookEvent.new(
55
+ event_type: event_type,
56
+ object_kind: object_kind,
57
+ event_object_id: event_object_id,
58
+ object_key: object_key,
59
+ project_id: project_id,
60
+ actor: map_user_data(creator),
61
+ timestamp: timestamp,
62
+ adapter_source: :basecamp,
63
+ changes: changes,
64
+ object_data: object_data,
65
+ raw_data: payload
66
+ )
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end