activeproject 0.1.0 → 0.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +4 -2
  3. data/lib/active_project/adapters/base.rb +9 -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 +158 -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 +150 -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 +61 -485
  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 +133 -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 +59 -377
  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 +19 -1
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Jira
6
+ module Issues
7
+ DEFAULT_FIELDS = %w[summary description status assignee reporter created updated project issuetype duedate priority].freeze
8
+
9
+ # Lists issues within a specific project, optionally filtered by JQL.
10
+ # @param project_id_or_key [String, Integer] The ID or key of the project.
11
+ # @param options [Hash] Optional filtering/pagination options. Accepts :jql, :fields, :start_at, :max_results.
12
+ # @return [Array<ActiveProject::Resources::Issue>]
13
+ def list_issues(project_id_or_key, options = {})
14
+ start_at = options.fetch(:start_at, 0)
15
+ max_results = options.fetch(:max_results, 50)
16
+ jql = options.fetch(:jql, "project = '#{project_id_or_key}' ORDER BY created DESC")
17
+ fields = options[:fields] || DEFAULT_FIELDS
18
+
19
+ all_issues = []
20
+ path = "/rest/api/3/search"
21
+
22
+ payload = {
23
+ jql: jql,
24
+ startAt: start_at,
25
+ maxResults: max_results,
26
+ fields: fields
27
+ }.to_json
28
+
29
+ response_data = make_request(:post, path, payload)
30
+
31
+ issues_data = response_data["issues"] || []
32
+ issues_data.each do |issue_data|
33
+ all_issues << map_issue_data(issue_data)
34
+ end
35
+
36
+ all_issues
37
+ end
38
+
39
+ # Finds a specific issue by its ID or key using the V3 endpoint.
40
+ # @param id_or_key [String, Integer] The ID or key of the issue.
41
+ # @param context [Hash] Optional context. Accepts :fields for field selection.
42
+ # @return [ActiveProject::Resources::Issue]
43
+ def find_issue(id_or_key, context = {})
44
+ fields = context[:fields] || DEFAULT_FIELDS
45
+ fields_param = fields.is_a?(Array) ? fields.join(",") : fields
46
+ path = "/rest/api/3/issue/#{id_or_key}?fields=#{fields_param}"
47
+
48
+ issue_data = make_request(:get, path)
49
+ map_issue_data(issue_data)
50
+ end
51
+
52
+ # Creates a new issue in Jira using the V3 endpoint.
53
+ # @param _project_id_or_key [String, Integer] Ignored (project info is in attributes).
54
+ # @param attributes [Hash] Issue attributes. Required: :project, :summary, :issue_type. Optional: :description, :assignee_id, :due_on, :priority.
55
+ # @return [ActiveProject::Resources::Issue]
56
+ def create_issue(_project_id_or_key, attributes)
57
+ path = "/rest/api/3/issue"
58
+
59
+ unless attributes[:project].is_a?(Hash) && (attributes[:project][:id] || attributes[:project][:key]) &&
60
+ attributes[:summary] && !attributes[:summary].empty? &&
61
+ attributes[:issue_type] && (attributes[:issue_type][:id] || attributes[:issue_type][:name])
62
+ raise ArgumentError,
63
+ "Missing required attributes for issue creation: :project (must be a Hash with id/key), :summary, :issue_type (with id/name)"
64
+ end
65
+
66
+ fields_payload = {
67
+ project: attributes[:project],
68
+ summary: attributes[:summary],
69
+ issuetype: attributes[:issue_type]
70
+ }
71
+
72
+ if attributes.key?(:description)
73
+ fields_payload[:description] = if attributes[:description].is_a?(String)
74
+ { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
75
+ elsif attributes[:description].is_a?(Hash)
76
+ attributes[:description]
77
+ end
78
+ end
79
+
80
+ fields_payload[:assignee] = { accountId: attributes[:assignee_id] } if attributes.key?(:assignee_id)
81
+
82
+ if attributes.key?(:due_on)
83
+ fields_payload[:duedate] =
84
+ attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on]
85
+ end
86
+
87
+ fields_payload[:priority] = attributes[:priority] if attributes.key?(:priority)
88
+
89
+ fields_payload[:parent] = attributes[:parent] if attributes.key?(:parent)
90
+
91
+ payload = { fields: fields_payload }.to_json
92
+ response_data = make_request(:post, path, payload)
93
+
94
+ find_issue(response_data["key"])
95
+ end
96
+
97
+ # Updates an existing issue in Jira using the V3 endpoint.
98
+ # @param id_or_key [String, Integer] The ID or key of the issue to update.
99
+ # @param attributes [Hash] Issue attributes to update (e.g., :summary, :description, :assignee_id, :due_on, :priority).
100
+ # @param context [Hash] Optional context. Accepts :fields for field selection on return.
101
+ # @return [ActiveProject::Resources::Issue]
102
+ def update_issue(id_or_key, attributes, context = {})
103
+ path = "/rest/api/3/issue/#{id_or_key}"
104
+
105
+ update_fields = {}
106
+ update_fields[:summary] = attributes[:summary] if attributes.key?(:summary)
107
+
108
+ if attributes.key?(:description)
109
+ update_fields[:description] = if attributes[:description].is_a?(String)
110
+ { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
111
+ elsif attributes[:description].is_a?(Hash)
112
+ attributes[:description]
113
+ end
114
+ end
115
+
116
+ if attributes.key?(:assignee_id)
117
+ update_fields[:assignee] = attributes[:assignee_id] ? { accountId: attributes[:assignee_id] } : nil
118
+ end
119
+
120
+ if attributes.key?(:due_on)
121
+ update_fields[:duedate] =
122
+ attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on]
123
+ end
124
+
125
+ update_fields[:priority] = attributes[:priority] if attributes.key?(:priority)
126
+
127
+ return find_issue(id_or_key, context) if update_fields.empty?
128
+
129
+ payload = { fields: update_fields }.to_json
130
+ make_request(:put, path, payload)
131
+
132
+ find_issue(id_or_key, context)
133
+ end
134
+
135
+ # Deletes an issue from Jira.
136
+ # @param id_or_key [String, Integer] The ID or key of the issue to delete.
137
+ # @param context [Hash] Optional context. Accepts :delete_subtasks to indicate whether subtasks should be deleted.
138
+ # @return [Boolean] True if successfully deleted.
139
+ def delete_issue(id_or_key, context = {})
140
+ delete_subtasks = context[:delete_subtasks] || false
141
+ path = "/rest/api/3/issue/#{id_or_key}"
142
+ query = { deleteSubtasks: delete_subtasks }
143
+
144
+ make_request(:delete, path, nil, query)
145
+ true
146
+ end
147
+ end
148
+ end
149
+ end
150
+ 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