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.
- checksums.yaml +4 -4
- data/Rakefile +4 -2
- data/lib/active_project/adapters/base.rb +3 -7
- 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 -437
- 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 +57 -483
- 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 -376
- data/lib/active_project/association_proxy.rb +9 -2
- data/lib/active_project/configuration.rb +1 -3
- data/lib/active_project/configurations/trello_configuration.rb +1 -3
- data/lib/active_project/resource_factory.rb +20 -10
- data/lib/active_project/resources/issue.rb +0 -3
- data/lib/active_project/resources/project.rb +0 -1
- data/lib/active_project/version.rb +3 -1
- data/lib/activeproject.rb +2 -2
- metadata +20 -5
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveProject
|
4
|
+
module Adapters
|
5
|
+
module Jira
|
6
|
+
module Issues
|
7
|
+
# Lists issues within a specific project, optionally filtered by JQL.
|
8
|
+
# @param project_id_or_key [String, Integer] The ID or key of the project.
|
9
|
+
# @param options [Hash] Optional filtering/pagination options.
|
10
|
+
# @return [Array<ActiveProject::Resources::Issue>]
|
11
|
+
def list_issues(project_id_or_key, options = {})
|
12
|
+
start_at = options.fetch(:start_at, 0)
|
13
|
+
max_results = options.fetch(:max_results, 50)
|
14
|
+
jql = options.fetch(:jql, "project = '#{project_id_or_key}' ORDER BY created DESC")
|
15
|
+
|
16
|
+
all_issues = []
|
17
|
+
path = "/rest/api/3/search"
|
18
|
+
|
19
|
+
payload = {
|
20
|
+
jql: jql,
|
21
|
+
startAt: start_at,
|
22
|
+
maxResults: max_results,
|
23
|
+
fields: %w[summary description status assignee reporter created updated project
|
24
|
+
issuetype duedate priority]
|
25
|
+
}.to_json
|
26
|
+
|
27
|
+
response_data = make_request(:post, path, payload)
|
28
|
+
|
29
|
+
issues_data = response_data["issues"] || []
|
30
|
+
issues_data.each do |issue_data|
|
31
|
+
all_issues << map_issue_data(issue_data)
|
32
|
+
end
|
33
|
+
|
34
|
+
all_issues
|
35
|
+
end
|
36
|
+
|
37
|
+
# Finds a specific issue by its ID or key using the V3 endpoint.
|
38
|
+
# @param id_or_key [String, Integer] The ID or key of the issue.
|
39
|
+
# @param context [Hash] Optional context (ignored).
|
40
|
+
# @return [ActiveProject::Resources::Issue]
|
41
|
+
def find_issue(id_or_key, _context = {})
|
42
|
+
fields = "summary,description,status,assignee,reporter,created,updated,project,issuetype,duedate,priority"
|
43
|
+
path = "/rest/api/3/issue/#{id_or_key}?fields=#{fields}"
|
44
|
+
|
45
|
+
issue_data = make_request(:get, path)
|
46
|
+
map_issue_data(issue_data)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Creates a new issue in Jira using the V3 endpoint.
|
50
|
+
# @param _project_id_or_key [String, Integer] Ignored (project info is in attributes).
|
51
|
+
# @param attributes [Hash] Issue attributes. Required: :project, :summary, :issue_type. Optional: :description, :assignee_id, :due_on, :priority.
|
52
|
+
# @return [ActiveProject::Resources::Issue]
|
53
|
+
def create_issue(_project_id_or_key, attributes)
|
54
|
+
path = "/rest/api/3/issue"
|
55
|
+
|
56
|
+
unless attributes[:project].is_a?(Hash) && (attributes[:project][:id] || attributes[:project][:key]) &&
|
57
|
+
attributes[:summary] && !attributes[:summary].empty? &&
|
58
|
+
attributes[:issue_type] && (attributes[:issue_type][:id] || attributes[:issue_type][:name])
|
59
|
+
raise ArgumentError,
|
60
|
+
"Missing required attributes for issue creation: :project (must be a Hash with id/key), :summary, :issue_type (with id/name)"
|
61
|
+
end
|
62
|
+
|
63
|
+
fields_payload = {
|
64
|
+
project: attributes[:project],
|
65
|
+
summary: attributes[:summary],
|
66
|
+
issuetype: attributes[:issue_type]
|
67
|
+
}
|
68
|
+
|
69
|
+
if attributes.key?(:description)
|
70
|
+
fields_payload[:description] = if attributes[:description].is_a?(String)
|
71
|
+
{ type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
|
72
|
+
elsif attributes[:description].is_a?(Hash)
|
73
|
+
attributes[:description]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
fields_payload[:assignee] = { accountId: attributes[:assignee_id] } if attributes.key?(:assignee_id)
|
78
|
+
|
79
|
+
if attributes.key?(:due_on)
|
80
|
+
fields_payload[:duedate] =
|
81
|
+
attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on]
|
82
|
+
end
|
83
|
+
|
84
|
+
fields_payload[:priority] = attributes[:priority] if attributes.key?(:priority)
|
85
|
+
|
86
|
+
payload = { fields: fields_payload }.to_json
|
87
|
+
response_data = make_request(:post, path, payload)
|
88
|
+
|
89
|
+
find_issue(response_data["key"])
|
90
|
+
end
|
91
|
+
|
92
|
+
# Updates an existing issue in Jira using the V3 endpoint.
|
93
|
+
# @param id_or_key [String, Integer] The ID or key of the issue to update.
|
94
|
+
# @param attributes [Hash] Issue attributes to update (e.g., :summary, :description, :assignee_id, :due_on, :priority).
|
95
|
+
# @param context [Hash] Optional context (ignored).
|
96
|
+
# @return [ActiveProject::Resources::Issue]
|
97
|
+
def update_issue(id_or_key, attributes, _context = {})
|
98
|
+
path = "/rest/api/3/issue/#{id_or_key}"
|
99
|
+
|
100
|
+
update_fields = {}
|
101
|
+
update_fields[:summary] = attributes[:summary] if attributes.key?(:summary)
|
102
|
+
|
103
|
+
if attributes.key?(:description)
|
104
|
+
update_fields[:description] = if attributes[:description].is_a?(String)
|
105
|
+
{ type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
|
106
|
+
elsif attributes[:description].is_a?(Hash)
|
107
|
+
attributes[:description]
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
if attributes.key?(:assignee_id)
|
112
|
+
update_fields[:assignee] = attributes[:assignee_id] ? { accountId: attributes[:assignee_id] } : nil
|
113
|
+
end
|
114
|
+
|
115
|
+
if attributes.key?(:due_on)
|
116
|
+
update_fields[:duedate] =
|
117
|
+
attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on]
|
118
|
+
end
|
119
|
+
|
120
|
+
update_fields[:priority] = attributes[:priority] if attributes.key?(:priority)
|
121
|
+
|
122
|
+
return find_issue(id_or_key) if update_fields.empty?
|
123
|
+
|
124
|
+
payload = { fields: update_fields }.to_json
|
125
|
+
make_request(:put, path, payload)
|
126
|
+
|
127
|
+
find_issue(id_or_key)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveProject
|
4
|
+
module Adapters
|
5
|
+
module Jira
|
6
|
+
module Projects
|
7
|
+
# Lists projects accessible by the configured credentials using the V3 endpoint.
|
8
|
+
# Handles pagination automatically.
|
9
|
+
# @return [Array<ActiveProject::Resources::Project>] An array of project resources.
|
10
|
+
def list_projects
|
11
|
+
start_at = 0
|
12
|
+
max_results = 50
|
13
|
+
all_projects = []
|
14
|
+
|
15
|
+
loop do
|
16
|
+
path = "/rest/api/3/project/search?startAt=#{start_at}&maxResults=#{max_results}"
|
17
|
+
response_data = make_request(:get, path)
|
18
|
+
|
19
|
+
projects_data = response_data["values"] || []
|
20
|
+
break if projects_data.empty?
|
21
|
+
|
22
|
+
projects_data.each do |project_data|
|
23
|
+
all_projects << Resources::Project.new(self,
|
24
|
+
id: project_data["id"],
|
25
|
+
key: project_data["key"],
|
26
|
+
name: project_data["name"],
|
27
|
+
adapter_source: :jira,
|
28
|
+
raw_data: project_data)
|
29
|
+
end
|
30
|
+
|
31
|
+
is_last = response_data["isLast"]
|
32
|
+
break if is_last || projects_data.size < max_results
|
33
|
+
|
34
|
+
start_at += projects_data.size
|
35
|
+
end
|
36
|
+
|
37
|
+
all_projects
|
38
|
+
end
|
39
|
+
|
40
|
+
# Finds a specific project by its ID or key.
|
41
|
+
# @param id_or_key [String, Integer] The ID or key of the project.
|
42
|
+
# @return [ActiveProject::Resources::Project]
|
43
|
+
def find_project(id_or_key)
|
44
|
+
path = "/rest/api/3/project/#{id_or_key}"
|
45
|
+
project_data = make_request(:get, path)
|
46
|
+
|
47
|
+
Resources::Project.new(self,
|
48
|
+
id: project_data["id"].to_i,
|
49
|
+
key: project_data["key"],
|
50
|
+
name: project_data["name"],
|
51
|
+
adapter_source: :jira,
|
52
|
+
raw_data: project_data)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Creates a new project in Jira.
|
56
|
+
# @param attributes [Hash] Project attributes. Required: :key, :name, :project_type_key, :lead_account_id. Optional: :description, :assignee_type.
|
57
|
+
# @return [ActiveProject::Resources::Project]
|
58
|
+
def create_project(attributes)
|
59
|
+
required_keys = %i[key name project_type_key lead_account_id]
|
60
|
+
missing_keys = required_keys.reject { |k| attributes.key?(k) && !attributes[k].to_s.empty? }
|
61
|
+
unless missing_keys.empty?
|
62
|
+
raise ArgumentError, "Missing required attributes for Jira project creation: #{missing_keys.join(', ')}"
|
63
|
+
end
|
64
|
+
|
65
|
+
path = "/rest/api/3/project"
|
66
|
+
payload = {
|
67
|
+
key: attributes[:key],
|
68
|
+
name: attributes[:name],
|
69
|
+
projectTypeKey: attributes[:project_type_key],
|
70
|
+
leadAccountId: attributes[:lead_account_id],
|
71
|
+
description: attributes[:description],
|
72
|
+
assigneeType: attributes[:assignee_type]
|
73
|
+
}.compact
|
74
|
+
|
75
|
+
project_data = make_request(:post, path, payload.to_json)
|
76
|
+
|
77
|
+
Resources::Project.new(self,
|
78
|
+
id: project_data["id"]&.to_i,
|
79
|
+
key: project_data["key"],
|
80
|
+
name: project_data["name"],
|
81
|
+
adapter_source: :jira,
|
82
|
+
raw_data: project_data)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Deletes a project in Jira.
|
86
|
+
# WARNING: This is a permanent deletion and requires admin permissions.
|
87
|
+
# @param project_id_or_key [String, Integer] The ID or key of the project to delete.
|
88
|
+
# @return [Boolean] true if deletion was successful (API returns 204).
|
89
|
+
# @raise [NotFoundError] if the project is not found.
|
90
|
+
# @raise [AuthenticationError] if credentials lack permission.
|
91
|
+
# @raise [ApiError] for other errors.
|
92
|
+
def delete_project(project_id_or_key)
|
93
|
+
path = "/rest/api/3/project/#{project_id_or_key}"
|
94
|
+
make_request(:delete, path)
|
95
|
+
true
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveProject
|
4
|
+
module Adapters
|
5
|
+
module Jira
|
6
|
+
module Transitions
|
7
|
+
# Transitions a Jira issue to a new status by finding and executing the appropriate workflow transition.
|
8
|
+
# @param issue_id_or_key [String, Integer] The ID or key of the issue.
|
9
|
+
# @param target_status_name_or_id [String, Integer] The name or ID of the target status.
|
10
|
+
# @param options [Hash] Optional parameters for the transition (e.g., :resolution, :comment).
|
11
|
+
# - :resolution [Hash] e.g., `{ name: 'Done' }`
|
12
|
+
# - :comment [String] Comment body to add during transition.
|
13
|
+
# @return [Boolean] true if successful.
|
14
|
+
# @raise [NotFoundError] if the issue or target transition is not found.
|
15
|
+
# @raise [ApiError] for other API errors.
|
16
|
+
def transition_issue(issue_id_or_key, target_status_name_or_id, options = {})
|
17
|
+
transitions_path = "/rest/api/3/issue/#{issue_id_or_key}/transitions"
|
18
|
+
begin
|
19
|
+
response_data = make_request(:get, transitions_path)
|
20
|
+
rescue NotFoundError
|
21
|
+
raise NotFoundError, "Jira issue '#{issue_id_or_key}' not found."
|
22
|
+
end
|
23
|
+
available_transitions = response_data["transitions"] || []
|
24
|
+
|
25
|
+
target_transition = available_transitions.find do |t|
|
26
|
+
t["id"] == target_status_name_or_id.to_s ||
|
27
|
+
t.dig("to", "name")&.casecmp?(target_status_name_or_id.to_s) ||
|
28
|
+
t.dig("to", "id") == target_status_name_or_id.to_s
|
29
|
+
end
|
30
|
+
|
31
|
+
unless target_transition
|
32
|
+
available_names = available_transitions.map { |t| t.dig("to", "name") }.compact.join(", ")
|
33
|
+
raise NotFoundError,
|
34
|
+
"Target transition '#{target_status_name_or_id}' not found or not available for issue '#{issue_id_or_key}'. Available transitions: [#{available_names}]"
|
35
|
+
end
|
36
|
+
|
37
|
+
payload = {
|
38
|
+
transition: { id: target_transition["id"] }
|
39
|
+
}
|
40
|
+
|
41
|
+
if options[:resolution]
|
42
|
+
payload[:fields] ||= {}
|
43
|
+
payload[:fields][:resolution] = options[:resolution]
|
44
|
+
end
|
45
|
+
|
46
|
+
if options[:comment] && !options[:comment].empty?
|
47
|
+
payload[:update] ||= {}
|
48
|
+
payload[:update][:comment] ||= []
|
49
|
+
payload[:update][:comment] << {
|
50
|
+
add: {
|
51
|
+
body: {
|
52
|
+
type: "doc", version: 1,
|
53
|
+
content: [ { type: "paragraph", content: [ { type: "text", text: options[:comment] } ] } ]
|
54
|
+
}
|
55
|
+
}
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
make_request(:post, transitions_path, payload.to_json)
|
60
|
+
true
|
61
|
+
rescue Faraday::Error => e
|
62
|
+
handle_faraday_error(e)
|
63
|
+
raise ApiError.new("Failed to transition Jira issue '#{issue_id_or_key}'", original_error: e)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveProject
|
4
|
+
module Adapters
|
5
|
+
module Jira
|
6
|
+
module Webhooks
|
7
|
+
# Parses an incoming Jira webhook payload.
|
8
|
+
# @param request_body [String] The raw JSON request body.
|
9
|
+
# @param headers [Hash] Request headers.
|
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
|
+
event_name = payload["webhookEvent"]
|
20
|
+
timestamp = payload["timestamp"] ? Time.at(payload["timestamp"] / 1000) : nil
|
21
|
+
|
22
|
+
actor_data = if event_name.start_with?("comment_")
|
23
|
+
payload.dig("comment", "author")
|
24
|
+
else
|
25
|
+
payload["user"]
|
26
|
+
end
|
27
|
+
|
28
|
+
issue_data = payload["issue"]
|
29
|
+
comment_data = payload["comment"]
|
30
|
+
changelog = payload["changelog"]
|
31
|
+
|
32
|
+
event_type = nil
|
33
|
+
object_kind = nil
|
34
|
+
event_object_id = nil
|
35
|
+
object_key = nil
|
36
|
+
project_id = nil
|
37
|
+
changes = nil
|
38
|
+
object_data = nil
|
39
|
+
|
40
|
+
case event_name
|
41
|
+
when "jira:issue_created"
|
42
|
+
event_type = :issue_created
|
43
|
+
object_kind = :issue
|
44
|
+
event_object_id = issue_data["id"]
|
45
|
+
object_key = issue_data["key"]
|
46
|
+
project_id = issue_data.dig("fields", "project", "id")&.to_i
|
47
|
+
when "jira:issue_updated"
|
48
|
+
event_type = :issue_updated
|
49
|
+
object_kind = :issue
|
50
|
+
event_object_id = issue_data["id"]
|
51
|
+
object_key = issue_data["key"]
|
52
|
+
project_id = issue_data.dig("fields", "project", "id")&.to_i
|
53
|
+
changes = parse_changelog(changelog)
|
54
|
+
when "comment_created"
|
55
|
+
event_type = :comment_added
|
56
|
+
object_kind = :comment
|
57
|
+
event_object_id = comment_data["id"]
|
58
|
+
object_key = nil
|
59
|
+
project_id = issue_data.dig("fields", "project", "id")&.to_i
|
60
|
+
when "comment_updated"
|
61
|
+
event_type = :comment_updated
|
62
|
+
object_kind = :comment
|
63
|
+
event_object_id = comment_data["id"]
|
64
|
+
object_key = nil
|
65
|
+
project_id = issue_data.dig("fields", "project", "id")&.to_i
|
66
|
+
else
|
67
|
+
return nil
|
68
|
+
end
|
69
|
+
|
70
|
+
WebhookEvent.new(
|
71
|
+
event_type: event_type,
|
72
|
+
object_kind: object_kind,
|
73
|
+
event_object_id: event_object_id,
|
74
|
+
object_key: object_key,
|
75
|
+
project_id: project_id,
|
76
|
+
actor: map_user_data(actor_data),
|
77
|
+
timestamp: timestamp,
|
78
|
+
adapter_source: :jira,
|
79
|
+
changes: changes,
|
80
|
+
object_data: object_data,
|
81
|
+
raw_data: payload
|
82
|
+
)
|
83
|
+
rescue JSON::ParserError
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|