activeproject 0.2.0 → 0.5.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +248 -51
  3. data/lib/active_project/adapters/base.rb +154 -14
  4. data/lib/active_project/adapters/basecamp/comments.rb +34 -0
  5. data/lib/active_project/adapters/basecamp/connection.rb +10 -23
  6. data/lib/active_project/adapters/basecamp/issues.rb +6 -5
  7. data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
  8. data/lib/active_project/adapters/basecamp_adapter.rb +2 -11
  9. data/lib/active_project/adapters/fizzy/columns.rb +116 -0
  10. data/lib/active_project/adapters/fizzy/comments.rb +129 -0
  11. data/lib/active_project/adapters/fizzy/connection.rb +41 -0
  12. data/lib/active_project/adapters/fizzy/issues.rb +221 -0
  13. data/lib/active_project/adapters/fizzy/projects.rb +105 -0
  14. data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
  15. data/lib/active_project/adapters/github_project/comments.rb +91 -0
  16. data/lib/active_project/adapters/github_project/connection.rb +58 -0
  17. data/lib/active_project/adapters/github_project/helpers.rb +100 -0
  18. data/lib/active_project/adapters/github_project/issues.rb +287 -0
  19. data/lib/active_project/adapters/github_project/projects.rb +139 -0
  20. data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
  21. data/lib/active_project/adapters/github_project.rb +8 -0
  22. data/lib/active_project/adapters/github_project_adapter.rb +65 -0
  23. data/lib/active_project/adapters/github_repo/connection.rb +62 -0
  24. data/lib/active_project/adapters/github_repo/issues.rb +242 -0
  25. data/lib/active_project/adapters/github_repo/projects.rb +116 -0
  26. data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
  27. data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
  28. data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
  29. data/lib/active_project/adapters/jira/comments.rb +41 -0
  30. data/lib/active_project/adapters/jira/connection.rb +43 -24
  31. data/lib/active_project/adapters/jira/issues.rb +21 -7
  32. data/lib/active_project/adapters/jira/projects.rb +3 -1
  33. data/lib/active_project/adapters/jira/transitions.rb +2 -1
  34. data/lib/active_project/adapters/jira/webhooks.rb +5 -7
  35. data/lib/active_project/adapters/jira_adapter.rb +23 -30
  36. data/lib/active_project/adapters/trello/comments.rb +34 -0
  37. data/lib/active_project/adapters/trello/connection.rb +28 -21
  38. data/lib/active_project/adapters/trello/issues.rb +7 -5
  39. data/lib/active_project/adapters/trello/webhooks.rb +5 -7
  40. data/lib/active_project/adapters/trello_adapter.rb +5 -25
  41. data/lib/active_project/association_proxy.rb +3 -2
  42. data/lib/active_project/async.rb +9 -0
  43. data/lib/active_project/configuration.rb +6 -3
  44. data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
  45. data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
  46. data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
  47. data/lib/active_project/configurations/github_configuration.rb +57 -0
  48. data/lib/active_project/configurations/jira_configuration.rb +54 -0
  49. data/lib/active_project/configurations/trello_configuration.rb +24 -2
  50. data/lib/active_project/connections/base.rb +35 -0
  51. data/lib/active_project/connections/graph_ql.rb +83 -0
  52. data/lib/active_project/connections/http_client.rb +79 -0
  53. data/lib/active_project/connections/pagination.rb +44 -0
  54. data/lib/active_project/connections/rest.rb +33 -0
  55. data/lib/active_project/error_mapper.rb +38 -0
  56. data/lib/active_project/errors.rb +13 -0
  57. data/lib/active_project/railtie.rb +33 -0
  58. data/lib/active_project/resource_factory.rb +18 -0
  59. data/lib/active_project/resources/base_resource.rb +13 -14
  60. data/lib/active_project/resources/comment.rb +46 -2
  61. data/lib/active_project/resources/issue.rb +106 -18
  62. data/lib/active_project/resources/persistable_resource.rb +47 -0
  63. data/lib/active_project/resources/project.rb +1 -1
  64. data/lib/active_project/status_mapper.rb +145 -0
  65. data/lib/active_project/version.rb +1 -1
  66. data/lib/active_project/webhook_event.rb +34 -12
  67. data/lib/activeproject.rb +11 -6
  68. metadata +107 -6
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubRepo
6
+ module Connection
7
+ BASE_URL = "https://api.github.com"
8
+
9
+ # Initializes the GitHub Repo Adapter.
10
+ # @param config [Configurations::BaseAdapterConfiguration, Configurations::GithubConfiguration]
11
+ # The configuration object for GitHub.
12
+ # @raise [ArgumentError] if required configuration options (:owner, :repo, :access_token) are missing.
13
+ def initialize(config:)
14
+ unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
15
+ raise ArgumentError, "GithubRepoAdapter requires a BaseAdapterConfiguration object"
16
+ end
17
+
18
+ @config = config
19
+
20
+ # Extract required configuration parameters
21
+ owner = @config.options[:owner]
22
+ repo = @config.options[:repo]
23
+ access_token = @config.options[:access_token]
24
+
25
+ # Validate required configuration parameters
26
+ unless owner && !owner.empty?
27
+ raise ArgumentError, "GithubRepoAdapter configuration requires :owner"
28
+ end
29
+
30
+ unless repo && !repo.empty?
31
+ raise ArgumentError, "GithubRepoAdapter configuration requires :repo"
32
+ end
33
+
34
+ unless access_token && !access_token.empty?
35
+ raise ArgumentError, "GithubRepoAdapter configuration requires :access_token"
36
+ end
37
+
38
+ # Set repository path for API requests
39
+ @repo_path = "repos/#{owner}/#{repo}"
40
+ @connection = initialize_connection
41
+ end
42
+
43
+ private
44
+
45
+ # Initializes the Faraday connection object.
46
+ # @return [Faraday::Connection] Configured Faraday connection for GitHub API
47
+ def initialize_connection
48
+ access_token = @config.options[:access_token]
49
+
50
+ Faraday.new(url: BASE_URL) do |conn|
51
+ conn.request :authorization, :bearer, access_token
52
+ conn.request :retry
53
+ conn.headers["Accept"] = "application/vnd.github.v3+json"
54
+ conn.headers["Content-Type"] = "application/json"
55
+ conn.headers["User-Agent"] = ActiveProject.user_agent
56
+ conn.response :raise_error
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubRepo
6
+ module Issues
7
+ # Lists GitHub issues within a specific repository (project).
8
+ # @param project_id [String] The repository name or full_name
9
+ # @param options [Hash] Optional filtering options.
10
+ # Supported keys:
11
+ # - status: 'open', 'closed', or 'all' (default: 'open')
12
+ # - page: Page number for pagination (default: 1)
13
+ # - per_page: Issues per page (default: 30, max: 100)
14
+ # - sort: 'created', 'updated', or 'comments' (default: 'created')
15
+ # - direction: 'asc' or 'desc' (default: 'desc')
16
+ # @return [Array<ActiveProject::Resources::Issue>]
17
+ def list_issues(project_id, options = {})
18
+ # Determine the repository path to use
19
+ repo_path = determine_repo_path(project_id)
20
+
21
+ # Build query parameters
22
+ query = {}
23
+ query[:state] = options[:status] || "open"
24
+ query[:page] = options[:page] if options[:page]
25
+ query[:per_page] = options[:per_page] if options[:per_page]
26
+ query[:sort] = options[:sort] if options[:sort]
27
+ query[:direction] = options[:direction] if options[:direction]
28
+
29
+ issues_data = make_request(:get, "#{repo_path}/issues", nil, query)
30
+ return [] unless issues_data.is_a?(Array)
31
+
32
+ issues_data.map { |issue_data| map_issue_data(issue_data) }
33
+ end
34
+
35
+ # Finds a specific issue by its number.
36
+ # @param id [String, Integer] The issue number within the repository.
37
+ # @param context [Hash] Optional context.
38
+ # Supported keys:
39
+ # - repo_owner: Repository owner if different from configured owner
40
+ # - repo_name: Repository name if different from configured repo
41
+ # @return [ActiveProject::Resources::Issue]
42
+ def find_issue(id, context = {})
43
+ # Determine the repository path to use
44
+ repo_path = if context[:repo_owner] && context[:repo_name]
45
+ "repos/#{context[:repo_owner]}/#{context[:repo_name]}"
46
+ else
47
+ @repo_path
48
+ end
49
+
50
+ issue_data = make_request(:get, "#{repo_path}/issues/#{id}")
51
+ map_issue_data(issue_data)
52
+ end
53
+
54
+ # Creates a new issue in a GitHub repository.
55
+ # @param project_id [String] The repository name or full_name
56
+ # @param attributes [Hash] Issue attributes.
57
+ # Required: :title
58
+ # Optional: :description (body), :assignees (array of usernames)
59
+ # @return [ActiveProject::Resources::Issue]
60
+ def create_issue(project_id, attributes)
61
+ # Determine the repository path to use
62
+ repo_path = determine_repo_path(project_id)
63
+
64
+ unless attributes[:title] && !attributes[:title].empty?
65
+ raise ArgumentError, "Missing required attribute for GitHub issue creation: :title"
66
+ end
67
+
68
+ data = {
69
+ title: attributes[:title],
70
+ body: attributes[:description]
71
+ }
72
+
73
+ # Convert assignees if present
74
+ if attributes[:assignees] && attributes[:assignees].is_a?(Array)
75
+ if attributes[:assignees].all? { |a| a.is_a?(Hash) && a[:name] }
76
+ data[:assignees] = attributes[:assignees].map { |a| a[:name] }
77
+ else
78
+ data[:assignees] = attributes[:assignees]
79
+ end
80
+ end
81
+
82
+ # Add labels if present
83
+ data[:labels] = attributes[:labels] if attributes[:labels]
84
+
85
+ issue_data = make_request(:post, "#{repo_path}/issues", data)
86
+ map_issue_data(issue_data)
87
+ end
88
+
89
+ # Updates an existing issue in GitHub.
90
+ # @param id [String, Integer] The issue number.
91
+ # @param attributes [Hash] Issue attributes to update.
92
+ # Supported keys: :title, :description (body), :status (state), :assignees
93
+ # @param context [Hash] Optional context.
94
+ # Supported keys:
95
+ # - repo_owner: Repository owner if different from configured owner
96
+ # - repo_name: Repository name if different from configured repo
97
+ # @return [ActiveProject::Resources::Issue]
98
+ def update_issue(id, attributes, context = {})
99
+ # Determine the repository path to use
100
+ repo_path = if context[:repo_owner] && context[:repo_name]
101
+ "repos/#{context[:repo_owner]}/#{context[:repo_name]}"
102
+ else
103
+ @repo_path
104
+ end
105
+
106
+ data = {}
107
+ data[:title] = attributes[:title] if attributes.key?(:title)
108
+ data[:body] = attributes[:description] if attributes.key?(:description)
109
+
110
+ # Handle status mapping
111
+ if attributes.key?(:status)
112
+ state = case attributes[:status]
113
+ when :open, :in_progress then "open"
114
+ when :closed then "closed"
115
+ else attributes[:status].to_s
116
+ end
117
+ data[:state] = state
118
+ end
119
+
120
+ # Convert assignees if present
121
+ if attributes.key?(:assignees)
122
+ if attributes[:assignees].nil? || attributes[:assignees].empty?
123
+ data[:assignees] = []
124
+ elsif attributes[:assignees].all? { |a| a.is_a?(Hash) && a[:name] }
125
+ data[:assignees] = attributes[:assignees].map { |a| a[:name] }
126
+ else
127
+ data[:assignees] = attributes[:assignees]
128
+ end
129
+ end
130
+
131
+ issue_data = make_request(:patch, "#{repo_path}/issues/#{id}", data)
132
+ map_issue_data(issue_data)
133
+ end
134
+
135
+ # Attempts to delete an issue in GitHub, but since GitHub doesn't support
136
+ # true deletion, it closes the issue instead.
137
+ # @param id [String, Integer] The issue number.
138
+ # @param context [Hash] Optional context.
139
+ # @return [Boolean] Always returns false since GitHub doesn't support true deletion.
140
+ def delete_issue(id, context = {})
141
+ # GitHub doesn't support true deletion of issues
142
+ # The best we can do is close the issue
143
+ update_issue(id, { status: :closed }, context)
144
+ false # Return false indicating true deletion is not supported
145
+ end
146
+
147
+ private
148
+
149
+ # Determines the repository path to use based on project_id.
150
+ # @param project_id [String] Repository name or full_name
151
+ # @return [String] The repository API path
152
+ def determine_repo_path(project_id)
153
+ # If project_id matches configured repo or is the same as the full_name, use @repo_path
154
+ if project_id.to_s == @config.options[:repo] ||
155
+ project_id.to_s == "#{@config.options[:owner]}/#{@config.options[:repo]}"
156
+ return @repo_path
157
+ end
158
+
159
+ # If project_id contains a slash, assume it's a full_name
160
+ if project_id.to_s.include?("/")
161
+ return "repos/#{project_id}"
162
+ end
163
+
164
+ # Otherwise, assume it's just a repo name and use the configured owner
165
+ "repos/#{@config.options[:owner]}/#{project_id}"
166
+ end
167
+
168
+ # Maps raw GitHub issue data to an ActiveProject::Resources::Issue
169
+ # @param issue_data [Hash] Raw issue data from GitHub API
170
+ # @return [ActiveProject::Resources::Issue]
171
+ def map_issue_data(issue_data)
172
+ # Map state to status
173
+ status = @config.status_mappings[issue_data["state"]] || :unknown
174
+
175
+ # Map assignees
176
+ assignees = []
177
+ if issue_data["assignees"] && !issue_data["assignees"].empty?
178
+ assignees = issue_data["assignees"].map do |assignee|
179
+ Resources::User.new(
180
+ self,
181
+ id: assignee["id"].to_s,
182
+ name: assignee["login"],
183
+ adapter_source: :github,
184
+ raw_data: assignee
185
+ )
186
+ end
187
+ end
188
+
189
+ # Map reporter (user who created the issue)
190
+ reporter = nil
191
+ if issue_data["user"]
192
+ reporter = Resources::User.new(
193
+ self,
194
+ id: issue_data["user"]["id"].to_s,
195
+ name: issue_data["user"]["login"],
196
+ adapter_source: :github,
197
+ raw_data: issue_data["user"]
198
+ )
199
+ end
200
+
201
+ # Extract project ID (repo name) from the URL
202
+ project_id = nil
203
+ if issue_data["repository_url"]
204
+ # Extract owner/repo from repository_url
205
+ repo_parts = issue_data["repository_url"].split("/")
206
+ project_id = repo_parts.last(2).join("/")
207
+ elsif issue_data["url"]
208
+ # Try to extract from issue URL
209
+ url_parts = issue_data["url"].split("/")
210
+ if url_parts.include?("repos")
211
+ repos_index = url_parts.index("repos")
212
+ if repos_index && repos_index + 2 < url_parts.length
213
+ project_id = "#{url_parts[repos_index + 1]}/#{url_parts[repos_index + 2]}"
214
+ end
215
+ end
216
+ end
217
+
218
+ # If still not found, use configured repo
219
+ project_id ||= "#{@config.options[:owner]}/#{@config.options[:repo]}"
220
+
221
+ Resources::Issue.new(
222
+ self,
223
+ id: issue_data["id"].to_s,
224
+ key: issue_data["number"].to_s,
225
+ title: issue_data["title"],
226
+ description: issue_data["body"],
227
+ status: status,
228
+ assignees: assignees,
229
+ reporter: reporter,
230
+ project_id: project_id,
231
+ created_at: issue_data["created_at"] ? Time.parse(issue_data["created_at"]) : nil,
232
+ updated_at: issue_data["updated_at"] ? Time.parse(issue_data["updated_at"]) : nil,
233
+ due_on: nil, # GitHub issues don't have a built-in due date
234
+ priority: nil, # GitHub issues don't have a built-in priority
235
+ adapter_source: :github,
236
+ raw_data: issue_data
237
+ )
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubRepo
6
+ module Projects
7
+ # Lists projects accessible by the configured credentials.
8
+ # In GitHub's context, this returns the configured repository as a "project".
9
+ # @return [Array<ActiveProject::Resources::Project>] Array containing the repository as a project
10
+ def list_projects
11
+ # Get the configured repository
12
+ repo_data = make_request(:get, @repo_path)
13
+ [ map_repository_to_project(repo_data) ]
14
+ end
15
+
16
+ # Finds a specific project by its ID or name.
17
+ # In GitHub's context, this finds a repository.
18
+ # @param id [String, Integer] The ID or full_name of the repository
19
+ # @return [ActiveProject::Resources::Project, nil] The repository as a project
20
+ def find_project(id)
21
+ # If id is nil or empty, use the configured repo
22
+ if id.nil? || id.to_s.empty?
23
+ id = @config.options[:repo]
24
+ end
25
+
26
+ # If id matches our configured repo, return that
27
+ if id.to_s == @config.options[:repo] || id.to_s == "#{@config.options[:owner]}/#{@config.options[:repo]}"
28
+ repo_data = make_request(:get, @repo_path)
29
+ return map_repository_to_project(repo_data)
30
+ end
31
+
32
+ # Otherwise, try to find by ID or full name
33
+ begin
34
+ repo_data = make_request(:get, "repositories/#{id}")
35
+ map_repository_to_project(repo_data)
36
+ rescue NotFoundError
37
+ # Try with full name path format (owner/repo)
38
+ if id.to_s.include?("/")
39
+ repo_data = make_request(:get, "repos/#{id}")
40
+ map_repository_to_project(repo_data)
41
+ else
42
+ # Try with owner + repo name
43
+ begin
44
+ repo_data = make_request(:get, "repos/#{@config.options[:owner]}/#{id}")
45
+ map_repository_to_project(repo_data)
46
+ rescue NotFoundError
47
+ raise NotFoundError, "GitHub repository with ID or name '#{id}' not found"
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ # Creates a new repository (project).
54
+ # Note: In most cases users will already have repositories set up.
55
+ # @param attributes [Hash] Repository attributes (name, description, etc.)
56
+ # @return [ActiveProject::Resources::Project] The created repository as a project
57
+ def create_project(attributes)
58
+ # Create in organization or user account based on config
59
+ owner = @config.options[:owner]
60
+
61
+ # Determine if creating in org or personal account
62
+ begin
63
+ make_request(:get, "orgs/#{owner}")
64
+ path = "orgs/#{owner}/repos"
65
+ rescue NotFoundError
66
+ path = "user/repos"
67
+ end
68
+
69
+ data = {
70
+ name: attributes[:name],
71
+ description: attributes[:description],
72
+ private: attributes[:private] || false,
73
+ has_issues: attributes[:has_issues] || true
74
+ }
75
+
76
+ repo_data = make_request(:post, path, data)
77
+ map_repository_to_project(repo_data)
78
+ end
79
+
80
+ # Deletes a repository.
81
+ # Note: This is a destructive operation and generally not recommended.
82
+ # @param repo_id [String] The ID or full_name of the repository
83
+ # @return [Boolean] True if successfully deleted
84
+ def delete_project(repo_id)
85
+ # Find the repository first to get its full path
86
+ repo = find_project(repo_id)
87
+ raise NotFoundError, "Repository not found" unless repo
88
+
89
+ # Delete requires the full path in "owner/repo" format
90
+ full_path = repo.name # We store full_name in the name field
91
+ make_request(:delete, "repos/#{full_path}")
92
+ true
93
+ rescue NotFoundError
94
+ false
95
+ end
96
+
97
+ private
98
+
99
+ # Maps a GitHub repository to an ActiveProject project resource.
100
+ # @param repo_data [Hash] Raw repository data from GitHub API
101
+ # @return [ActiveProject::Resources::Project] The mapped project resource
102
+ def map_repository_to_project(repo_data)
103
+ Resources::Project.new(
104
+ self,
105
+ id: repo_data["id"].to_s,
106
+ key: repo_data["name"], # Repository name (without owner)
107
+ name: repo_data["full_name"], # Full repository name (owner/repo)
108
+ description: repo_data["description"],
109
+ adapter_source: :github,
110
+ raw_data: repo_data
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end