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.
- checksums.yaml +4 -4
- data/README.md +201 -55
- data/lib/active_project/adapters/base.rb +154 -14
- data/lib/active_project/adapters/basecamp/comments.rb +34 -0
- data/lib/active_project/adapters/basecamp/connection.rb +6 -24
- data/lib/active_project/adapters/basecamp/issues.rb +6 -5
- data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
- data/lib/active_project/adapters/fizzy/columns.rb +116 -0
- data/lib/active_project/adapters/fizzy/comments.rb +129 -0
- data/lib/active_project/adapters/fizzy/connection.rb +41 -0
- data/lib/active_project/adapters/fizzy/issues.rb +221 -0
- data/lib/active_project/adapters/fizzy/projects.rb +105 -0
- data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
- data/lib/active_project/adapters/github_project/comments.rb +91 -0
- data/lib/active_project/adapters/github_project/connection.rb +58 -0
- data/lib/active_project/adapters/github_project/helpers.rb +100 -0
- data/lib/active_project/adapters/github_project/issues.rb +287 -0
- data/lib/active_project/adapters/github_project/projects.rb +139 -0
- data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
- data/lib/active_project/adapters/github_project.rb +8 -0
- data/lib/active_project/adapters/github_project_adapter.rb +65 -0
- data/lib/active_project/adapters/github_repo/connection.rb +62 -0
- data/lib/active_project/adapters/github_repo/issues.rb +242 -0
- data/lib/active_project/adapters/github_repo/projects.rb +116 -0
- data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
- data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
- data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
- data/lib/active_project/adapters/jira/comments.rb +41 -0
- data/lib/active_project/adapters/jira/connection.rb +15 -15
- data/lib/active_project/adapters/jira/issues.rb +21 -7
- data/lib/active_project/adapters/jira/projects.rb +3 -1
- data/lib/active_project/adapters/jira/transitions.rb +2 -1
- data/lib/active_project/adapters/jira/webhooks.rb +5 -7
- data/lib/active_project/adapters/jira_adapter.rb +23 -3
- data/lib/active_project/adapters/trello/comments.rb +34 -0
- data/lib/active_project/adapters/trello/connection.rb +12 -9
- data/lib/active_project/adapters/trello/issues.rb +7 -5
- data/lib/active_project/adapters/trello/webhooks.rb +5 -7
- data/lib/active_project/adapters/trello_adapter.rb +5 -3
- data/lib/active_project/association_proxy.rb +3 -2
- data/lib/active_project/configuration.rb +6 -3
- data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
- data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
- data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
- data/lib/active_project/configurations/github_configuration.rb +57 -0
- data/lib/active_project/configurations/jira_configuration.rb +54 -0
- data/lib/active_project/configurations/trello_configuration.rb +24 -2
- data/lib/active_project/connections/base.rb +35 -0
- data/lib/active_project/connections/graph_ql.rb +83 -0
- data/lib/active_project/connections/http_client.rb +79 -0
- data/lib/active_project/connections/pagination.rb +44 -0
- data/lib/active_project/connections/rest.rb +33 -0
- data/lib/active_project/error_mapper.rb +38 -0
- data/lib/active_project/errors.rb +13 -0
- data/lib/active_project/railtie.rb +1 -3
- data/lib/active_project/resources/base_resource.rb +13 -14
- data/lib/active_project/resources/comment.rb +46 -2
- data/lib/active_project/resources/issue.rb +106 -18
- data/lib/active_project/resources/persistable_resource.rb +47 -0
- data/lib/active_project/resources/project.rb +1 -1
- data/lib/active_project/status_mapper.rb +145 -0
- data/lib/active_project/version.rb +1 -1
- data/lib/active_project/webhook_event.rb +34 -12
- data/lib/activeproject.rb +9 -6
- metadata +74 -16
- data/lib/active_project/adapters/http_client.rb +0 -71
- 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,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
|