activeproject 0.3.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +201 -55
  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 +6 -24
  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/fizzy/columns.rb +116 -0
  9. data/lib/active_project/adapters/fizzy/comments.rb +129 -0
  10. data/lib/active_project/adapters/fizzy/connection.rb +41 -0
  11. data/lib/active_project/adapters/fizzy/issues.rb +221 -0
  12. data/lib/active_project/adapters/fizzy/projects.rb +105 -0
  13. data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
  14. data/lib/active_project/adapters/github_project/comments.rb +91 -0
  15. data/lib/active_project/adapters/github_project/connection.rb +58 -0
  16. data/lib/active_project/adapters/github_project/helpers.rb +100 -0
  17. data/lib/active_project/adapters/github_project/issues.rb +287 -0
  18. data/lib/active_project/adapters/github_project/projects.rb +139 -0
  19. data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
  20. data/lib/active_project/adapters/github_project.rb +8 -0
  21. data/lib/active_project/adapters/github_project_adapter.rb +65 -0
  22. data/lib/active_project/adapters/github_repo/connection.rb +62 -0
  23. data/lib/active_project/adapters/github_repo/issues.rb +242 -0
  24. data/lib/active_project/adapters/github_repo/projects.rb +116 -0
  25. data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
  26. data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
  27. data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
  28. data/lib/active_project/adapters/jira/comments.rb +41 -0
  29. data/lib/active_project/adapters/jira/connection.rb +15 -15
  30. data/lib/active_project/adapters/jira/issues.rb +21 -7
  31. data/lib/active_project/adapters/jira/projects.rb +3 -1
  32. data/lib/active_project/adapters/jira/transitions.rb +2 -1
  33. data/lib/active_project/adapters/jira/webhooks.rb +5 -7
  34. data/lib/active_project/adapters/jira_adapter.rb +23 -3
  35. data/lib/active_project/adapters/trello/comments.rb +34 -0
  36. data/lib/active_project/adapters/trello/connection.rb +12 -9
  37. data/lib/active_project/adapters/trello/issues.rb +7 -5
  38. data/lib/active_project/adapters/trello/webhooks.rb +5 -7
  39. data/lib/active_project/adapters/trello_adapter.rb +5 -3
  40. data/lib/active_project/association_proxy.rb +3 -2
  41. data/lib/active_project/configuration.rb +6 -3
  42. data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
  43. data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
  44. data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
  45. data/lib/active_project/configurations/github_configuration.rb +57 -0
  46. data/lib/active_project/configurations/jira_configuration.rb +54 -0
  47. data/lib/active_project/configurations/trello_configuration.rb +24 -2
  48. data/lib/active_project/connections/base.rb +35 -0
  49. data/lib/active_project/connections/graph_ql.rb +83 -0
  50. data/lib/active_project/connections/http_client.rb +79 -0
  51. data/lib/active_project/connections/pagination.rb +44 -0
  52. data/lib/active_project/connections/rest.rb +33 -0
  53. data/lib/active_project/error_mapper.rb +38 -0
  54. data/lib/active_project/errors.rb +13 -0
  55. data/lib/active_project/railtie.rb +1 -3
  56. data/lib/active_project/resources/base_resource.rb +13 -14
  57. data/lib/active_project/resources/comment.rb +46 -2
  58. data/lib/active_project/resources/issue.rb +106 -18
  59. data/lib/active_project/resources/persistable_resource.rb +47 -0
  60. data/lib/active_project/resources/project.rb +1 -1
  61. data/lib/active_project/status_mapper.rb +145 -0
  62. data/lib/active_project/version.rb +1 -1
  63. data/lib/active_project/webhook_event.rb +34 -12
  64. data/lib/activeproject.rb +9 -6
  65. metadata +74 -16
  66. data/lib/active_project/adapters/http_client.rb +0 -71
  67. data/lib/active_project/adapters/pagination.rb +0 -68
@@ -25,10 +25,10 @@ module ActiveProject
25
25
  loop do
26
26
  response = @connection.get(path, query)
27
27
  todos_data = begin
28
- JSON.parse(response.body)
29
- rescue StandardError
30
- []
31
- end
28
+ JSON.parse(response.body)
29
+ rescue StandardError
30
+ []
31
+ end
32
32
  break if todos_data.empty?
33
33
 
34
34
  todos_data.each do |todo_data|
@@ -145,7 +145,8 @@ module ActiveProject
145
145
  def delete_issue(todo_id, context = {})
146
146
  project_id = context[:project_id]
147
147
  unless project_id
148
- raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#delete_issue"
148
+ raise ArgumentError,
149
+ "Missing required context: :project_id must be provided for BasecampAdapter#delete_issue"
149
150
  end
150
151
 
151
152
  path = "buckets/#{project_id}/todos/#{todo_id}.json"
@@ -38,7 +38,8 @@ module ActiveProject
38
38
  when /todo_created$/
39
39
  event_type = :issue_created
40
40
  object_kind = :issue
41
- when /todo_assignment_changed$/, /todo_completion_changed$/, /todo_content_updated$/, /todo_description_changed$/, /todo_due_on_changed$/
41
+ when /todo_assignment_changed$/, /todo_completion_changed$/, /todo_content_updated$/,
42
+ /todo_description_changed$/, /todo_due_on_changed$/
42
43
  event_type = :issue_updated
43
44
  object_kind = :issue
44
45
  when /comment_created$/
@@ -52,16 +53,14 @@ module ActiveProject
52
53
  end
53
54
 
54
55
  WebhookEvent.new(
55
- event_type: event_type,
56
- object_kind: object_kind,
57
- event_object_id: event_object_id,
58
- object_key: object_key,
56
+ type: event_type,
57
+ resource_type: object_kind,
58
+ resource_id: event_object_id,
59
59
  project_id: project_id,
60
60
  actor: map_user_data(creator),
61
61
  timestamp: timestamp,
62
- adapter_source: :basecamp,
63
- changes: changes,
64
- object_data: object_data,
62
+ source: webhook_type,
63
+ data: (object_data || {}).merge(changes: changes, object_key: object_key),
65
64
  raw_data: payload
66
65
  )
67
66
  rescue JSON::ParserError
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Fizzy
6
+ module Columns
7
+ # Lists columns (workflow stages) on a board.
8
+ # @param board_id [String] The board ULID.
9
+ # @return [Array<Hash>] An array of column hashes.
10
+ def list_lists(board_id)
11
+ path = "boards/#{board_id}/columns.json"
12
+ response = @connection.get(path)
13
+ columns_data = parse_response(response)
14
+
15
+ columns_data.map do |column_data|
16
+ map_column_data(column_data, board_id)
17
+ end
18
+ rescue Faraday::Error => e
19
+ handle_faraday_error(e)
20
+ end
21
+
22
+ # Creates a new column on a board.
23
+ # @param board_id [String] The board ULID.
24
+ # @param attributes [Hash] Column attributes.
25
+ # - :name [String] Required. The column name.
26
+ # - :color [String] Optional. CSS var color (e.g., "var(--color-card-4)").
27
+ # @return [Hash] The created column hash.
28
+ def create_list(board_id, attributes)
29
+ unless attributes[:name] && !attributes[:name].empty?
30
+ raise ArgumentError, "Missing required attribute for Fizzy column creation: :name"
31
+ end
32
+
33
+ path = "boards/#{board_id}/columns.json"
34
+ payload = {
35
+ column: {
36
+ name: attributes[:name],
37
+ color: attributes[:color]
38
+ }.compact
39
+ }
40
+
41
+ response = @connection.post(path) do |req|
42
+ req.body = payload.to_json
43
+ end
44
+
45
+ # Extract column ID from Location header and fetch it
46
+ location = response.headers["Location"]
47
+ if location
48
+ column_id = location.match(%r{/columns/([^/.]+)})[1]
49
+ find_list(board_id, column_id)
50
+ else
51
+ # Fallback: parse response body if available
52
+ column_data = parse_response(response)
53
+ map_column_data(column_data, board_id)
54
+ end
55
+ rescue Faraday::Error => e
56
+ handle_faraday_error(e)
57
+ end
58
+
59
+ # Finds a specific column.
60
+ # @param board_id [String] The board ULID.
61
+ # @param column_id [String] The column ULID.
62
+ # @return [Hash] The column hash.
63
+ def find_list(board_id, column_id)
64
+ path = "boards/#{board_id}/columns/#{column_id}.json"
65
+ column_data = make_request(:get, path)
66
+ return nil unless column_data
67
+
68
+ map_column_data(column_data, board_id)
69
+ end
70
+
71
+ # Updates a column.
72
+ # @param board_id [String] The board ULID.
73
+ # @param column_id [String] The column ULID.
74
+ # @param attributes [Hash] Attributes to update.
75
+ # - :name [String] The column name.
76
+ # - :color [String] CSS var color.
77
+ # @return [Hash] The updated column hash.
78
+ def update_list(board_id, column_id, attributes)
79
+ path = "boards/#{board_id}/columns/#{column_id}.json"
80
+ payload = {
81
+ column: {
82
+ name: attributes[:name],
83
+ color: attributes[:color]
84
+ }.compact
85
+ }
86
+
87
+ make_request(:put, path, payload.to_json)
88
+ find_list(board_id, column_id)
89
+ end
90
+
91
+ # Deletes a column.
92
+ # @param board_id [String] The board ULID.
93
+ # @param column_id [String] The column ULID.
94
+ # @return [Boolean] True if successfully deleted.
95
+ def delete_list(board_id, column_id)
96
+ path = "boards/#{board_id}/columns/#{column_id}.json"
97
+ make_request(:delete, path)
98
+ true
99
+ end
100
+
101
+ private
102
+
103
+ def map_column_data(column_data, board_id)
104
+ {
105
+ id: column_data["id"],
106
+ name: column_data["name"],
107
+ color: column_data["color"],
108
+ board_id: board_id,
109
+ created_at: column_data["created_at"] ? Time.parse(column_data["created_at"]) : nil,
110
+ raw_data: column_data
111
+ }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Fizzy
6
+ module Comments
7
+ # Lists comments on a card.
8
+ # @param card_number [Integer, String] The card number.
9
+ # @return [Array<ActiveProject::Resources::Comment>] An array of comment resources.
10
+ def list_comments(card_number)
11
+ all_comments = []
12
+ path = "cards/#{card_number}/comments.json"
13
+
14
+ loop do
15
+ response = @connection.get(path)
16
+ comments_data = parse_response(response)
17
+ break if comments_data.empty?
18
+
19
+ comments_data.each do |comment_data|
20
+ all_comments << map_comment_data(comment_data, card_number)
21
+ end
22
+
23
+ next_url = parse_next_link(response.headers["Link"])
24
+ break unless next_url
25
+
26
+ path = extract_path_from_url(next_url)
27
+ end
28
+
29
+ all_comments
30
+ rescue Faraday::Error => e
31
+ handle_faraday_error(e)
32
+ end
33
+
34
+ # Adds a comment to a card.
35
+ # @param card_number [Integer, String] The card number.
36
+ # @param comment_body [String] The comment body (supports HTML rich text).
37
+ # @param context [Hash] Optional context (not required for Fizzy).
38
+ # @return [ActiveProject::Resources::Comment] The created comment resource.
39
+ def add_comment(card_number, comment_body, context = {})
40
+ path = "cards/#{card_number}/comments.json"
41
+ payload = {
42
+ comment: {
43
+ body: comment_body
44
+ }
45
+ }
46
+
47
+ response = @connection.post(path) do |req|
48
+ req.body = payload.to_json
49
+ end
50
+
51
+ # Extract comment ID from Location header and fetch it
52
+ location = response.headers["Location"]
53
+ if location
54
+ comment_id = location.match(%r{/comments/([^/.]+)})[1]
55
+ find_comment(card_number, comment_id)
56
+ else
57
+ # Fallback: parse response body if available
58
+ comment_data = parse_response(response)
59
+ map_comment_data(comment_data, card_number)
60
+ end
61
+ rescue Faraday::Error => e
62
+ handle_faraday_error(e)
63
+ end
64
+
65
+ # Finds a specific comment.
66
+ # @param card_number [Integer, String] The card number.
67
+ # @param comment_id [String] The comment ULID.
68
+ # @return [ActiveProject::Resources::Comment] The comment resource.
69
+ def find_comment(card_number, comment_id)
70
+ path = "cards/#{card_number}/comments/#{comment_id}.json"
71
+ comment_data = make_request(:get, path)
72
+ return nil unless comment_data
73
+
74
+ map_comment_data(comment_data, card_number)
75
+ end
76
+
77
+ # Updates a comment.
78
+ # @param card_number [Integer, String] The card number.
79
+ # @param comment_id [String] The comment ULID.
80
+ # @param comment_body [String] The new comment body.
81
+ # @return [ActiveProject::Resources::Comment] The updated comment resource.
82
+ def update_comment(card_number, comment_id, comment_body)
83
+ path = "cards/#{card_number}/comments/#{comment_id}.json"
84
+ payload = {
85
+ comment: {
86
+ body: comment_body
87
+ }
88
+ }
89
+
90
+ make_request(:put, path, payload.to_json)
91
+ find_comment(card_number, comment_id)
92
+ end
93
+
94
+ # Deletes a comment.
95
+ # @param card_number [Integer, String] The card number.
96
+ # @param comment_id [String] The comment ULID.
97
+ # @return [Boolean] True if successfully deleted.
98
+ def delete_comment(card_number, comment_id)
99
+ path = "cards/#{card_number}/comments/#{comment_id}.json"
100
+ make_request(:delete, path)
101
+ true
102
+ end
103
+
104
+ private
105
+
106
+ def map_comment_data(comment_data, card_number)
107
+ # Fizzy returns body as { plain_text: "...", html: "..." }
108
+ body = if comment_data["body"].is_a?(Hash)
109
+ comment_data["body"]["plain_text"] || comment_data["body"]["html"]
110
+ else
111
+ comment_data["body"]
112
+ end
113
+
114
+ Resources::Comment.new(
115
+ self,
116
+ id: comment_data["id"],
117
+ body: body,
118
+ author: map_user_data(comment_data["creator"]),
119
+ created_at: comment_data["created_at"] ? Time.parse(comment_data["created_at"]) : nil,
120
+ updated_at: comment_data["updated_at"] ? Time.parse(comment_data["updated_at"]) : nil,
121
+ issue_id: card_number.to_s,
122
+ adapter_source: :fizzy,
123
+ raw_data: comment_data
124
+ )
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Fizzy
6
+ module Connection
7
+ include Connections::Rest
8
+
9
+ DEFAULT_BASE_URL = "https://app.fizzy.do"
10
+
11
+ # Initializes the Fizzy Adapter.
12
+ # @param config [Configurations::FizzyConfiguration] The configuration object for Fizzy.
13
+ # @raise [ArgumentError] if required configuration options are missing.
14
+ def initialize(config:)
15
+ unless config.is_a?(ActiveProject::Configurations::FizzyConfiguration)
16
+ raise ArgumentError, "FizzyAdapter requires a FizzyConfiguration object"
17
+ end
18
+
19
+ super(config: config)
20
+
21
+ account_slug = @config.options.fetch(:account_slug)
22
+ access_token = @config.options.fetch(:access_token)
23
+ base_url = @config.options[:base_url] || DEFAULT_BASE_URL
24
+
25
+ # Fizzy uses account_slug in URL path: /:account_slug/boards, etc.
26
+ @base_url = "#{base_url}/#{account_slug}/"
27
+
28
+ init_rest(
29
+ base_url: @base_url,
30
+ auth_middleware: ->(conn) { conn.request :authorization, "Bearer", access_token },
31
+ extra_headers: { "Accept" => "application/json", "Content-Type" => "application/json" }
32
+ )
33
+
34
+ return if account_slug && !account_slug.to_s.empty? && access_token && !access_token.empty?
35
+
36
+ raise ArgumentError, "FizzyAdapter configuration requires :account_slug and :access_token"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Fizzy
6
+ module Issues
7
+ # Lists cards within a specific board.
8
+ # @param board_id [String] The ULID of the Fizzy board.
9
+ # @param options [Hash] Optional options for filtering.
10
+ # - :tag_ids [Array<String>] Filter by tag IDs
11
+ # - :assignee_ids [Array<String>] Filter by assignee user IDs
12
+ # - :indexed_by [String] Filter: all, closed, not_now, stalled, postponing_soon, golden
13
+ # - :sorted_by [String] Sort: latest, newest, oldest
14
+ # @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
15
+ def list_issues(board_id, options = {})
16
+ all_cards = []
17
+ query = { "board_ids[]" => board_id }
18
+
19
+ # Add optional filters
20
+ options[:tag_ids]&.each { |id| query["tag_ids[]"] = id }
21
+ options[:assignee_ids]&.each { |id| query["assignee_ids[]"] = id }
22
+ query[:indexed_by] = options[:indexed_by] if options[:indexed_by]
23
+ query[:sorted_by] = options[:sorted_by] if options[:sorted_by]
24
+
25
+ path = "cards.json"
26
+
27
+ loop do
28
+ response = @connection.get(path, query)
29
+ cards_data = parse_response(response)
30
+ break if cards_data.empty?
31
+
32
+ cards_data.each do |card_data|
33
+ all_cards << map_card_data(card_data, board_id)
34
+ end
35
+
36
+ next_url = parse_next_link(response.headers["Link"])
37
+ break unless next_url
38
+
39
+ path = extract_path_from_url(next_url)
40
+ query = {} # Query params are in the URL now
41
+ end
42
+
43
+ all_cards
44
+ rescue Faraday::Error => e
45
+ handle_faraday_error(e)
46
+ end
47
+
48
+ # Finds a specific card by its number.
49
+ # @param card_number [Integer, String] The card number (sequential per account).
50
+ # @param context [Hash] Optional context (not required for Fizzy).
51
+ # @return [ActiveProject::Resources::Issue] The issue resource.
52
+ def find_issue(card_number, context = {})
53
+ path = "cards/#{card_number}.json"
54
+ card_data = make_request(:get, path)
55
+ return nil unless card_data
56
+
57
+ board_id = card_data.dig("board", "id")
58
+ map_card_data(card_data, board_id)
59
+ end
60
+
61
+ # Creates a new card in a board.
62
+ # @param board_id [String] The ULID of the Fizzy board.
63
+ # @param attributes [Hash] Card attributes.
64
+ # - :title [String] Required. The card title.
65
+ # - :description [String] Optional. Rich text description (HTML).
66
+ # - :status [String] Optional. Initial status: published (default), drafted.
67
+ # - :tag_ids [Array<String>] Optional. Tag IDs to apply.
68
+ # @return [ActiveProject::Resources::Issue] The created issue resource.
69
+ def create_issue(board_id, attributes)
70
+ title = attributes[:title]
71
+ unless title && !title.empty?
72
+ raise ArgumentError, "Missing required attribute for Fizzy card creation: :title"
73
+ end
74
+
75
+ path = "boards/#{board_id}/cards.json"
76
+ payload = {
77
+ card: {
78
+ title: title,
79
+ description: attributes[:description],
80
+ status: attributes[:status] || "published",
81
+ tag_ids: attributes[:tag_ids]
82
+ }.compact
83
+ }
84
+
85
+ # Fizzy returns 201 Created with Location header
86
+ response = @connection.post(path) do |req|
87
+ req.body = payload.to_json
88
+ end
89
+
90
+ # Extract card number from Location header and fetch it
91
+ location = response.headers["Location"]
92
+ if location
93
+ card_number = location.match(%r{/cards/(\d+)})[1]
94
+ find_issue(card_number)
95
+ else
96
+ # Fallback: parse response body if available
97
+ card_data = parse_response(response)
98
+ map_card_data(card_data, board_id)
99
+ end
100
+ rescue Faraday::Error => e
101
+ handle_faraday_error(e)
102
+ end
103
+
104
+ # Updates an existing card.
105
+ # @param card_number [Integer, String] The card number.
106
+ # @param attributes [Hash] Attributes to update.
107
+ # - :title [String] The card title.
108
+ # - :description [String] Rich text description.
109
+ # - :status [Symbol] Status change: :open, :closed, :on_hold.
110
+ # - :tag_ids [Array<String>] Tag IDs to apply.
111
+ # @param context [Hash] Optional context (not required for Fizzy).
112
+ # @return [ActiveProject::Resources::Issue] The updated issue resource.
113
+ def update_issue(card_number, attributes, context = {})
114
+ put_payload = {}
115
+ put_payload[:title] = attributes[:title] if attributes.key?(:title)
116
+ put_payload[:description] = attributes[:description] if attributes.key?(:description)
117
+ put_payload[:tag_ids] = attributes[:tag_ids] if attributes.key?(:tag_ids)
118
+
119
+ status_change_required = attributes.key?(:status)
120
+ target_status = attributes[:status] if status_change_required
121
+
122
+ unless !put_payload.empty? || status_change_required
123
+ raise ArgumentError, "No attributes provided to update for FizzyAdapter#update_issue"
124
+ end
125
+
126
+ # Update basic fields via PUT
127
+ unless put_payload.empty?
128
+ put_path = "cards/#{card_number}.json"
129
+ make_request(:put, put_path, { card: put_payload }.to_json)
130
+ end
131
+
132
+ # Handle status changes via separate endpoints
133
+ if status_change_required
134
+ handle_status_change(card_number, target_status)
135
+ end
136
+
137
+ find_issue(card_number, context)
138
+ end
139
+
140
+ # Deletes a card.
141
+ # @param card_number [Integer, String] The card number to delete.
142
+ # @param context [Hash] Optional context (not required for Fizzy).
143
+ # @return [Boolean] True if successfully deleted.
144
+ def delete_issue(card_number, context = {})
145
+ path = "cards/#{card_number}.json"
146
+ make_request(:delete, path)
147
+ true
148
+ end
149
+
150
+ private
151
+
152
+ def handle_status_change(card_number, target_status)
153
+ case target_status
154
+ when :closed
155
+ # POST /cards/:num/closure
156
+ make_request(:post, "cards/#{card_number}/closure.json")
157
+ when :open
158
+ # DELETE /cards/:num/closure (reopen)
159
+ begin
160
+ make_request(:delete, "cards/#{card_number}/closure.json")
161
+ rescue NotFoundError
162
+ # Card wasn't closed, ignore
163
+ end
164
+ when :on_hold
165
+ # POST /cards/:num/not_now
166
+ make_request(:post, "cards/#{card_number}/not_now.json")
167
+ end
168
+ end
169
+
170
+ def map_card_data(card_data, board_id)
171
+ # Determine status based on card state and column
172
+ status = determine_card_status(card_data, board_id)
173
+
174
+ # Map creator
175
+ creator = map_user_data(card_data["creator"])
176
+
177
+ # Map assignees if present (Fizzy cards can have multiple assignees)
178
+ assignees = (card_data["assignees"] || []).map { |a| map_user_data(a) }.compact
179
+
180
+ Resources::Issue.new(
181
+ self,
182
+ id: card_data["id"],
183
+ key: card_data["number"]&.to_s,
184
+ title: card_data["title"],
185
+ description: card_data["description"],
186
+ status: status,
187
+ assignees: assignees,
188
+ reporter: creator,
189
+ project_id: board_id || card_data.dig("board", "id"),
190
+ created_at: card_data["created_at"] ? Time.parse(card_data["created_at"]) : nil,
191
+ updated_at: card_data["last_active_at"] ? Time.parse(card_data["last_active_at"]) : nil,
192
+ due_on: nil, # Fizzy doesn't have due dates on cards
193
+ priority: nil,
194
+ adapter_source: :fizzy,
195
+ raw_data: card_data
196
+ )
197
+ end
198
+
199
+ def determine_card_status(card_data, board_id)
200
+ # Check for closed status
201
+ return :closed if card_data["closed"] == true
202
+
203
+ # Check for not_now status
204
+ return :on_hold if card_data["not_now"].present?
205
+
206
+ # Check column-based status mapping from config
207
+ column_name = card_data.dig("column", "name")
208
+ if column_name && board_id
209
+ board_mappings = @config.status_mappings[board_id]
210
+ if board_mappings && board_mappings.key?(column_name)
211
+ return board_mappings[column_name]
212
+ end
213
+ end
214
+
215
+ # Default: published cards are open
216
+ card_data["status"] == "drafted" ? :open : :open
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Fizzy
6
+ module Projects
7
+ # Lists boards 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_boards = []
12
+ path = "boards.json"
13
+
14
+ loop do
15
+ response = @connection.get(path)
16
+ boards_data = parse_response(response)
17
+ break if boards_data.empty?
18
+
19
+ boards_data.each do |board_data|
20
+ all_boards << map_board_data(board_data)
21
+ end
22
+
23
+ next_url = parse_next_link(response.headers["Link"])
24
+ break unless next_url
25
+
26
+ path = extract_path_from_url(next_url)
27
+ end
28
+
29
+ all_boards
30
+ rescue Faraday::Error => e
31
+ handle_faraday_error(e)
32
+ end
33
+
34
+ # Finds a specific board by its ID.
35
+ # @param board_id [String] The ULID of the Fizzy board.
36
+ # @return [ActiveProject::Resources::Project] The project resource.
37
+ def find_project(board_id)
38
+ path = "boards/#{board_id}.json"
39
+ board_data = make_request(:get, path)
40
+ return nil unless board_data
41
+
42
+ map_board_data(board_data)
43
+ end
44
+
45
+ # Creates a new board in Fizzy.
46
+ # @param attributes [Hash] Board attributes. Required: :name. Optional: :all_access, :auto_postpone_period.
47
+ # @return [ActiveProject::Resources::Project] The created project resource.
48
+ def create_project(attributes)
49
+ unless attributes[:name] && !attributes[:name].empty?
50
+ raise ArgumentError, "Missing required attribute for Fizzy board creation: :name"
51
+ end
52
+
53
+ path = "boards.json"
54
+ payload = {
55
+ board: {
56
+ name: attributes[:name],
57
+ all_access: attributes.fetch(:all_access, true),
58
+ auto_postpone_period: attributes[:auto_postpone_period]
59
+ }.compact
60
+ }
61
+
62
+ # Fizzy returns 201 Created with Location header, need to fetch the board
63
+ response = @connection.post(path) do |req|
64
+ req.body = payload.to_json
65
+ end
66
+
67
+ # Extract board ID from Location header and fetch it
68
+ location = response.headers["Location"]
69
+ if location
70
+ board_id = location.match(%r{/boards/([^/.]+)})[1]
71
+ find_project(board_id)
72
+ else
73
+ # Fallback: parse response body if available
74
+ board_data = parse_response(response)
75
+ map_board_data(board_data)
76
+ end
77
+ rescue Faraday::Error => e
78
+ handle_faraday_error(e)
79
+ end
80
+
81
+ # Deletes a board in Fizzy.
82
+ # @param board_id [String] The ULID of the board to delete.
83
+ # @return [Boolean] true if deletion was successful (API returns 204).
84
+ def delete_project(board_id)
85
+ path = "boards/#{board_id}.json"
86
+ make_request(:delete, path)
87
+ true
88
+ end
89
+
90
+ private
91
+
92
+ def map_board_data(board_data)
93
+ Resources::Project.new(
94
+ self,
95
+ id: board_data["id"],
96
+ key: nil,
97
+ name: board_data["name"],
98
+ adapter_source: :fizzy,
99
+ raw_data: board_data
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end