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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -82
  3. data/Rakefile +4 -2
  4. data/lib/active_project/adapters/base.rb +3 -14
  5. data/lib/active_project/adapters/basecamp/comments.rb +27 -0
  6. data/lib/active_project/adapters/basecamp/connection.rb +49 -0
  7. data/lib/active_project/adapters/basecamp/issues.rb +139 -0
  8. data/lib/active_project/adapters/basecamp/lists.rb +54 -0
  9. data/lib/active_project/adapters/basecamp/projects.rb +110 -0
  10. data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
  11. data/lib/active_project/adapters/basecamp_adapter.rb +46 -449
  12. data/lib/active_project/adapters/jira/comments.rb +28 -0
  13. data/lib/active_project/adapters/jira/connection.rb +47 -0
  14. data/lib/active_project/adapters/jira/issues.rb +132 -0
  15. data/lib/active_project/adapters/jira/projects.rb +100 -0
  16. data/lib/active_project/adapters/jira/transitions.rb +68 -0
  17. data/lib/active_project/adapters/jira/webhooks.rb +89 -0
  18. data/lib/active_project/adapters/jira_adapter.rb +59 -486
  19. data/lib/active_project/adapters/trello/comments.rb +21 -0
  20. data/lib/active_project/adapters/trello/connection.rb +37 -0
  21. data/lib/active_project/adapters/trello/issues.rb +117 -0
  22. data/lib/active_project/adapters/trello/lists.rb +27 -0
  23. data/lib/active_project/adapters/trello/projects.rb +82 -0
  24. data/lib/active_project/adapters/trello/webhooks.rb +91 -0
  25. data/lib/active_project/adapters/trello_adapter.rb +54 -377
  26. data/lib/active_project/association_proxy.rb +10 -3
  27. data/lib/active_project/configuration.rb +23 -17
  28. data/lib/active_project/configurations/trello_configuration.rb +1 -3
  29. data/lib/active_project/resource_factory.rb +20 -10
  30. data/lib/active_project/resources/comment.rb +0 -5
  31. data/lib/active_project/resources/issue.rb +0 -5
  32. data/lib/active_project/resources/project.rb +0 -3
  33. data/lib/active_project/resources/user.rb +0 -1
  34. data/lib/active_project/version.rb +3 -1
  35. data/lib/activeproject.rb +67 -15
  36. metadata +26 -8
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Jira
6
+ module Connection
7
+ # Initializes the Jira Adapter.
8
+ # @param config [Configurations::BaseAdapterConfiguration] The configuration object for Jira.
9
+ # @raise [ArgumentError] if required configuration options (:site_url, :username, :api_token) are missing.
10
+ def initialize(config:)
11
+ unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
12
+ raise ArgumentError, "JiraAdapter requires a BaseAdapterConfiguration object"
13
+ end
14
+
15
+ @config = config
16
+
17
+ unless @config.options[:site_url] && !@config.options[:site_url].empty? &&
18
+ @config.options[:username] && !@config.options[:username].empty? &&
19
+ @config.options[:api_token] && !@config.options[:api_token].empty?
20
+ raise ArgumentError, "JiraAdapter configuration requires :site_url, :username, and :api_token"
21
+ end
22
+
23
+ @connection = initialize_connection
24
+ end
25
+
26
+ private
27
+
28
+ # Initializes the Faraday connection object.
29
+ def initialize_connection
30
+ site_url = @config.options[:site_url].chomp("/")
31
+ username = @config.options[:username]
32
+ api_token = @config.options[:api_token]
33
+
34
+ Faraday.new(url: site_url) do |conn|
35
+ conn.request :authorization, :basic, username, api_token
36
+ conn.request :retry
37
+ # Important: Keep raise_error middleware *after* retry
38
+ # conn.response :raise_error # Defer raising error to handle_faraday_error
39
+ conn.headers["Content-Type"] = "application/json"
40
+ conn.headers["Accept"] = "application/json"
41
+ conn.headers["User-Agent"] = ActiveProject.user_agent
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -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