collavre_linear 0.1.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 +7 -0
- data/app/controllers/collavre_linear/application_controller.rb +21 -0
- data/app/controllers/collavre_linear/auth_controller.rb +135 -0
- data/app/controllers/collavre_linear/creatives/integrations_controller.rb +271 -0
- data/app/controllers/collavre_linear/webhooks_controller.rb +187 -0
- data/app/javascript/collavre_linear.js +199 -0
- data/app/jobs/collavre_linear/inbound_apply_job.rb +36 -0
- data/app/jobs/collavre_linear/outbound_archive_job.rb +29 -0
- data/app/jobs/collavre_linear/outbound_comment_delete_job.rb +41 -0
- data/app/jobs/collavre_linear/outbound_comment_sync_job.rb +112 -0
- data/app/jobs/collavre_linear/outbound_comment_update_job.rb +55 -0
- data/app/jobs/collavre_linear/outbound_sync_job.rb +39 -0
- data/app/models/collavre_linear/account.rb +29 -0
- data/app/models/collavre_linear/application_record.rb +7 -0
- data/app/models/collavre_linear/comment_link.rb +12 -0
- data/app/models/collavre_linear/issue_link.rb +20 -0
- data/app/models/collavre_linear/project_link.rb +76 -0
- data/app/observers/collavre_linear/comment_sync_observer.rb +97 -0
- data/app/observers/collavre_linear/creative_sync_observer.rb +162 -0
- data/app/services/collavre_linear/client.rb +425 -0
- data/app/services/collavre_linear/comment_formatter.rb +34 -0
- data/app/services/collavre_linear/comment_syncability.rb +33 -0
- data/app/services/collavre_linear/creative_exporter.rb +352 -0
- data/app/services/collavre_linear/echo_guard.rb +36 -0
- data/app/services/collavre_linear/field_mapper.rb +119 -0
- data/app/services/collavre_linear/inbound_applier.rb +599 -0
- data/app/services/collavre_linear/o_auth_token_service.rb +163 -0
- data/app/views/collavre_linear/auth/setup.html.erb +63 -0
- data/app/views/collavre_linear/integrations/_modal.html.erb +200 -0
- data/config/initializers/integration_settings.rb +8 -0
- data/config/locales/en.yml +49 -0
- data/config/locales/ko.yml +49 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260701000000_create_linear_accounts.rb +20 -0
- data/db/migrate/20260701000001_create_collavre_linear_project_links.rb +18 -0
- data/db/migrate/20260701000002_create_collavre_linear_issue_links.rb +22 -0
- data/db/migrate/20260701000003_create_collavre_linear_comment_links.rb +17 -0
- data/db/migrate/20260701000004_add_last_outbound_at_to_linear_links.rb +6 -0
- data/db/migrate/20260701000005_add_webhook_id_to_linear_project_links.rb +5 -0
- data/db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb +17 -0
- data/db/migrate/20260701000007_enforce_one_project_link_per_creative.rb +19 -0
- data/db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb +8 -0
- data/lib/collavre_linear/engine.rb +122 -0
- data/lib/collavre_linear/version.rb +3 -0
- data/lib/collavre_linear.rb +5 -0
- metadata +115 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreLinear
|
|
4
|
+
# Auto-triggers an outbound Linear comment sync when a human posts a chat
|
|
5
|
+
# message in the Main topic of a creative that is linked to a Linear issue.
|
|
6
|
+
#
|
|
7
|
+
# Mixed into Collavre::Comment via the engine's `to_prepare` block:
|
|
8
|
+
#
|
|
9
|
+
# Collavre::Comment.include CollavreLinear::CommentSyncObserver
|
|
10
|
+
#
|
|
11
|
+
# Mirrors CreativeSyncObserver: the transient `skip_linear_sync` flag lets the
|
|
12
|
+
# inbound applier suppress the echo when it mirrors a Linear comment locally
|
|
13
|
+
# (otherwise the mirrored comment would bounce straight back out to Linear).
|
|
14
|
+
module CommentSyncObserver
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
# Transient, per-record suppression flag set by the inbound applier on the
|
|
19
|
+
# comment it mirrors from Linear so this after_commit does not echo it back.
|
|
20
|
+
attr_accessor :skip_linear_sync
|
|
21
|
+
|
|
22
|
+
after_create_commit :enqueue_linear_comment_sync
|
|
23
|
+
after_update_commit :enqueue_linear_comment_update
|
|
24
|
+
after_destroy_commit :enqueue_linear_comment_delete
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def enqueue_linear_comment_sync
|
|
30
|
+
return unless linear_syncable_comment?
|
|
31
|
+
|
|
32
|
+
CollavreLinear::OutboundCommentSyncJob.perform_later(id)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
log_enqueue_failure("sync", e)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Propagate a local edit to Linear. A single core update can change content,
|
|
38
|
+
# `private`, and `topic_id` together, so re-check syncability: if the comment
|
|
39
|
+
# turned private or moved out of Main, remove its Linear mirror rather than
|
|
40
|
+
# pushing the now-hidden body outward. `skip_linear_sync` suppresses the echo
|
|
41
|
+
# when the inbound applier rewrites content from Linear.
|
|
42
|
+
def enqueue_linear_comment_update
|
|
43
|
+
return if skip_linear_sync
|
|
44
|
+
|
|
45
|
+
comment_link = CollavreLinear::CommentLink.find_by(comment_id: id)
|
|
46
|
+
|
|
47
|
+
unless comment_link
|
|
48
|
+
# No mirror: either never synced, or a prior update tore it down when the
|
|
49
|
+
# comment went private / left Main. If it is syncable again, recreate the
|
|
50
|
+
# mirror so re-entering visibility doesn't leave it permanently off Linear.
|
|
51
|
+
# The sync job locks the IssueLink and re-checks CommentLink existence, so
|
|
52
|
+
# this can't duplicate a mirror the create path is still building.
|
|
53
|
+
CollavreLinear::OutboundCommentSyncJob.perform_later(id) if linear_syncable_comment?
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
unless linear_syncable_comment?
|
|
58
|
+
CollavreLinear::OutboundCommentDeleteJob.perform_later(comment_link.id)
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return unless saved_change_to_content?
|
|
63
|
+
|
|
64
|
+
CollavreLinear::OutboundCommentUpdateJob.perform_later(id)
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
log_enqueue_failure("update", e)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Propagate a local deletion to Linear. The comment row is gone by the time
|
|
70
|
+
# this after-commit runs, so the CommentLink (not cascade-deleted — no FK on
|
|
71
|
+
# comment_id) is the only handle on the Linear comment id; the job consumes it.
|
|
72
|
+
def enqueue_linear_comment_delete
|
|
73
|
+
return if skip_linear_sync
|
|
74
|
+
|
|
75
|
+
comment_link = CollavreLinear::CommentLink.find_by(comment_id: id)
|
|
76
|
+
return unless comment_link
|
|
77
|
+
|
|
78
|
+
CollavreLinear::OutboundCommentDeleteJob.perform_later(comment_link.id)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
log_enqueue_failure("delete", e)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def log_enqueue_failure(kind, error)
|
|
84
|
+
# Never let sync scheduling break the host transaction's commit callbacks.
|
|
85
|
+
Rails.logger.error(
|
|
86
|
+
"[CollavreLinear::CommentSyncObserver] failed to enqueue comment #{kind} for " \
|
|
87
|
+
"comment #{id}: #{error.class}: #{error.message}"
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Delegates to the shared predicate so the observer and the outbound update
|
|
92
|
+
# job (which re-checks at run time) can never diverge on what is syncable.
|
|
93
|
+
def linear_syncable_comment?
|
|
94
|
+
CollavreLinear::CommentSyncability.syncable?(self)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreLinear
|
|
4
|
+
# Auto-triggers an outbound Linear sync whenever a Collavre::Creative inside a
|
|
5
|
+
# linked subtree is created, updated, moved, or destroyed.
|
|
6
|
+
#
|
|
7
|
+
# Mixed into Collavre::Creative via the engine's `to_prepare` block:
|
|
8
|
+
#
|
|
9
|
+
# Collavre::Creative.include CollavreLinear::CreativeSyncObserver
|
|
10
|
+
#
|
|
11
|
+
# which registers `after_commit` and adds the transient `skip_linear_sync`
|
|
12
|
+
# accessor.
|
|
13
|
+
#
|
|
14
|
+
# Loop guard: the inbound applier (Task 10) sets `creative.skip_linear_sync =
|
|
15
|
+
# true` on records it mutates so the resulting after_commit does NOT bounce a
|
|
16
|
+
# change back to Linear. The flag is a per-record transient attribute (not
|
|
17
|
+
# persisted), so it only suppresses the single in-memory instance that applied
|
|
18
|
+
# the inbound change.
|
|
19
|
+
module CreativeSyncObserver
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
# Transient, per-record suppression flag used by the inbound applier.
|
|
24
|
+
attr_accessor :skip_linear_sync
|
|
25
|
+
|
|
26
|
+
# Transient storage for archive data captured before the row is gone.
|
|
27
|
+
attr_accessor :_linear_archive_issue_id, :_linear_archive_account_id
|
|
28
|
+
|
|
29
|
+
# Transient flag: did the just-committed save touch a Linear-relevant
|
|
30
|
+
# column? Captured in after_save (where saved_changes is reliable) so the
|
|
31
|
+
# after_commit hook can short-circuit WITHOUT a DB query. saved_changes is
|
|
32
|
+
# cleared by the time after_commit runs (esp. under transactional tests),
|
|
33
|
+
# so it cannot be read there directly.
|
|
34
|
+
attr_accessor :_linear_relevant_change
|
|
35
|
+
|
|
36
|
+
# Transient flag: did this save re-parent the creative (parent_id change)?
|
|
37
|
+
# A move carries its whole subtree, but the core move hook touches
|
|
38
|
+
# descendants via `update_all` (no callbacks fire on them), so their sync
|
|
39
|
+
# must be fanned out from the moved root. Same capture-in-after_save reason
|
|
40
|
+
# as above.
|
|
41
|
+
attr_accessor :_linear_parent_changed
|
|
42
|
+
|
|
43
|
+
# prepend: true ensures we run before the dependent: :destroy cascade that
|
|
44
|
+
# deletes IssueLinks — which is added by a separate engine initializer.
|
|
45
|
+
before_destroy :capture_linear_archive_info, prepend: true
|
|
46
|
+
after_save :capture_linear_relevant_change
|
|
47
|
+
after_commit :enqueue_linear_outbound_sync
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Capture linear_issue_id and account_id before the creative (and its
|
|
53
|
+
# dependent IssueLink) are deleted from the DB. Stored in transient attrs
|
|
54
|
+
# so the after_commit destroy branch can read them without hitting a gone row.
|
|
55
|
+
def capture_linear_archive_info
|
|
56
|
+
issue_link = linear_issue_links.first
|
|
57
|
+
return unless issue_link
|
|
58
|
+
|
|
59
|
+
self._linear_archive_issue_id = issue_link.linear_issue_id
|
|
60
|
+
self._linear_archive_account_id = issue_link.project_link.account_id
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Rails.logger.error(
|
|
63
|
+
"[CollavreLinear::CreativeSyncObserver] before_destroy capture failed for " \
|
|
64
|
+
"creative #{id}: #{e.class}: #{e.message}"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Columns whose change can alter the exported Linear issue (mirrors what
|
|
69
|
+
# CreativeExporter hashes: description -> title/description, sequence ->
|
|
70
|
+
# priority, data -> state/labels) plus parent_id (re-parenting -> parentId).
|
|
71
|
+
LINEAR_RELEVANT_COLUMNS = %w[description sequence data parent_id].freeze
|
|
72
|
+
|
|
73
|
+
# Record — in after_save, where saved_changes is reliable — whether this
|
|
74
|
+
# save touched a Linear-relevant column. A create always counts (its seeded
|
|
75
|
+
# attributes populate the whole row).
|
|
76
|
+
def capture_linear_relevant_change
|
|
77
|
+
self._linear_relevant_change =
|
|
78
|
+
if previously_new_record?
|
|
79
|
+
true
|
|
80
|
+
else
|
|
81
|
+
(saved_changes.keys & LINEAR_RELEVANT_COLUMNS).any?
|
|
82
|
+
end
|
|
83
|
+
self._linear_parent_changed = saved_change_to_parent_id?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def enqueue_linear_outbound_sync
|
|
87
|
+
return if skip_linear_sync
|
|
88
|
+
|
|
89
|
+
# Destroys are driven ENTIRELY by what before_destroy captured. By the time
|
|
90
|
+
# this after_commit runs, the ProjectLink/IssueLink rows are already gone
|
|
91
|
+
# via dependent: :destroy, so `linked_subtree?` is unreliable here — it
|
|
92
|
+
# returns false for the link OWNER itself, which would otherwise swallow
|
|
93
|
+
# the captured archive id and leak a live Linear issue. Trust the captured
|
|
94
|
+
# ids; enqueue_archive_if_captured no-ops when nothing was captured (an
|
|
95
|
+
# unlinked creative), so this is safe for every destroy.
|
|
96
|
+
if destroyed?
|
|
97
|
+
enqueue_archive_if_captured
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Cheap in-memory short-circuit BEFORE any DB work: `after_commit` fires for
|
|
102
|
+
# every Creative write app-wide. An update/create with no Linear-relevant
|
|
103
|
+
# column change can't affect the exported issue, so skip the subtree query.
|
|
104
|
+
return if relevant_change_absent?
|
|
105
|
+
return unless linked_subtree?
|
|
106
|
+
|
|
107
|
+
mark_issue_link_dirty
|
|
108
|
+
|
|
109
|
+
# A re-parent moves this creative's whole subtree into the linked root, but
|
|
110
|
+
# the core move hook only `update_all`-touches descendants (no callbacks),
|
|
111
|
+
# so they never enqueue themselves. Fan out to each so pre-existing children
|
|
112
|
+
# reach Linear too. Per-creative jobs self-order via the exporter's
|
|
113
|
+
# ParentNotExportedError retry, so enqueue order doesn't matter. Non-move
|
|
114
|
+
# changes (description/data/sequence) only affect this one creative.
|
|
115
|
+
target_ids = _linear_parent_changed ? self_and_descendants.ids : [ id ]
|
|
116
|
+
target_ids.each { |cid| CollavreLinear::OutboundSyncJob.perform_later(cid) }
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
# Never let a sync-scheduling failure break the host transaction's
|
|
119
|
+
# commit callbacks.
|
|
120
|
+
Rails.logger.error(
|
|
121
|
+
"[CollavreLinear::CreativeSyncObserver] failed to enqueue sync for " \
|
|
122
|
+
"creative #{id}: #{e.class}: #{e.message}"
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# True when this commit touched no Linear-relevant column. Only reached for
|
|
127
|
+
# non-destroy commits (destroys return early via the archive path). Reads
|
|
128
|
+
# the flag captured in after_save; a nil flag (no save ran, e.g. a bare
|
|
129
|
+
# touch) is treated as "no relevant change".
|
|
130
|
+
def relevant_change_absent?
|
|
131
|
+
!_linear_relevant_change
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def enqueue_archive_if_captured
|
|
135
|
+
return unless _linear_archive_issue_id && _linear_archive_account_id
|
|
136
|
+
|
|
137
|
+
CollavreLinear::OutboundArchiveJob.perform_later(
|
|
138
|
+
_linear_archive_issue_id,
|
|
139
|
+
_linear_archive_account_id
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Mark the IssueLink dirty so the exporter knows a sync is pending.
|
|
144
|
+
# Only applicable when a link already exists; new creatives have no link yet
|
|
145
|
+
# (linear_issue_id is NOT NULL so a link cannot be created before the API call).
|
|
146
|
+
def mark_issue_link_dirty
|
|
147
|
+
issue_link = linear_issue_links.first
|
|
148
|
+
issue_link&.update_column(:sync_state, CollavreLinear::IssueLink.sync_states[:dirty])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# True when this creative (or any ancestor) carries a ProjectLink, i.e. it
|
|
152
|
+
# lives inside a subtree that is linked to a Linear project. Only called for
|
|
153
|
+
# non-destroy commits — the row is still present, so `self_and_ancestors` is
|
|
154
|
+
# queryable.
|
|
155
|
+
def linked_subtree?
|
|
156
|
+
ancestor_ids = self_and_ancestors.pluck(:id)
|
|
157
|
+
return false if ancestor_ids.empty?
|
|
158
|
+
|
|
159
|
+
CollavreLinear::ProjectLink.where(creative_id: ancestor_ids).exists?
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module CollavreLinear
|
|
8
|
+
class Client
|
|
9
|
+
# Raised when the GraphQL response includes an `errors` array.
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
DEFAULT_ENDPOINT = "https://api.linear.app/graphql"
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# GraphQL operation strings
|
|
16
|
+
# NOTE: All *Input field names below must be verified against the Live Linear
|
|
17
|
+
# Apollo schema before production use. Field names are conventional based on
|
|
18
|
+
# the public Linear GraphQL documentation but have NOT been validated against
|
|
19
|
+
# the live schema in this implementation.
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
# IssueCreateInput fields used: teamId, title, description, parentId,
|
|
23
|
+
# projectId, stateId, assigneeId, labelIds, priority
|
|
24
|
+
ISSUE_CREATE = <<~GQL.freeze
|
|
25
|
+
mutation IssueCreate($input: IssueCreateInput!) {
|
|
26
|
+
issueCreate(input: $input) {
|
|
27
|
+
success
|
|
28
|
+
issue {
|
|
29
|
+
id
|
|
30
|
+
identifier
|
|
31
|
+
updatedAt
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
GQL
|
|
36
|
+
|
|
37
|
+
# IssueUpdateInput fields used: title, description, parentId, projectId,
|
|
38
|
+
# stateId, assigneeId, labelIds, priority (any subset passed as **fields)
|
|
39
|
+
ISSUE_UPDATE = <<~GQL.freeze
|
|
40
|
+
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
|
|
41
|
+
issueUpdate(id: $id, input: $input) {
|
|
42
|
+
success
|
|
43
|
+
issue {
|
|
44
|
+
id
|
|
45
|
+
identifier
|
|
46
|
+
updatedAt
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
GQL
|
|
51
|
+
|
|
52
|
+
# ProjectCreateInput fields used: name, teamIds
|
|
53
|
+
PROJECT_CREATE = <<~GQL.freeze
|
|
54
|
+
mutation ProjectCreate($input: ProjectCreateInput!) {
|
|
55
|
+
projectCreate(input: $input) {
|
|
56
|
+
success
|
|
57
|
+
project {
|
|
58
|
+
id
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
GQL
|
|
63
|
+
|
|
64
|
+
# ProjectUpdateInput fields used: name (any subset passed as **fields)
|
|
65
|
+
PROJECT_UPDATE = <<~GQL.freeze
|
|
66
|
+
mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {
|
|
67
|
+
projectUpdate(id: $id, input: $input) {
|
|
68
|
+
success
|
|
69
|
+
project {
|
|
70
|
+
id
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
GQL
|
|
75
|
+
|
|
76
|
+
# CommentCreateInput fields used: issueId, body
|
|
77
|
+
COMMENT_CREATE = <<~GQL.freeze
|
|
78
|
+
mutation CommentCreate($input: CommentCreateInput!) {
|
|
79
|
+
commentCreate(input: $input) {
|
|
80
|
+
success
|
|
81
|
+
comment {
|
|
82
|
+
id
|
|
83
|
+
updatedAt
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
GQL
|
|
88
|
+
|
|
89
|
+
# CommentUpdateInput fields used: body
|
|
90
|
+
COMMENT_UPDATE = <<~GQL.freeze
|
|
91
|
+
mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) {
|
|
92
|
+
commentUpdate(id: $id, input: $input) {
|
|
93
|
+
success
|
|
94
|
+
comment {
|
|
95
|
+
id
|
|
96
|
+
updatedAt
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
GQL
|
|
101
|
+
|
|
102
|
+
COMMENT_DELETE = <<~GQL.freeze
|
|
103
|
+
mutation CommentDelete($id: String!) {
|
|
104
|
+
commentDelete(id: $id) {
|
|
105
|
+
success
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
GQL
|
|
109
|
+
|
|
110
|
+
# WebhookCreateInput fields used: url, secret, teamId, resourceTypes
|
|
111
|
+
WEBHOOK_CREATE = <<~GQL.freeze
|
|
112
|
+
mutation WebhookCreate($input: WebhookCreateInput!) {
|
|
113
|
+
webhookCreate(input: $input) {
|
|
114
|
+
success
|
|
115
|
+
webhook {
|
|
116
|
+
id
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
GQL
|
|
121
|
+
|
|
122
|
+
# Archive (soft-delete) a Linear issue by id.
|
|
123
|
+
ISSUE_ARCHIVE = <<~GQL.freeze
|
|
124
|
+
mutation IssueArchive($id: String!) {
|
|
125
|
+
issueArchive(id: $id) {
|
|
126
|
+
success
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
GQL
|
|
130
|
+
|
|
131
|
+
# Delete (deregister) a Linear webhook by id.
|
|
132
|
+
WEBHOOK_DELETE = <<~GQL.freeze
|
|
133
|
+
mutation WebhookDelete($id: String!) {
|
|
134
|
+
webhookDelete(id: $id) {
|
|
135
|
+
success
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
GQL
|
|
139
|
+
|
|
140
|
+
# Delete (deregister) a webhook from Linear.
|
|
141
|
+
# @param id [String] Linear webhook UUID
|
|
142
|
+
# @return [Boolean] success
|
|
143
|
+
def delete_webhook(id)
|
|
144
|
+
data = post!(WEBHOOK_DELETE, { id: id })
|
|
145
|
+
data.dig("webhookDelete", "success") == true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Viewer identity only. Linear's schema exposes no Query field for the OAuth
|
|
149
|
+
# app actor id of the current token (`applicationWithAuthorization { appActor }`
|
|
150
|
+
# is not valid — that field is absent and its type carries no appActor), so
|
|
151
|
+
# app_actor_id stays nil and EchoGuard no-ops. Loops are already prevented
|
|
152
|
+
# independently by InboundApplier (skip_linear_sync on inbound writes,
|
|
153
|
+
# IssueLink dedup on create, content_hash on update).
|
|
154
|
+
VIEWER = <<~GQL.freeze
|
|
155
|
+
query Viewer {
|
|
156
|
+
viewer {
|
|
157
|
+
id
|
|
158
|
+
organization {
|
|
159
|
+
id
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
GQL
|
|
164
|
+
|
|
165
|
+
# List the workspace teams the token can see (for the link-a-project picker).
|
|
166
|
+
TEAMS = <<~GQL.freeze
|
|
167
|
+
query Teams {
|
|
168
|
+
teams(first: 250) {
|
|
169
|
+
nodes {
|
|
170
|
+
id
|
|
171
|
+
name
|
|
172
|
+
key
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
GQL
|
|
177
|
+
|
|
178
|
+
# List projects with their owning team ids so the picker can scope projects
|
|
179
|
+
# to the chosen team (a Linear project may belong to more than one team).
|
|
180
|
+
PROJECTS = <<~GQL.freeze
|
|
181
|
+
query Projects {
|
|
182
|
+
projects(first: 250) {
|
|
183
|
+
nodes {
|
|
184
|
+
id
|
|
185
|
+
name
|
|
186
|
+
teams {
|
|
187
|
+
nodes { id }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
GQL
|
|
193
|
+
|
|
194
|
+
def initialize(account)
|
|
195
|
+
@account = account
|
|
196
|
+
@endpoint = resolve_endpoint
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Create a Linear issue.
|
|
200
|
+
# @return [Hash] with :id and :identifier
|
|
201
|
+
def create_issue(team_id:, title:, description: nil, parent_id: nil,
|
|
202
|
+
project_id: nil, state_id: nil, assignee_id: nil,
|
|
203
|
+
label_ids: [], priority: nil)
|
|
204
|
+
input = { teamId: team_id, title: title }.tap do |h|
|
|
205
|
+
h[:description] = description if description
|
|
206
|
+
h[:parentId] = parent_id if parent_id
|
|
207
|
+
h[:projectId] = project_id if project_id
|
|
208
|
+
h[:stateId] = state_id if state_id
|
|
209
|
+
h[:assigneeId] = assignee_id if assignee_id
|
|
210
|
+
h[:labelIds] = label_ids if label_ids.any?
|
|
211
|
+
h[:priority] = priority if priority
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
data = post!(ISSUE_CREATE, { input: input })
|
|
215
|
+
node = data.dig("issueCreate", "issue")
|
|
216
|
+
symbolize(node)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Update a Linear issue by id.
|
|
220
|
+
# @param id [String] Linear issue UUID
|
|
221
|
+
# @param fields [Hash] keyword args mapping to IssueUpdateInput fields
|
|
222
|
+
# @return [Hash] with :id and :identifier
|
|
223
|
+
def update_issue(id, **fields)
|
|
224
|
+
input = camelize_keys(fields)
|
|
225
|
+
data = post!(ISSUE_UPDATE, { id: id, input: input })
|
|
226
|
+
node = data.dig("issueUpdate", "issue")
|
|
227
|
+
symbolize(node)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Create a Linear project.
|
|
231
|
+
# @return [Hash] with :id
|
|
232
|
+
def create_project(name:, team_ids:)
|
|
233
|
+
input = { name: name, teamIds: team_ids }
|
|
234
|
+
data = post!(PROJECT_CREATE, { input: input })
|
|
235
|
+
node = data.dig("projectCreate", "project")
|
|
236
|
+
symbolize(node)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Update a Linear project by id.
|
|
240
|
+
# @param id [String] Linear project UUID
|
|
241
|
+
# @param fields [Hash] keyword args mapping to ProjectUpdateInput fields
|
|
242
|
+
# @return [Hash] with :id
|
|
243
|
+
def update_project(id, **fields)
|
|
244
|
+
input = camelize_keys(fields)
|
|
245
|
+
data = post!(PROJECT_UPDATE, { id: id, input: input })
|
|
246
|
+
node = data.dig("projectUpdate", "project")
|
|
247
|
+
symbolize(node)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Archive a Linear issue (soft-delete).
|
|
251
|
+
# @param id [String] Linear issue UUID
|
|
252
|
+
# @return [Boolean] success
|
|
253
|
+
def archive_issue(id)
|
|
254
|
+
data = post!(ISSUE_ARCHIVE, { id: id })
|
|
255
|
+
data.dig("issueArchive", "success") == true
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Create a comment on a Linear issue.
|
|
259
|
+
# @return [Hash] with :id
|
|
260
|
+
def create_comment(issue_id:, body:)
|
|
261
|
+
input = { issueId: issue_id, body: body }
|
|
262
|
+
data = post!(COMMENT_CREATE, { input: input })
|
|
263
|
+
node = data.dig("commentCreate", "comment")
|
|
264
|
+
symbolize(node)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Edit an existing Linear comment's body.
|
|
268
|
+
# @return [Hash] with :id
|
|
269
|
+
def update_comment(id:, body:)
|
|
270
|
+
data = post!(COMMENT_UPDATE, { id: id, input: { body: body } })
|
|
271
|
+
node = data.dig("commentUpdate", "comment")
|
|
272
|
+
symbolize(node)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Delete a Linear comment.
|
|
276
|
+
# @return [Boolean] success
|
|
277
|
+
def delete_comment(id)
|
|
278
|
+
data = post!(COMMENT_DELETE, { id: id })
|
|
279
|
+
data.dig("commentDelete", "success") == true
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Fetch the authenticated viewer identity.
|
|
283
|
+
# app_actor_id is intentionally nil (no Linear query exposes it for the
|
|
284
|
+
# current token); EchoGuard degrades to a no-op — see VIEWER above.
|
|
285
|
+
# @return [Hash] with :user_id, :app_actor_id, :organization_id
|
|
286
|
+
def viewer_and_app_actor
|
|
287
|
+
data = post!(VIEWER, {})
|
|
288
|
+
{
|
|
289
|
+
user_id: data.dig("viewer", "id"),
|
|
290
|
+
organization_id: data.dig("viewer", "organization", "id"),
|
|
291
|
+
app_actor_id: nil
|
|
292
|
+
}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# List workspace teams for the link picker.
|
|
296
|
+
# @return [Array<Hash>] each with :id, :name, :key
|
|
297
|
+
def list_teams
|
|
298
|
+
data = post!(TEAMS, {})
|
|
299
|
+
nodes = data.dig("teams", "nodes") || []
|
|
300
|
+
nodes.map { |n| symbolize(n) }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# List projects for the link picker, each with the ids of its owning teams
|
|
304
|
+
# so the UI can scope projects to the selected team.
|
|
305
|
+
# @return [Array<Hash>] each with :id, :name, :team_ids
|
|
306
|
+
def list_projects
|
|
307
|
+
data = post!(PROJECTS, {})
|
|
308
|
+
nodes = data.dig("projects", "nodes") || []
|
|
309
|
+
nodes.map do |n|
|
|
310
|
+
{
|
|
311
|
+
id: n["id"],
|
|
312
|
+
name: n["name"],
|
|
313
|
+
team_ids: (n.dig("teams", "nodes") || []).map { |t| t["id"] }
|
|
314
|
+
}
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Register a webhook with Linear.
|
|
319
|
+
# WebhookCreateInput fields: url, secret, teamId, resourceTypes
|
|
320
|
+
# @return [Hash] with :id
|
|
321
|
+
def register_webhook(url:, secret:, team_id:, resource_types:)
|
|
322
|
+
input = { url: url, secret: secret, teamId: team_id, resourceTypes: resource_types }
|
|
323
|
+
data = post!(WEBHOOK_CREATE, { input: input })
|
|
324
|
+
node = data.dig("webhookCreate", "webhook")
|
|
325
|
+
symbolize(node)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
attr_reader :account, :endpoint
|
|
331
|
+
|
|
332
|
+
# Execute a GraphQL operation and return the `data` hash.
|
|
333
|
+
# Raises Client::Error if the response contains a top-level `errors` key.
|
|
334
|
+
def post!(query, variables)
|
|
335
|
+
uri = URI.parse(endpoint)
|
|
336
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
337
|
+
http.use_ssl = uri.scheme == "https"
|
|
338
|
+
http.open_timeout = 10
|
|
339
|
+
http.read_timeout = 30
|
|
340
|
+
|
|
341
|
+
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
|
342
|
+
request["Content-Type"] = "application/json"
|
|
343
|
+
request["Authorization"] = "Bearer #{fresh_access_token}"
|
|
344
|
+
request.body = { query: query, variables: variables }.to_json
|
|
345
|
+
|
|
346
|
+
response =
|
|
347
|
+
begin
|
|
348
|
+
http.request(request)
|
|
349
|
+
rescue SocketError, SystemCallError, Timeout::Error, IOError,
|
|
350
|
+
OpenSSL::SSL::SSLError => e
|
|
351
|
+
# Transport-layer failures (connection refused/reset, DNS, TLS,
|
|
352
|
+
# open/read timeouts) raise before any GraphQL response exists, so they
|
|
353
|
+
# bypass the parsed-`errors` path below. Wrap them in Error so the
|
|
354
|
+
# outbound jobs' `retry_on Client::Error` treats a transient
|
|
355
|
+
# Linear/network outage as retryable instead of dropping the pending
|
|
356
|
+
# sync/comment/archive on the first failure.
|
|
357
|
+
raise Error, "Linear transport error: #{e.class}: #{e.message}"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
parsed = begin
|
|
361
|
+
JSON.parse(response.body)
|
|
362
|
+
rescue JSON::ParserError
|
|
363
|
+
raise Error, "Linear returned non-JSON response (HTTP #{response.code}): #{response.body.to_s[0, 200]}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
if parsed["errors"].present?
|
|
367
|
+
messages = parsed["errors"].map { |e| e["message"] }.join("; ")
|
|
368
|
+
raise Error, "Linear GraphQL error(s): #{messages}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
372
|
+
raise Error, "Linear HTTP error: #{response.code} #{response.message}"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
parsed["data"]
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Return a non-expired access token, refreshing via the refresh_token grant
|
|
379
|
+
# when the current token is expiring soon.
|
|
380
|
+
#
|
|
381
|
+
# Guards:
|
|
382
|
+
# - no refresh_token present → use current token (nothing to refresh with)
|
|
383
|
+
# - nil token_expires_at → long-lived token, never triggers a refresh
|
|
384
|
+
# (Account#token_expiring_soon? returns false for a nil expiry)
|
|
385
|
+
def fresh_access_token
|
|
386
|
+
if account.refresh_token.present? && account.token_expiring_soon?
|
|
387
|
+
begin
|
|
388
|
+
CollavreLinear::OAuthTokenService.refresh(account)
|
|
389
|
+
rescue CollavreLinear::OAuthTokenService::Error,
|
|
390
|
+
SocketError, SystemCallError, Timeout::Error, IOError,
|
|
391
|
+
OpenSSL::SSL::SSLError => e
|
|
392
|
+
# The refresh runs before the GraphQL request, so a transient failure
|
|
393
|
+
# here (network/TLS/timeout, or a temporary non-2xx from Linear's token
|
|
394
|
+
# endpoint) raises outside post!'s transport wrap and would bypass the
|
|
395
|
+
# outbound jobs' retry_on Client::Error, dropping the pending sync. Map
|
|
396
|
+
# it to Error so a transient outage is retried, not lost.
|
|
397
|
+
raise Error, "Linear token refresh failed: #{e.class}: #{e.message}"
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
account.access_token
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def resolve_endpoint
|
|
404
|
+
if defined?(Collavre::IntegrationSettings::Resolver)
|
|
405
|
+
Collavre::IntegrationSettings::Resolver.get(:linear_api_endpoint).presence
|
|
406
|
+
end || DEFAULT_ENDPOINT
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Convert symbol keys to camelCase strings for GraphQL variables.
|
|
410
|
+
# e.g. :team_id => "teamId"
|
|
411
|
+
def camelize_keys(hash)
|
|
412
|
+
hash.transform_keys { |k| camelize(k.to_s) }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def camelize(str)
|
|
416
|
+
parts = str.split("_")
|
|
417
|
+
parts[0] + parts[1..].map(&:capitalize).join
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def symbolize(hash)
|
|
421
|
+
return {} if hash.nil?
|
|
422
|
+
hash.transform_keys(&:to_sym)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|