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
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubProject
6
+ module Projects
7
+ include Helpers
8
+
9
+ #
10
+ # List all ProjectsV2 for a GitHub user.
11
+ #
12
+ # Because nothing says "weekend hustle" like spinning up yet another project,
13
+ # posting "🚀 Day 1 of #BuildInPublic" on X, and immediately abandoning it by Tuesday.
14
+ #
15
+ def list_projects(options = {})
16
+ owner = options[:owner] || @config.owner
17
+ page_size = options.fetch(:page_size, 50)
18
+
19
+ # ---- build query template ------------------------------------------------
20
+ query_tmpl = lambda { |kind|
21
+ # rubocop:disable Layout/LineLength
22
+ <<~GQL
23
+ query($login:String!, $first:Int!, $after:String){
24
+ #{kind}(login:$login){
25
+ projectsV2(first:$first, after:$after){
26
+ nodes{ id number title }
27
+ pageInfo{ hasNextPage endCursor }
28
+ }
29
+ }
30
+ }
31
+ GQL
32
+ # rubocop:enable Layout/LineLength
33
+ }
34
+
35
+ # ---- fetch pages, trying user first, then organisation -------------------
36
+ begin
37
+ nodes = fetch_all_pages(
38
+ query_tmpl.call("user"),
39
+ variables: { login: owner, first: page_size },
40
+ connection_path: %w[user projectsV2]
41
+ )
42
+ rescue ActiveProject::NotFoundError, ActiveProject::ValidationError
43
+ nodes = fetch_all_pages(
44
+ query_tmpl.call("organization"),
45
+ variables: { login: owner, first: page_size },
46
+ connection_path: %w[organization projectsV2]
47
+ )
48
+ end
49
+
50
+ nodes.map { |proj| build_project_resource(proj) }
51
+ end
52
+
53
+ #
54
+ # Find a project either by its public-facing number or internal node ID.
55
+ #
56
+ # Supports both:
57
+ # - people who proudly know their project number (respect)
58
+ # - and people copy-pasting weird node IDs at 2am on a Saturday.
59
+ #
60
+ def find_project(id_or_number)
61
+ if id_or_number.to_s =~ /^\d+$/
62
+ # UI-visible number path: the civilized way.
63
+ owner = @config.owner
64
+ num = id_or_number.to_i
65
+ q = <<~GQL
66
+ query($login: String!, $num: Int!) {
67
+ user(login: $login) {
68
+ projectV2(number: $num) { id number title }
69
+ }
70
+ }
71
+ GQL
72
+ data = request_gql(query: q, variables: { login: owner, num: num })
73
+ proj = data.dig("user", "projectV2") or raise NotFoundError
74
+ else
75
+ # Node ID path: the "I swear I know what I'm doing" path.
76
+ proj = request_gql(
77
+ query: "query($id:ID!){ node(id:$id){ ... on ProjectV2 { id number title }}}",
78
+ variables: { id: id_or_number }
79
+ )["node"]
80
+ end
81
+ build_project_resource(proj)
82
+ end
83
+
84
+ #
85
+ # Create a shiny new GitHub Project.
86
+ #
87
+ # Required:
88
+ # - :name → preferably a trendy one like "TasklyAgent" or "ZenboardAI"
89
+ #
90
+ # Step 1: create project.
91
+ # Step 2: tweet "Just shipped something huge 🔥 #buildinpublic".
92
+ # Step 3: forget about it.
93
+ #
94
+ def create_project(attributes)
95
+ name = attributes[:name] or raise ArgumentError, "Missing :name"
96
+ owner_id = owner_node_id(@config.owner)
97
+ q = <<~GQL
98
+ mutation($name:String!, $owner:ID!){
99
+ createProjectV2(input:{title:$name,ownerId:$owner}) { projectV2 { id number title } }
100
+ }
101
+ GQL
102
+ proj = request_gql(query: q, variables: { name: name, owner: owner_id })
103
+ .dig("createProjectV2", "projectV2")
104
+ build_project_resource(proj)
105
+ end
106
+
107
+ #
108
+ # Soft-delete a project by "closing" it.
109
+ #
110
+ # GitHub doesn't believe in real deletion yet, only ghosting.
111
+ # Just like that app idea you posted about but never launched.
112
+ #
113
+ def delete_project(project_id)
114
+ q = <<~GQL
115
+ mutation($id:ID!){ updateProjectV2(input:{projectId:$id, closed:true}) { clientMutationId } }
116
+ GQL
117
+ request_gql(query: q, variables: { id: project_id })
118
+ true
119
+ end
120
+
121
+ private
122
+
123
+ #
124
+ # Turn raw GraphQL sludge into a proper Project resource.
125
+ #
126
+ # For when you need your side project to at least *look* real in screenshots.
127
+ #
128
+ def build_project_resource(proj)
129
+ Resources::Project.new(self,
130
+ id: proj["id"],
131
+ key: proj["number"],
132
+ name: proj["title"],
133
+ adapter_source: :github,
134
+ raw_data: proj)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module ActiveProject
7
+ module Adapters
8
+ module GithubProject
9
+ # GitHub Project webhook processing for GitHub Projects V2.
10
+ # Handles project item and project events from GitHub webhooks.
11
+ module Webhooks
12
+ # Verifies GitHub webhook signature using SHA256 HMAC.
13
+ # @param request_body [String] Raw request body
14
+ # @param signature_header [String] Value of X-Hub-Signature-256 header
15
+ # @param webhook_secret [String] GitHub webhook secret
16
+ # @return [Boolean] true if signature is valid
17
+ def verify_webhook_signature(request_body, signature_header, webhook_secret: nil)
18
+ return false unless webhook_secret && signature_header
19
+
20
+ # GitHub sends signature as "sha256=<hash>"
21
+ return false unless signature_header.start_with?("sha256=")
22
+
23
+ expected_signature = signature_header[7..-1] # Remove "sha256=" prefix
24
+ computed_signature = OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, request_body)
25
+
26
+ # Use secure comparison to prevent timing attacks
27
+ secure_compare(expected_signature, computed_signature)
28
+ end
29
+
30
+ # Parses GitHub webhook payload into standardized WebhookEvent.
31
+ # Supports projects_v2_item and projects_v2 events.
32
+ # @param request_body [String] Raw JSON payload
33
+ # @param headers [Hash] Request headers (for X-GitHub-Event)
34
+ # @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unsupported
35
+ def parse_webhook(request_body, headers = {})
36
+ payload = JSON.parse(request_body)
37
+ github_event = headers["X-GitHub-Event"] || headers["x-github-event"]
38
+
39
+ case github_event
40
+ when "projects_v2_item"
41
+ parse_project_item_event(payload)
42
+ when "projects_v2"
43
+ parse_project_event(payload)
44
+ else
45
+ # Return nil for unsupported events (not an error)
46
+ nil
47
+ end
48
+ rescue JSON::ParserError
49
+ # Invalid JSON - return nil
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ # Secure string comparison to prevent timing attacks
56
+ def secure_compare(a, b)
57
+ return false unless a.bytesize == b.bytesize
58
+
59
+ l = a.unpack("C*")
60
+ r = b.unpack("C*")
61
+
62
+ result = 0
63
+ l.zip(r) { |x, y| result |= x ^ y }
64
+ result == 0
65
+ end
66
+
67
+ # Parses projects_v2_item events (item created, edited, deleted, etc.)
68
+ def parse_project_item_event(payload)
69
+ action = payload["action"]
70
+ item = payload["projects_v2_item"]
71
+ return nil unless item
72
+
73
+ # Map GitHub actions to ActiveProject event types
74
+ event_type = case action
75
+ when "created" then "issue_created"
76
+ when "edited" then "issue_updated"
77
+ when "deleted" then "issue_deleted"
78
+ when "archived" then "issue_updated"
79
+ when "restored" then "issue_updated"
80
+ else action # Pass through unknown actions
81
+ end
82
+
83
+ # Extract project info
84
+ project = payload["projects_v2"]
85
+ project_id = project&.dig("node_id")
86
+
87
+ # Extract actor (sender)
88
+ sender = payload["sender"]
89
+ actor = map_user_data(sender) if sender
90
+
91
+ # Build changes hash for updates
92
+ changes = {}
93
+ if action == "edited" && payload["changes"]
94
+ payload["changes"].each do |field, change_data|
95
+ changes[field] = {
96
+ from: change_data["from"],
97
+ to: change_data["to"]
98
+ }
99
+ end
100
+ end
101
+
102
+ # Extract content (linked issue/PR) if available
103
+ content = item["content"]
104
+ object_key = content&.dig("number")&.to_s
105
+ object_data = content || item
106
+
107
+ WebhookEvent.new(
108
+ event_type: event_type,
109
+ object_kind: "issue", # GitHub Project items are treated as issues
110
+ event_object_id: item["node_id"],
111
+ object_key: object_key,
112
+ project_id: project_id,
113
+ actor: actor,
114
+ timestamp: Time.parse(payload["created_at"] || Time.now.iso8601),
115
+ adapter_source: webhook_type,
116
+ changes: changes,
117
+ object_data: object_data,
118
+ raw_data: payload
119
+ )
120
+ end
121
+
122
+ # Parses projects_v2 events (project created, edited, deleted, etc.)
123
+ def parse_project_event(payload)
124
+ action = payload["action"]
125
+ project = payload["projects_v2"]
126
+ return nil unless project
127
+
128
+ # Map GitHub actions to ActiveProject event types
129
+ event_type = case action
130
+ when "created" then "project_created"
131
+ when "edited" then "project_updated"
132
+ when "deleted" then "project_deleted"
133
+ else action # Pass through unknown actions
134
+ end
135
+
136
+ # Extract actor (sender)
137
+ sender = payload["sender"]
138
+ actor = map_user_data(sender) if sender
139
+
140
+ # Build changes hash for updates
141
+ changes = {}
142
+ if action == "edited" && payload["changes"]
143
+ payload["changes"].each do |field, change_data|
144
+ changes[field] = {
145
+ from: change_data["from"],
146
+ to: change_data["to"]
147
+ }
148
+ end
149
+ end
150
+
151
+ WebhookEvent.new(
152
+ event_type: event_type,
153
+ object_kind: "project",
154
+ event_object_id: project["node_id"],
155
+ object_key: project["number"]&.to_s,
156
+ project_id: project["node_id"],
157
+ actor: actor,
158
+ timestamp: Time.parse(payload["created_at"] || Time.now.iso8601),
159
+ adapter_source: webhook_type,
160
+ changes: changes,
161
+ object_data: project,
162
+ raw_data: payload
163
+ )
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubProject
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ class GithubProjectAdapter < Base
6
+ include GithubProject::Connection
7
+ include GithubProject::Projects
8
+ include GithubProject::Issues
9
+ include GithubProject::Comments
10
+ include GithubProject::Webhooks
11
+
12
+ def projects = ResourceFactory.new(adapter: self, resource_class: Resources::Project)
13
+ def issues = ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
14
+
15
+ def get_current_user
16
+ q = "query{viewer{ id login name email }}"
17
+ data = request_gql(query: q)
18
+ map_user_data(data["viewer"])
19
+ end
20
+
21
+ def connected? = begin
22
+ !get_current_user.nil?
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ def adapter_type
28
+ :github_project
29
+ end
30
+
31
+ def update_issue(id, attributes, context = {})
32
+ project_id = context[:project_id] || raise(ArgumentError,
33
+ "GithubProjectAdapter requires :project_id in context")
34
+ update_issue_internal(project_id, id, attributes)
35
+ end
36
+
37
+ def delete_issue(id, context = {})
38
+ project_id = context[:project_id] || raise(ArgumentError,
39
+ "GithubProjectAdapter requires :project_id in context")
40
+ delete_issue_internal(project_id, id)
41
+ end
42
+
43
+ private
44
+
45
+ def map_user_data(person_data)
46
+ return nil unless person_data && person_data["id"]
47
+
48
+ Resources::User.new(self, # Pass adapter instance
49
+ id: person_data["id"],
50
+ name: person_data["name"],
51
+ email: person_data["email"],
52
+ adapter_source: :github_project,
53
+ raw_data: person_data)
54
+ end
55
+
56
+ def update_issue_internal(project_id, item_id, attrs = {})
57
+ update_issue_original(project_id, item_id, attrs)
58
+ end
59
+
60
+ def delete_issue_internal(project_id, item_id)
61
+ delete_issue_original(project_id, item_id)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -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