activeproject 0.0.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.
- checksums.yaml +4 -4
- data/README.md +28 -82
- data/Rakefile +4 -2
- data/lib/active_project/adapters/base.rb +3 -14
- data/lib/active_project/adapters/basecamp/comments.rb +27 -0
- data/lib/active_project/adapters/basecamp/connection.rb +49 -0
- data/lib/active_project/adapters/basecamp/issues.rb +139 -0
- data/lib/active_project/adapters/basecamp/lists.rb +54 -0
- data/lib/active_project/adapters/basecamp/projects.rb +110 -0
- data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
- data/lib/active_project/adapters/basecamp_adapter.rb +46 -449
- data/lib/active_project/adapters/jira/comments.rb +28 -0
- data/lib/active_project/adapters/jira/connection.rb +47 -0
- data/lib/active_project/adapters/jira/issues.rb +132 -0
- data/lib/active_project/adapters/jira/projects.rb +100 -0
- data/lib/active_project/adapters/jira/transitions.rb +68 -0
- data/lib/active_project/adapters/jira/webhooks.rb +89 -0
- data/lib/active_project/adapters/jira_adapter.rb +59 -486
- data/lib/active_project/adapters/trello/comments.rb +21 -0
- data/lib/active_project/adapters/trello/connection.rb +37 -0
- data/lib/active_project/adapters/trello/issues.rb +117 -0
- data/lib/active_project/adapters/trello/lists.rb +27 -0
- data/lib/active_project/adapters/trello/projects.rb +82 -0
- data/lib/active_project/adapters/trello/webhooks.rb +91 -0
- data/lib/active_project/adapters/trello_adapter.rb +54 -377
- data/lib/active_project/association_proxy.rb +10 -3
- data/lib/active_project/configuration.rb +23 -17
- data/lib/active_project/configurations/trello_configuration.rb +1 -3
- data/lib/active_project/resource_factory.rb +20 -10
- data/lib/active_project/resources/comment.rb +0 -5
- data/lib/active_project/resources/issue.rb +0 -5
- data/lib/active_project/resources/project.rb +0 -3
- data/lib/active_project/resources/user.rb +0 -1
- data/lib/active_project/version.rb +3 -1
- data/lib/activeproject.rb +67 -15
- metadata +26 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 46d0e95fea9e696d7f35f2f8ad2cc32a82f9c068aaf834a9daa2650824650620
|
4
|
+
data.tar.gz: 709c98e0c3bab903db0447c7d53559c55e5c7c98cffc511a771c3b9f08cfa2fc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7fb8db4e4f952b10364e91727ebec93e6f0e65f6ebf7d5bd331dab77013bd3fc34d100f522a76d5f8d7014d947081a16fdee34535a6327cbabfe6bb74cbfc00b
|
7
|
+
data.tar.gz: 15231c9e5b94a103f21b643f219ffebf4747223f2662a410389ecbf142cc1f618c96384dd5314ba56d00afa6bdf4d0f81cfc0ba33bd580f2fa4999dae8a954eb
|
data/README.md
CHANGED
@@ -18,7 +18,7 @@ The ActiveProject gem aims to solve this by providing a unified, opinionated int
|
|
18
18
|
|
19
19
|
The initial focus is on integrating with platforms primarily via their **REST APIs**:
|
20
20
|
|
21
|
-
* **Jira (Cloud & Server):** REST API (
|
21
|
+
* **Jira (Cloud & Server):** REST API (v3)
|
22
22
|
* **Basecamp (v3+):** REST API
|
23
23
|
* **Trello:** REST API
|
24
24
|
|
@@ -69,29 +69,49 @@ $ gem install activeproject
|
|
69
69
|
|
70
70
|
### Configuration
|
71
71
|
|
72
|
-
Configure
|
72
|
+
Configure multiple adapters, optionally with named instances (default is `:primary`):
|
73
73
|
|
74
74
|
```ruby
|
75
75
|
ActiveProject.configure do |config|
|
76
|
-
#
|
76
|
+
# Primary Jira instance (default name :primary)
|
77
77
|
config.add_adapter(:jira,
|
78
78
|
site_url: ENV.fetch('JIRA_SITE_URL'),
|
79
|
-
username: ENV.fetch('JIRA_USERNAME'),
|
79
|
+
username: ENV.fetch('JIRA_USERNAME'),
|
80
80
|
api_token: ENV.fetch('JIRA_API_TOKEN')
|
81
81
|
)
|
82
82
|
|
83
|
+
# Secondary Jira instance
|
84
|
+
config.add_adapter(:jira, :secondary,
|
85
|
+
site_url: ENV.fetch('JIRA_SECOND_SITE_URL'),
|
86
|
+
username: ENV.fetch('JIRA_SECOND_USERNAME'),
|
87
|
+
api_token: ENV.fetch('JIRA_SECOND_API_TOKEN')
|
88
|
+
)
|
83
89
|
|
84
|
-
#
|
90
|
+
# Basecamp primary instance
|
85
91
|
config.add_adapter(:basecamp,
|
86
92
|
account_id: ENV.fetch('BASECAMP_ACCOUNT_ID'),
|
87
93
|
access_token: ENV.fetch('BASECAMP_ACCESS_TOKEN')
|
88
94
|
)
|
89
95
|
|
90
|
-
#
|
91
|
-
|
96
|
+
# Trello primary instance
|
97
|
+
config.add_adapter(:trello,
|
98
|
+
key: ENV.fetch('TRELLO_KEY'),
|
99
|
+
token: ENV.fetch('TRELLO_TOKEN')
|
100
|
+
)
|
92
101
|
end
|
93
102
|
```
|
94
103
|
|
104
|
+
### Accessing adapters
|
105
|
+
|
106
|
+
Fetch a specific adapter instance:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
jira_primary = ActiveProject.adapter(:jira) # defaults to :primary
|
110
|
+
jira_secondary = ActiveProject.adapter(:jira, :secondary)
|
111
|
+
basecamp = ActiveProject.adapter(:basecamp) # defaults to :primary
|
112
|
+
trello = ActiveProject.adapter(:trello) # defaults to :primary
|
113
|
+
```
|
114
|
+
|
95
115
|
### Basic Usage (Jira Example)
|
96
116
|
|
97
117
|
```ruby
|
@@ -215,80 +235,6 @@ rescue => e
|
|
215
235
|
end
|
216
236
|
```
|
217
237
|
|
218
|
-
|
219
|
-
|
220
|
-
### Webhook Handling
|
221
|
-
|
222
|
-
The gem provides helpers for parsing webhook payloads and verifying signatures (where applicable), but you need to implement your own webhook receiver endpoint (e.g., a Rails controller action).
|
223
|
-
|
224
|
-
```ruby
|
225
|
-
# Example Rails Controller Action
|
226
|
-
class WebhooksController < ApplicationController
|
227
|
-
# Disable CSRF protection for webhook endpoints
|
228
|
-
skip_before_action :verify_authenticity_token
|
229
|
-
|
230
|
-
def jira_webhook
|
231
|
-
adapter = ActiveProject.adapter(:jira)
|
232
|
-
request_body = request.body.read
|
233
|
-
|
234
|
-
# Verification (if applicable and implemented for your Jira setup)
|
235
|
-
# signature = request.headers['X-Jira-Signature'] # Example header
|
236
|
-
# unless adapter.verify_webhook_signature(request_body, signature)
|
237
|
-
# render plain: 'Invalid signature', status: :unauthorized
|
238
|
-
# return
|
239
|
-
# end
|
240
|
-
|
241
|
-
# Parse the event
|
242
|
-
event = adapter.parse_webhook(request_body, request.headers)
|
243
|
-
|
244
|
-
if event
|
245
|
-
puts "Received Jira Event: #{event.event_type} for #{event.object_kind} #{event.object_key || event.object_id}"
|
246
|
-
# Process the event (e.g., queue a background job)
|
247
|
-
# handle_event(event)
|
248
|
-
else
|
249
|
-
puts "Received unhandled or unparseable Jira webhook"
|
250
|
-
end
|
251
|
-
|
252
|
-
head :ok # Respond to Jira quickly
|
253
|
-
end
|
254
|
-
|
255
|
-
def trello_webhook
|
256
|
-
adapter = ActiveProject.adapter(:trello)
|
257
|
-
request_body = request.body.read
|
258
|
-
signature = request.headers['X-Trello-Webhook'] # Trello signature header
|
259
|
-
callback_url = request.original_url # The URL Trello sent the webhook to
|
260
|
-
trello_api_secret = ENV.fetch('TRELLO_API_SECRET') # You need your secret
|
261
|
-
|
262
|
-
# Verification (Manual comparison using the helper)
|
263
|
-
expected_signature = ActiveProject::Adapters::TrelloAdapter.compute_webhook_signature(
|
264
|
-
callback_url,
|
265
|
-
request_body,
|
266
|
-
trello_api_secret
|
267
|
-
)
|
268
|
-
|
269
|
-
unless ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
|
270
|
-
render plain: 'Invalid Trello signature', status: :unauthorized
|
271
|
-
return
|
272
|
-
end
|
273
|
-
|
274
|
-
# Parse the event
|
275
|
-
event = adapter.parse_webhook(request_body)
|
276
|
-
|
277
|
-
if event
|
278
|
-
puts "Received Trello Event: #{event.event_type} for #{event.object_kind} #{event.object_id}"
|
279
|
-
# Process the event
|
280
|
-
else
|
281
|
-
puts "Received unhandled or unparseable Trello webhook"
|
282
|
-
end
|
283
|
-
|
284
|
-
head :ok
|
285
|
-
end
|
286
|
-
|
287
|
-
# Add similar actions for other adapters like Basecamp
|
288
|
-
|
289
|
-
end
|
290
|
-
```
|
291
|
-
|
292
238
|
## Development
|
293
239
|
|
294
240
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
@@ -297,7 +243,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
297
243
|
|
298
244
|
## Contributing
|
299
245
|
|
300
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/seuros/
|
246
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/seuros/active_project.
|
301
247
|
|
302
248
|
## License
|
303
249
|
|
data/Rakefile
CHANGED
@@ -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
|
92
|
-
# @param
|
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(
|
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.
|
@@ -122,13 +118,6 @@ module ActiveProject
|
|
122
118
|
def connected?
|
123
119
|
raise NotImplementedError, "#{self.class.name} must implement #connected?"
|
124
120
|
end
|
125
|
-
|
126
|
-
|
127
|
-
# Placeholder comments for data structures (to be defined elsewhere)
|
128
|
-
# Example:
|
129
|
-
# Project = Struct.new(:id, :key, :name, :adapter_source, keyword_init: true)
|
130
|
-
# Issue = Struct.new(:id, :key, :title, :description, :status, :assignee, :project_id, :adapter_source, keyword_init: true)
|
131
|
-
# Comment = Struct.new(:id, :body, :author, :created_at, :issue_id, :adapter_source, keyword_init: true)
|
132
121
|
end
|
133
122
|
end
|
134
123
|
end
|
@@ -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
|