gitlab-triage-linear-migrator 1.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +12 -0
- data/lib/gitlab/triage/linear/migrator/graphql_client.rb +111 -0
- data/lib/gitlab/triage/linear/migrator/issue_extension.rb +126 -0
- data/lib/gitlab/triage/linear/migrator/linear_connector.rb +299 -0
- data/lib/gitlab/triage/linear/migrator/linear_interface.rb +222 -0
- data/lib/gitlab/triage/linear/migrator/query_logger.rb +37 -0
- data/lib/gitlab/triage/linear/migrator/version.rb +11 -0
- data/lib/gitlab/triage/linear/migrator.rb +13 -0
- data/sig/gitlab/triage/linear/migrator.rbs +10 -0
- data/support/.gitlab-ci.example.yml +21 -0
- data/support/triage_plugins.example.rb +4 -0
- data/support/triage_policies.example.yml +31 -0
- metadata +106 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b631398c5c849d4879f5c4e299be58e02361a2a8fe73d3e145a66ffce0d4d58f
|
4
|
+
data.tar.gz: 1062431dbd7b9fcb994d94caa48002ecd616fe45b24f362cad9eba1c90496124
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bfcf03533f44225e64e98c291a25102f8bba22d32906aed4440de705e9d321d745d458d24b0aac2b438c987d25428c7158af08e3ac67b7daa65bf86f9bb24359
|
7
|
+
data.tar.gz: 27607a1270f7500c1c70bcd73180e7f65da5a918b1a8b56ff81c4d230e9f8495d5daf86bd5fb0e55b2322581a2b7ecd9749d7d8811457c4d2b3c24cd8501ca8d
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require: rubocop-rspec
|
2
|
+
|
3
|
+
AllCops:
|
4
|
+
TargetRubyVersion: 3.1
|
5
|
+
NewCops: enable
|
6
|
+
|
7
|
+
Style/StringLiterals:
|
8
|
+
EnforcedStyle: double_quotes
|
9
|
+
|
10
|
+
Style/StringLiteralsInInterpolation:
|
11
|
+
EnforcedStyle: double_quotes
|
12
|
+
|
13
|
+
RSpec/MultipleMemoizedHelpers:
|
14
|
+
Max: 10
|
15
|
+
|
16
|
+
RSpec/NestedGroups:
|
17
|
+
Max: 6
|
18
|
+
|
19
|
+
RSpec/ExampleLength:
|
20
|
+
Max: 10
|
21
|
+
|
22
|
+
Metrics/MethodLength:
|
23
|
+
Max: 60
|
24
|
+
|
25
|
+
Layout/LineLength:
|
26
|
+
Max: 200
|
27
|
+
|
28
|
+
MultipleMemoizedHelpers:
|
29
|
+
Max: 20
|
30
|
+
|
31
|
+
Metrics/ClassLength:
|
32
|
+
Max: 300
|
33
|
+
|
34
|
+
# SubjectStub needed to be turned off because we have to stub `build_url` method to get the
|
35
|
+
# API calls working. It is not a real concern here as we don't stub a method of the module
|
36
|
+
# under test, but the method of the class we extend with the module.
|
37
|
+
RSpec/SubjectStub:
|
38
|
+
Enabled: false
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Platform.sh
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,158 @@
|
|
1
|
+
<p style="text-align: center">
|
2
|
+
<a href="https://www.upsun.com/">
|
3
|
+
<img src="https://github.com/upsun/.github/blob/main/profile/logo.svg?raw=true" width="500px" alt="Upsun logo">
|
4
|
+
</a>
|
5
|
+
<br />
|
6
|
+
<br />
|
7
|
+
<a href="https://devcenter.upsun.com"><strong>Developer Center</strong></a>      
|
8
|
+
<a href="https://upsun.com/"><strong>Website</strong></a>      
|
9
|
+
<a href="https://docs.upsun.com"><strong>Documentation</strong></a>      
|
10
|
+
<a href="https://upsun.com/blog/"><strong>Blog</strong></a>      
|
11
|
+
<br /><br />
|
12
|
+
</p>
|
13
|
+
|
14
|
+
# Gitlab::Triage::Linear::Migrator
|
15
|
+
|
16
|
+
Extends Gitlab Triage with an action that migrates issues and epics to Linear (https://linear.app).
|
17
|
+
|
18
|
+
Initial version was developed by Platform.sh (https://platform.sh).
|
19
|
+
|
20
|
+
## Installation
|
21
|
+
|
22
|
+
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after
|
23
|
+
releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section
|
24
|
+
with instructions to install your gem from git if you don't plan to release to RubyGems.org.
|
25
|
+
|
26
|
+
Install the gem and add to the application's Gemfile by executing:
|
27
|
+
|
28
|
+
```bash
|
29
|
+
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
30
|
+
```
|
31
|
+
|
32
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
36
|
+
```
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
### Preparation
|
41
|
+
|
42
|
+
1. Create a policy file. You can use `support/triage_policies.example.yml` as a start/example. Read more about policy
|
43
|
+
files in the Triage Bot documentation: https://www.rubydoc.info/gems/gitlab-triage. This migrator provides an extra
|
44
|
+
`create_issue_in_linear()` action that can be used in a comment.
|
45
|
+
2. Extend Triage Bot with the Linear Migrator
|
46
|
+
1. If you already have some extensions and using the --require option, simply add the below lines to the file
|
47
|
+
2. If not, create a new file e.g. `triage_plugins.rb` and paste the code below (you can also copy the file
|
48
|
+
`support/triage_plugins.example.rb`)
|
49
|
+
3. Set the required environment variables. On top of the standard Triage Bot command line arguments, we need extra
|
50
|
+
options to
|
51
|
+
pass over to the Migrator. See the list below.
|
52
|
+
|
53
|
+
#### Example policy
|
54
|
+
|
55
|
+
```yml
|
56
|
+
resource_rules:
|
57
|
+
issues:
|
58
|
+
rules:
|
59
|
+
- name: Create issue in Linear
|
60
|
+
limits:
|
61
|
+
most_recent: 500
|
62
|
+
conditions:
|
63
|
+
labels:
|
64
|
+
- "Linear::To Migrate" # Remove this line if you want all issues to be migrated.
|
65
|
+
#state: opened # Remove this if you want closed issues to be migrated as well.
|
66
|
+
forbidden_labels:
|
67
|
+
- "Linear::Migrated"
|
68
|
+
- "Linear::Migration Failed"
|
69
|
+
ruby: "!labels.map(&:name).grep(/^Guild::.+$/).empty?" # Change this to match team_label_prefix parameter below!
|
70
|
+
actions:
|
71
|
+
comment: |
|
72
|
+
#{create_issue_in_linear(set_state: true, prepend_project_name: false, team_label_prefix: "Guild")}
|
73
|
+
|
74
|
+
```
|
75
|
+
|
76
|
+
Change the parameters as you wish:
|
77
|
+
- `set_state`: set to true if you want your imported issues reflect the current state (group label `S::` in GitLab).
|
78
|
+
Please note: issues in `S::Inbox` will be migrated to Triage if your team has Triage turned on in Linear.
|
79
|
+
If Triage is turned off, issues will have the default issue state, which is indicated (and can be set) on your
|
80
|
+
team's Workflow settings page in Linear.
|
81
|
+
- `prepend_project_name`: true means all imported issues will have their titles starting with the GitLab project name.
|
82
|
+
Set to false to simply copy the issue title as is.
|
83
|
+
- `team_label_prefix`: The migration requires a label in Gitlab that can be used to set the team name in Linear.
|
84
|
+
By default this label needs to be in this format `Team::Name of the team`. The team in Linear must be `Team: Name of the team`.
|
85
|
+
You can change the `Team:` prefix by setting this parameter to anything you want (e.g. Guild as in the example above).
|
86
|
+
|
87
|
+
This comment action will post a comment with a link to the new issue in Linear, add Linear::Migrated label and close the issue.
|
88
|
+
If the migration fails, it will add Linear::Migration Failed label and also some error debug information into the comment.
|
89
|
+
|
90
|
+
#### Migrating epics
|
91
|
+
|
92
|
+
If you want to migrate epics, you can add an Epics rule. Epics will be migrated as issues into Linear. Issues under epics
|
93
|
+
will be migrated as sub-issues below the issue.
|
94
|
+
|
95
|
+
```yml
|
96
|
+
resource_rules:
|
97
|
+
epics:
|
98
|
+
rules:
|
99
|
+
- name: Create epic in Linear
|
100
|
+
# ...
|
101
|
+
issues:
|
102
|
+
rules:
|
103
|
+
- name: Create issue in Linear
|
104
|
+
# ...
|
105
|
+
```
|
106
|
+
|
107
|
+
Note that epics in Gitlab only exist in the group level, so `--source` should be `group` when running the Triage Bot.
|
108
|
+
|
109
|
+
#### Extend the Triage Bot
|
110
|
+
|
111
|
+
```ruby
|
112
|
+
require "gitlab/triage/linear/migrator/issue_extension"
|
113
|
+
Gitlab::Triage::Resource::Context.include Gitlab::Triage::Linear::Migrator::IssueExtension
|
114
|
+
```
|
115
|
+
|
116
|
+
#### Environment variables
|
117
|
+
|
118
|
+
| Variable | Description | Example |
|
119
|
+
|----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|
|
120
|
+
| LINEAR_API_TOKEN | API token to access Linear. See https://developers.linear.app/docs/oauth/authentication | `Bearer lin_oauth_ssdjw23242349020492342` |
|
121
|
+
| IGNORE_LINEAR_DRYRUN | If this is set to anything, real Linear calls will be made when running Triage Bot with --dry-run. This is to test the migration on a test worspace without changing the opriginal issues in Gitlab. | `true` |
|
122
|
+
|
123
|
+
### Run from your local (testing)
|
124
|
+
|
125
|
+
1. Simply run Triage Bot from the command line.
|
126
|
+
|
127
|
+
Migrate a group:
|
128
|
+
` gitlab-triage --dry-run --token $GITLAB_API_TOKEN --source group --source-id gitlab-group --host-url https://your-gitlab-url.example.com --require ./triage-plugins.rb --policies-file 'triage-policies.yml'`
|
129
|
+
|
130
|
+
Migrate a project:
|
131
|
+
` gitlab-triage --dry-run --token $GITLAB_API_TOKEN --source project --source-id gitlab-group/project --host-url https://your-gitlab-url.example.com --require ./triage-plugins.rb --policies-file 'triage-policies.yml'`
|
132
|
+
|
133
|
+
Migrate an entire instance:
|
134
|
+
` gitlab-triage --dry-run --token $GITLAB_API_TOKEN --all --host-url https://your-gitlab-url.example.com --require ./triage-plugins.rb --policies-file 'triage-policies.yml'`
|
135
|
+
|
136
|
+
|
137
|
+
See https://gitlab.com/gitlab-org/ruby/gems/gitlab-triage/#running-with-the-installed-gem for more.
|
138
|
+
|
139
|
+
### Run from the CI (recommended)
|
140
|
+
|
141
|
+
See https://gitlab.com/gitlab-org/ruby/gems/gitlab-triage/#running-on-gitlab-ci-pipeline.
|
142
|
+
|
143
|
+
## Development
|
144
|
+
|
145
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can
|
146
|
+
also run `bin/console` for an interactive prompt that will allow you to experiment.
|
147
|
+
|
148
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
|
149
|
+
version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
|
150
|
+
push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
151
|
+
|
152
|
+
## Contributing
|
153
|
+
|
154
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/upsun/gitlab-triage-linear-migrator.
|
155
|
+
|
156
|
+
## License
|
157
|
+
|
158
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require "json"
|
5
|
+
require "uri"
|
6
|
+
require_relative "query_logger"
|
7
|
+
|
8
|
+
module Gitlab
|
9
|
+
module Triage
|
10
|
+
module Linear
|
11
|
+
module Migrator
|
12
|
+
# Client class for the GraphQL queries
|
13
|
+
class GraphqlClient
|
14
|
+
THROTTLE_RETRIES = 3
|
15
|
+
|
16
|
+
attr_reader :endpoint, :headers
|
17
|
+
attr_accessor :dry_run, :sleep_duration
|
18
|
+
|
19
|
+
include QueryLogger
|
20
|
+
|
21
|
+
def initialize(endpoint, headers = {}, dry_run: false)
|
22
|
+
@sleep_duration = 30
|
23
|
+
@endpoint = endpoint
|
24
|
+
@headers = headers
|
25
|
+
@dry_run = dry_run
|
26
|
+
|
27
|
+
@uri = URI.parse(@endpoint)
|
28
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
29
|
+
@http.use_ssl = @uri.scheme == "https"
|
30
|
+
@cache = {}
|
31
|
+
end
|
32
|
+
|
33
|
+
def query(query_string, variables = {})
|
34
|
+
log_query(query_string, variables)
|
35
|
+
cache_key = [query_string, variables].hash
|
36
|
+
return cached_result(cache_key) if cache_hit?(cache_key)
|
37
|
+
|
38
|
+
result = execute(query_string, variables)
|
39
|
+
cache_result(cache_key, result)
|
40
|
+
result
|
41
|
+
end
|
42
|
+
|
43
|
+
def mutation(mutation_string, variables = {})
|
44
|
+
if @dry_run
|
45
|
+
log_dry_run(mutation_string, variables)
|
46
|
+
return nil
|
47
|
+
end
|
48
|
+
|
49
|
+
log_query(mutation_string, variables)
|
50
|
+
execute(mutation_string, variables)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def cache_result(cache_key, result)
|
56
|
+
@cache[cache_key] = result
|
57
|
+
end
|
58
|
+
|
59
|
+
def cached_result(cache_key)
|
60
|
+
log_cache_hit(cache_key)
|
61
|
+
log_response(@cache[cache_key])
|
62
|
+
@cache[cache_key]
|
63
|
+
end
|
64
|
+
|
65
|
+
def cache_hit?(cache_key)
|
66
|
+
@cache.key?(cache_key)
|
67
|
+
end
|
68
|
+
|
69
|
+
def execute(graphql_string, variables)
|
70
|
+
retries = 0
|
71
|
+
|
72
|
+
while retries < THROTTLE_RETRIES
|
73
|
+
response = send_request(graphql_string, variables)
|
74
|
+
parsed_response = JSON.parse(response.body)
|
75
|
+
|
76
|
+
if should_retry?(response, parsed_response)
|
77
|
+
retries += 1
|
78
|
+
sleep(@sleep_duration)
|
79
|
+
elsif parsed_response["errors"]
|
80
|
+
errors_string = parsed_response["errors"].map { |error| error["message"] }.join(", ")
|
81
|
+
log_error(errors_string)
|
82
|
+
raise StandardError, "GraphQL Error: #{errors_string}"
|
83
|
+
else
|
84
|
+
return parsed_response
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
raise StandardError, "Rate limit reached after #{THROTTLE_RETRIES} retries"
|
89
|
+
end
|
90
|
+
|
91
|
+
def send_request(graphql_string, variables)
|
92
|
+
request = Net::HTTP::Post.new(@uri.request_uri, @headers)
|
93
|
+
request["Content-Type"] = "application/json"
|
94
|
+
request.body = { query: graphql_string, variables: }.to_json
|
95
|
+
|
96
|
+
response = @http.request(request)
|
97
|
+
log_response(response.body)
|
98
|
+
response
|
99
|
+
end
|
100
|
+
|
101
|
+
def should_retry?(response, parsed_response)
|
102
|
+
return false unless parsed_response.is_a?(Hash) && parsed_response["errors"].is_a?(Array)
|
103
|
+
|
104
|
+
response.code.to_i.between?(400, 499) &&
|
105
|
+
parsed_response["errors"].any? { |error| error.dig("extensions", "code") == "RATELIMITED" }
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "linear_connector"
|
4
|
+
|
5
|
+
module Gitlab
|
6
|
+
module Triage
|
7
|
+
module Linear
|
8
|
+
module Migrator
|
9
|
+
# Extending Gitlab::Triage::Resource::Issue with functions required for Linear migration.
|
10
|
+
module IssueExtension
|
11
|
+
def discussions
|
12
|
+
# Epic discussions GET endpoint weirdly has id instead of iid as param.
|
13
|
+
url = if self.class.name.demodulize.underscore.pluralize == "epics"
|
14
|
+
build_url(
|
15
|
+
options: {
|
16
|
+
params: { system: false },
|
17
|
+
resource_id: resource["id"],
|
18
|
+
sub_resource_type: "discussions"
|
19
|
+
}
|
20
|
+
)
|
21
|
+
else
|
22
|
+
resource_url(sub_resource_type: "discussions")
|
23
|
+
end
|
24
|
+
network.query_api_cached(url)
|
25
|
+
end
|
26
|
+
|
27
|
+
def human_discussions
|
28
|
+
discussions.reject do |discussion_item|
|
29
|
+
discussion_item["individual_note"] == true && discussion_item["notes"].first["system"] == true
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def extract_issue_id(text)
|
34
|
+
# Use a regular expression to match the issue ID pattern within the fixed text
|
35
|
+
issue_id_pattern = /Linear issue ID: ([a-f0-9-]{36})/
|
36
|
+
|
37
|
+
# Extract the issue ID using the pattern
|
38
|
+
match = text.match(issue_id_pattern)
|
39
|
+
|
40
|
+
# Return the issue ID if found, otherwise return nil
|
41
|
+
match ? match[1] : nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def find_linear_id_in_gitlab
|
45
|
+
return if resource[:epic].empty?
|
46
|
+
|
47
|
+
url = build_url(params: {},
|
48
|
+
options: { source: "groups", resource_type: "epics", resource_id: resource[:epic][:id],
|
49
|
+
source_id: resource[:epic][:group_id], sub_resource_type: "notes" })
|
50
|
+
comments = network.query_api_cached(url)
|
51
|
+
comments.each do |comment|
|
52
|
+
if (issue_id = process_comment(comment["body"]))
|
53
|
+
puts "Parent epic found: #{issue_id}"
|
54
|
+
return issue_id
|
55
|
+
end
|
56
|
+
end
|
57
|
+
nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def process_comment(comment_body)
|
61
|
+
return unless comment_body.start_with?("Issue created in Linear:")
|
62
|
+
|
63
|
+
extract_issue_id(comment_body)
|
64
|
+
end
|
65
|
+
|
66
|
+
# @todo: make these configurable
|
67
|
+
LABEL_MIGRATION_FAILED = '/label ~"Linear::Migration Failed"\n/remove_label ~"Linear::To Migrate"'
|
68
|
+
LABEL_MIGRATED = '/label ~"Linear::Migrated"\n/remove_label ~"Linear::Migration Failed" ~"Linear::To Migrate"'
|
69
|
+
CLOSE_ACTON = "/close"
|
70
|
+
|
71
|
+
def create_issue_in_linear(set_state: false, prepend_project_name: false, team_label_prefix: "Team")
|
72
|
+
connector = setup_linear_connector
|
73
|
+
connector.team_label_prefix = team_label_prefix
|
74
|
+
project_name = if prepend_project_name
|
75
|
+
fetch_project_name
|
76
|
+
end.to_s
|
77
|
+
|
78
|
+
begin
|
79
|
+
log_processing_issue
|
80
|
+
issue = connector.import_issue(self, set_state:, project_name:)
|
81
|
+
rescue StandardError => e
|
82
|
+
handle_error(e.message, project_name)
|
83
|
+
return
|
84
|
+
end
|
85
|
+
|
86
|
+
return unless issue
|
87
|
+
|
88
|
+
construct_output(issue)
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def log_processing_issue
|
94
|
+
puts Rainbow("Processing issue: #{resource["web_url"]}").yellow
|
95
|
+
end
|
96
|
+
|
97
|
+
def handle_error(message, project_name)
|
98
|
+
puts Rainbow(message).red
|
99
|
+
%(Issue migration failed for issue ##{resource["id"]} in project #{project_name} #{resource["web_url"]}
|
100
|
+
Error:
|
101
|
+
#{message[0..100]}
|
102
|
+
#{LABEL_MIGRATION_FAILED})
|
103
|
+
end
|
104
|
+
|
105
|
+
def construct_output(issue)
|
106
|
+
%(Issue created in Linear: #{issue["url"]}
|
107
|
+
Linear issue ID: #{issue["id"]}
|
108
|
+
#{LABEL_MIGRATED}
|
109
|
+
#{CLOSE_ACTON})
|
110
|
+
end
|
111
|
+
|
112
|
+
def setup_linear_connector
|
113
|
+
connector = LinearConnector.new
|
114
|
+
connector.gitlab_dry_run = network.options.dry_run
|
115
|
+
connector.linear_dry_run = ENV.fetch("IGNORE_LINEAR_DRYRUN", false) ? false : network.options.dry_run
|
116
|
+
connector
|
117
|
+
end
|
118
|
+
|
119
|
+
def fetch_project_name
|
120
|
+
network.query_api_cached(build_url(options: { resource_type: nil })).first["name"]
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,299 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "graphql_client"
|
4
|
+
require_relative "linear_interface"
|
5
|
+
|
6
|
+
module Gitlab
|
7
|
+
module Triage
|
8
|
+
module Linear
|
9
|
+
module Migrator
|
10
|
+
# Connects to Linear and creates stuff.
|
11
|
+
class LinearConnector
|
12
|
+
MIGRATION_LABEL_NAME = "Migrated from GitLab"
|
13
|
+
MIGRATION_IN_PROGRESS_LABEL_NAME = "Migrating from GitLab - in progress"
|
14
|
+
MIGRATION_LABEL_NAME_DRY_RUN = "Migrated from GitLab (DRY-RUN)"
|
15
|
+
MIGRATION_IN_PROGRESS_LABEL_NAME_DRY_RUN = "Migrating from GitLab - in progress (DRY-RUN)"
|
16
|
+
|
17
|
+
LINEAR_STATE_MAP =
|
18
|
+
{
|
19
|
+
"Inbox" => nil,
|
20
|
+
"InProgress" => "In Progress",
|
21
|
+
"OnHold" => "Blocked",
|
22
|
+
"PendingRelease" => "Pending Release",
|
23
|
+
"Planned" => "Planned",
|
24
|
+
"Review" => "In Review",
|
25
|
+
"Candidate" => "Candidate",
|
26
|
+
"Blocked" => "Blocked",
|
27
|
+
"Closed" => "Done",
|
28
|
+
"Testing" => "Testing",
|
29
|
+
"NeedsQA" => "Needs QA",
|
30
|
+
"DesignReview" => "Design Review",
|
31
|
+
"CodeReview" => "Code Review",
|
32
|
+
"ReadyForDev" => "Planned"
|
33
|
+
}.freeze
|
34
|
+
|
35
|
+
def initialize(gitlab_dry_run: false, linear_dry_run: false, client: nil, interface: nil, team_label_prefix: "Team")
|
36
|
+
@gitlab_dry_run = gitlab_dry_run
|
37
|
+
@linear_dry_run = linear_dry_run
|
38
|
+
@team_label_prefix = team_label_prefix
|
39
|
+
|
40
|
+
# @todo: Find a better solution to set the Linear API token.
|
41
|
+
@graphql_client = client || GraphqlClient.new("https://api.linear.app/graphql", {
|
42
|
+
"Authorization" => ENV.fetch("LINEAR_API_TOKEN", nil)
|
43
|
+
}, dry_run: @linear_dry_run)
|
44
|
+
@id_map = []
|
45
|
+
|
46
|
+
@query_count = 0
|
47
|
+
@mutation_count = 0
|
48
|
+
@query_count_by_function = []
|
49
|
+
|
50
|
+
@linear_interface = interface || LinearInterface.new(graphql_client: @graphql_client)
|
51
|
+
end
|
52
|
+
|
53
|
+
attr_accessor :gitlab_dry_run, :team_label_prefix
|
54
|
+
attr_reader :linear_dry_run
|
55
|
+
|
56
|
+
def linear_dry_run=(dry_run)
|
57
|
+
@linear_dry_run = dry_run
|
58
|
+
@graphql_client.dry_run = @linear_dry_run
|
59
|
+
end
|
60
|
+
|
61
|
+
def import_issue(gitlab_issue, set_state: false, project_name: nil)
|
62
|
+
linear_team_data = fetch_linear_team_data(gitlab_issue)
|
63
|
+
linear_label_ids = get_linear_label_ids(gitlab_issue.labels, linear_team_data["id"])
|
64
|
+
|
65
|
+
issue_data = prepare_issue_data(gitlab_issue, linear_label_ids, linear_team_data, project_name, set_state)
|
66
|
+
issue_created = @linear_interface.create_issue(issue_data)
|
67
|
+
|
68
|
+
return unless issue_created
|
69
|
+
|
70
|
+
if gitlab_issue.instance_of?(Gitlab::Triage::Resource::Issue)
|
71
|
+
import_mr_links(gitlab_issue,
|
72
|
+
issue_created["id"])
|
73
|
+
end
|
74
|
+
|
75
|
+
import_comments(gitlab_issue, gitlab_issue.discussions, issue_created["id"])
|
76
|
+
|
77
|
+
if issue_created
|
78
|
+
handle_post_creation_tasks(gitlab_issue, issue_created["id"], issue_data["parent_id"], linear_label_ids,
|
79
|
+
issue_created["url"])
|
80
|
+
end
|
81
|
+
|
82
|
+
issue_created
|
83
|
+
end
|
84
|
+
|
85
|
+
def import_comments(_gitlab_issue, discussions, linear_id)
|
86
|
+
discussions.each do |discussion|
|
87
|
+
process_discussion(discussion["notes"], linear_id) if valid_discussion?(discussion["notes"])
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def valid_discussion?(notes)
|
94
|
+
notes && !notes.empty? && notes.none? { |note| note["system"] }
|
95
|
+
end
|
96
|
+
|
97
|
+
def process_discussion(notes, linear_id)
|
98
|
+
return if notes.empty?
|
99
|
+
|
100
|
+
if notes.size > 1
|
101
|
+
process_threaded_comments(notes, linear_id)
|
102
|
+
else
|
103
|
+
create_comment_from_note(notes.first, linear_id)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def process_threaded_comments(notes, linear_id)
|
108
|
+
parent_note = notes.first
|
109
|
+
parent_comment = create_comment_from_note(parent_note, linear_id)
|
110
|
+
parent_external_id = parent_comment["id"]
|
111
|
+
|
112
|
+
notes[1..].each do |child_note|
|
113
|
+
create_comment_from_note(child_note, linear_id, parent_external_id)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def create_comment_from_note(note, linear_id, parent_id = nil)
|
118
|
+
@linear_interface.create_comment(
|
119
|
+
body: note["body"],
|
120
|
+
linear_issue_id: linear_id,
|
121
|
+
author_name: note["author"]["name"],
|
122
|
+
parent_id:,
|
123
|
+
created_at: note["created_at"]
|
124
|
+
)
|
125
|
+
end
|
126
|
+
|
127
|
+
def prepare_issue_data(gitlab_issue, linear_label_ids, linear_team_data, project_name, set_state)
|
128
|
+
{
|
129
|
+
title: format_issue_title(gitlab_issue, project_name),
|
130
|
+
description: sanitize_description(gitlab_issue.resource[:description]),
|
131
|
+
team_id: linear_team_data["id"],
|
132
|
+
create_as_user: gitlab_issue.resource[:author][:name],
|
133
|
+
assignee_id: determine_assignee_id(gitlab_issue),
|
134
|
+
state_id: determine_state_id(gitlab_issue, linear_team_data, set_state),
|
135
|
+
parent_id: determine_epic_id(gitlab_issue),
|
136
|
+
created_at: gitlab_issue.resource["created_at"],
|
137
|
+
label_ids: linear_label_ids,
|
138
|
+
due_date: gitlab_issue.resource["due_date"],
|
139
|
+
sort_order: gitlab_issue.resource["weight"]
|
140
|
+
}
|
141
|
+
end
|
142
|
+
|
143
|
+
def fetch_linear_team_data(gitlab_issue)
|
144
|
+
team_name = "#{@team_label_prefix}: #{get_team_label(gitlab_issue.labels)}"
|
145
|
+
linear_team_data = @linear_interface.get_team_by_name(team_name)
|
146
|
+
unless linear_team_data
|
147
|
+
raise StandardError,
|
148
|
+
"Couldn't create issue in Linear, because the team #{linear_team_data["name"]} doesn't exists in Linear."
|
149
|
+
end
|
150
|
+
|
151
|
+
linear_team_data
|
152
|
+
end
|
153
|
+
|
154
|
+
def format_issue_title(gitlab_issue, project_name)
|
155
|
+
project_name.to_s.empty? ? gitlab_issue.resource[:title].to_s : "#{project_name}: #{gitlab_issue.resource[:title]}"
|
156
|
+
end
|
157
|
+
|
158
|
+
def sanitize_description(description)
|
159
|
+
return nil if description.nil?
|
160
|
+
|
161
|
+
description.gsub(/<!--(.*?)-->/m, "").gsub("...", ". . . ")
|
162
|
+
end
|
163
|
+
|
164
|
+
def handle_post_creation_tasks(gitlab_issue, issue_id, parent_id, linear_label_ids, issue_url)
|
165
|
+
handle_missing_parent_issue(gitlab_issue, issue_id, parent_id)
|
166
|
+
@linear_interface.create_comment(
|
167
|
+
body: compile_migration_notes(gitlab_issue.resource["web_url"],
|
168
|
+
gitlab_issue.labels), linear_issue_id: issue_id, author_name: "Migration"
|
169
|
+
)
|
170
|
+
@linear_interface.create_url_link(issue_id, gitlab_issue.resource["web_url"],
|
171
|
+
"Original issue in GitLab: #{gitlab_issue.resource["title"]}")
|
172
|
+
write_to_migration_map(gitlab_issue, linear_id: issue_id, linear_url: issue_url)
|
173
|
+
update_linear_labels(issue_id, linear_label_ids)
|
174
|
+
end
|
175
|
+
|
176
|
+
def handle_missing_parent_issue(gitlab_issue, issue_id, parent_id)
|
177
|
+
return unless gitlab_issue.resource["epic"] && parent_id.nil?
|
178
|
+
|
179
|
+
epic_url = gitlab_issue.host_url + gitlab_issue.resource["epic"]["url"]
|
180
|
+
@linear_interface.create_comment(body: "The original issue in GitLab has an epic that was not migrated to Linear. Epic in GitLab: #{epic_url}",
|
181
|
+
linear_issue_id: issue_id, author_name: "Migration")
|
182
|
+
@linear_interface.create_url_link(issue_id, epic_url,
|
183
|
+
"Epic in GitLab: #{gitlab_issue.resource["epic"]["title"]}")
|
184
|
+
end
|
185
|
+
|
186
|
+
def update_linear_labels(issue_id, linear_label_ids)
|
187
|
+
if @gitlab_dry_run && !@linear_dry_run
|
188
|
+
linear_label_ids.delete(@linear_interface.find_label(MIGRATION_IN_PROGRESS_LABEL_NAME_DRY_RUN))
|
189
|
+
linear_label_ids.append(@linear_interface.find_label(MIGRATION_LABEL_NAME_DRY_RUN))
|
190
|
+
else
|
191
|
+
linear_label_ids.delete(@linear_interface.find_label(MIGRATION_IN_PROGRESS_LABEL_NAME))
|
192
|
+
linear_label_ids.append(@linear_interface.find_label(MIGRATION_LABEL_NAME))
|
193
|
+
end
|
194
|
+
@linear_interface.update_labels(issue_id, linear_label_ids)
|
195
|
+
end
|
196
|
+
|
197
|
+
def determine_assignee_id(gitlab_issue)
|
198
|
+
(return unless gitlab_issue.resource[:assignees]&.length&.positive?
|
199
|
+
|
200
|
+
@linear_interface.get_user_id_by_email(gitlab_issue.resource[:assignees].first[:email])
|
201
|
+
)
|
202
|
+
end
|
203
|
+
|
204
|
+
def determine_state_id(gitlab_issue, linear_team_data, set_state)
|
205
|
+
if gitlab_issue.state == "closed"
|
206
|
+
find_state_id_by_name(linear_team_data, "Closed")
|
207
|
+
else
|
208
|
+
(find_state_id_by_name(linear_team_data, get_s_label(gitlab_issue.labels)) if set_state)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def determine_epic_id(gitlab_issue)
|
213
|
+
epic_id_in_linear = nil
|
214
|
+
if gitlab_issue.resource["epic"]
|
215
|
+
epic_id = gitlab_issue.resource["epic"]["id"]
|
216
|
+
epic_id_in_linear = get_linear_id_from_migration_map(gitlab_type: "epic", gitlab_id: epic_id)
|
217
|
+
epic_id_in_linear = @linear_interface.find_linear_issue_by_gitlab_url("#{gitlab_issue.host_url}#{gitlab_issue.resource["epic"]["url"]}") if epic_id_in_linear.nil?
|
218
|
+
end
|
219
|
+
epic_id_in_linear
|
220
|
+
end
|
221
|
+
|
222
|
+
def import_mr_links(gitlab_issue, linear_id)
|
223
|
+
gitlab_issue.related_merge_requests.each do |mr|
|
224
|
+
@linear_interface.create_mr_link(linear_id, mr.resource["web_url"], mr.project_path, mr.resource["iid"],
|
225
|
+
mr.resource["title"])
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
def get_linear_label_ids(gitlab_labels, team_id)
|
230
|
+
# 1. transform the labels ito a flat name list
|
231
|
+
# E::Level Easy --> Level Easy
|
232
|
+
# T::Bug --> Bug
|
233
|
+
|
234
|
+
gitlab_label_list = gitlab_labels.map { |n| n.name.gsub(/.*::/, "") }
|
235
|
+
|
236
|
+
if @gitlab_dry_run && !@linear_dry_run
|
237
|
+
gitlab_label_list.append(MIGRATION_IN_PROGRESS_LABEL_NAME_DRY_RUN)
|
238
|
+
else
|
239
|
+
gitlab_label_list.append(MIGRATION_IN_PROGRESS_LABEL_NAME)
|
240
|
+
end
|
241
|
+
|
242
|
+
# 2. list the labels in Linear
|
243
|
+
|
244
|
+
linear_labels = @linear_interface.list_labels(gitlab_label_list, team_id)
|
245
|
+
|
246
|
+
linear_labels["data"]["issueLabels"]["nodes"].map { |n| n["id"] }
|
247
|
+
end
|
248
|
+
|
249
|
+
def get_team_label(labels)
|
250
|
+
labels.select { |label| label.name.start_with?("#{@team_label_prefix}::") }
|
251
|
+
.map { |label| label.name.split("::").last }
|
252
|
+
.first
|
253
|
+
end
|
254
|
+
|
255
|
+
def get_s_label(labels)
|
256
|
+
labels.select { |label| label.name.start_with?("S::") }
|
257
|
+
.map { |label| label.name.split("::").last }
|
258
|
+
.first
|
259
|
+
end
|
260
|
+
|
261
|
+
def compile_migration_notes(gitlab_url, labels)
|
262
|
+
%(This issue was copied from GitLab by Triage Bot. Original issue: #{gitlab_url}
|
263
|
+
|
264
|
+
Original labels: #{labels.map { |label| "'#{label.name}'" }.join(", ")}
|
265
|
+
)
|
266
|
+
end
|
267
|
+
|
268
|
+
def find_state_id_by_name(teams, search_name)
|
269
|
+
return nil if teams.empty?
|
270
|
+
|
271
|
+
states = teams["states"]["nodes"]
|
272
|
+
|
273
|
+
# Finding the state with the given name
|
274
|
+
state = states.find { |s| s["name"] == LINEAR_STATE_MAP[search_name] }
|
275
|
+
|
276
|
+
# Return the id if state is found, otherwise nil
|
277
|
+
state["id"] unless state.nil?
|
278
|
+
end
|
279
|
+
|
280
|
+
def write_to_migration_map(gitlab_issue, linear_id: nil, linear_url: "")
|
281
|
+
gitlab_type = gitlab_issue.class.name.demodulize.underscore
|
282
|
+
gitlab_id = gitlab_issue.resource["id"]
|
283
|
+
gitlab_url = gitlab_issue.resource["web_url"]
|
284
|
+
linear_type = "issue"
|
285
|
+
|
286
|
+
@id_map.push(gitlab_type, gitlab_id, linear_type, linear_id, gitlab_url, linear_url)
|
287
|
+
end
|
288
|
+
|
289
|
+
def get_linear_id_from_migration_map(gitlab_type: "issue", gitlab_id: nil, linear_type: "issue")
|
290
|
+
row = @id_map.find do |element|
|
291
|
+
element[0] == gitlab_type && element[1] == gitlab_id && element[2] == linear_type
|
292
|
+
end
|
293
|
+
row[3] if row
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "graphql_client"
|
4
|
+
|
5
|
+
module Gitlab
|
6
|
+
module Triage
|
7
|
+
module Linear
|
8
|
+
module Migrator
|
9
|
+
# An interface class that provides functions to get and send data from/to Linear
|
10
|
+
class LinearInterface
|
11
|
+
FIND_LABEL_QUERY = <<~GRAPHQL
|
12
|
+
query($label: String!) {
|
13
|
+
issueLabels(
|
14
|
+
filter: { name: { eq: $label } }
|
15
|
+
) {
|
16
|
+
nodes {
|
17
|
+
id
|
18
|
+
name
|
19
|
+
}
|
20
|
+
}
|
21
|
+
}
|
22
|
+
GRAPHQL
|
23
|
+
|
24
|
+
UPDATE_LABELS_MUTATION = <<~GRAPHQL
|
25
|
+
mutation($issueId: String!, $labels: [String!]!) {
|
26
|
+
issueUpdate(input: { labelIds: $labels }, id: $issueId) {
|
27
|
+
lastSyncId
|
28
|
+
success
|
29
|
+
}
|
30
|
+
}
|
31
|
+
GRAPHQL
|
32
|
+
|
33
|
+
LIST_LABELS_QUERY = <<~GRAPHQL
|
34
|
+
query($labels: [String!], $teamId: ID) {
|
35
|
+
issueLabels(
|
36
|
+
filter: {
|
37
|
+
name: { in: $labels }
|
38
|
+
or: [
|
39
|
+
{ team: { id: { eq: $teamId } } }
|
40
|
+
{ team: { null: true } }
|
41
|
+
]
|
42
|
+
}
|
43
|
+
) {
|
44
|
+
nodes {
|
45
|
+
id
|
46
|
+
name
|
47
|
+
}
|
48
|
+
}
|
49
|
+
}
|
50
|
+
GRAPHQL
|
51
|
+
|
52
|
+
GET_USER_ID_BY_EMAIL_QUERY = <<~GRAPHQL
|
53
|
+
query($email: String!) {
|
54
|
+
users(filter: { email: { eq: $email } }) {
|
55
|
+
nodes {
|
56
|
+
id
|
57
|
+
}
|
58
|
+
}
|
59
|
+
}
|
60
|
+
GRAPHQL
|
61
|
+
|
62
|
+
GET_TEAM_BY_NAME_QUERY = <<~GRAPHQL
|
63
|
+
query($name: String!) {
|
64
|
+
teams(filter: { name: { eq: $name } }) {
|
65
|
+
nodes {
|
66
|
+
id
|
67
|
+
name
|
68
|
+
states {
|
69
|
+
nodes {
|
70
|
+
id
|
71
|
+
name
|
72
|
+
}
|
73
|
+
}
|
74
|
+
}
|
75
|
+
}
|
76
|
+
}
|
77
|
+
GRAPHQL
|
78
|
+
|
79
|
+
FIND_LINEAR_ISSUE_BY_GITLAB_URL_QUERY = <<~GRAPHQL
|
80
|
+
query($gitlabUrl: String!) {
|
81
|
+
issueSearch(
|
82
|
+
filter: {
|
83
|
+
comments: {
|
84
|
+
body: {
|
85
|
+
contains: "Original issue: $gitlabUrl"
|
86
|
+
}
|
87
|
+
}
|
88
|
+
}
|
89
|
+
) {
|
90
|
+
nodes {
|
91
|
+
id
|
92
|
+
}
|
93
|
+
}
|
94
|
+
}
|
95
|
+
GRAPHQL
|
96
|
+
|
97
|
+
def initialize(graphql_client: GraphqlClient)
|
98
|
+
@graphql_client = graphql_client
|
99
|
+
end
|
100
|
+
|
101
|
+
def find_label(label)
|
102
|
+
response = @graphql_client.query(FIND_LABEL_QUERY, { label: })
|
103
|
+
response["data"]["issueLabels"]["nodes"].first["id"]
|
104
|
+
end
|
105
|
+
|
106
|
+
def update_labels(issue_id, labels)
|
107
|
+
@graphql_client.mutation(UPDATE_LABELS_MUTATION, { issueId: issue_id, labels: })
|
108
|
+
end
|
109
|
+
|
110
|
+
def list_labels(labels, team_id)
|
111
|
+
@graphql_client.query(LIST_LABELS_QUERY, { labels:, teamId: team_id })
|
112
|
+
end
|
113
|
+
|
114
|
+
def get_user_id_by_email(email)
|
115
|
+
return nil if email.nil? || email.empty?
|
116
|
+
|
117
|
+
response = @graphql_client.query(GET_USER_ID_BY_EMAIL_QUERY, { email: })
|
118
|
+
response["data"]["users"]["nodes"].first["id"]
|
119
|
+
end
|
120
|
+
|
121
|
+
def get_team_by_name(name)
|
122
|
+
response = @graphql_client.query(GET_TEAM_BY_NAME_QUERY, { name: })
|
123
|
+
raise StandardError, "Team #{name} not found in Linear." if response["data"]["teams"]["nodes"].empty?
|
124
|
+
|
125
|
+
response["data"]["teams"]["nodes"].first
|
126
|
+
end
|
127
|
+
|
128
|
+
def find_linear_issue_by_gitlab_url(gitlab_url)
|
129
|
+
response = @graphql_client.query(FIND_LINEAR_ISSUE_BY_GITLAB_URL_QUERY, { gitlabUrl: gitlab_url })
|
130
|
+
response["data"]["issueSearch"]["nodes"].first["id"]
|
131
|
+
end
|
132
|
+
|
133
|
+
def create_issue(issue_data)
|
134
|
+
query = build_graphql_mutation("issueCreate", {
|
135
|
+
input: {
|
136
|
+
title: issue_data[:title],
|
137
|
+
teamId: issue_data[:team_id],
|
138
|
+
description: issue_data[:description],
|
139
|
+
createAsUser: issue_data[:create_as_user],
|
140
|
+
assigneeId: issue_data[:assignee_id],
|
141
|
+
stateId: issue_data[:state_id],
|
142
|
+
parentId: issue_data[:parent_id],
|
143
|
+
createdAt: issue_data[:created_at],
|
144
|
+
labelIds: issue_data[:label_ids],
|
145
|
+
dueDate: issue_data[:due_date],
|
146
|
+
sortOrder: issue_data[:sort_order]
|
147
|
+
}
|
148
|
+
}, ["lastSyncId", "success", "issue { id, url }"])
|
149
|
+
|
150
|
+
linear_issue = @graphql_client.mutation(query)
|
151
|
+
linear_issue&.dig("data", "issueCreate", "issue")
|
152
|
+
end
|
153
|
+
|
154
|
+
def create_comment(body: "", linear_issue_id: nil, author_name: nil, parent_id: nil, created_at: Time.now)
|
155
|
+
query = build_graphql_mutation("commentCreate", {
|
156
|
+
input: {
|
157
|
+
body: body.gsub("...", ". . . "),
|
158
|
+
issueId: linear_issue_id,
|
159
|
+
parentId: parent_id,
|
160
|
+
createAsUser: author_name,
|
161
|
+
createdAt: created_at
|
162
|
+
}
|
163
|
+
}, ["lastSyncId", "success", "comment { id }"])
|
164
|
+
|
165
|
+
response = @graphql_client.mutation(query)
|
166
|
+
response["data"]["commentCreate"]["comment"]
|
167
|
+
end
|
168
|
+
|
169
|
+
def create_mr_link(linear_id, url, path, number, title)
|
170
|
+
query = build_graphql_mutation("attachmentLinkGitLabMR", {
|
171
|
+
issueId: linear_id,
|
172
|
+
url:,
|
173
|
+
projectPathWithNamespace: path,
|
174
|
+
number:,
|
175
|
+
title:
|
176
|
+
}, %w[lastSyncId success])
|
177
|
+
|
178
|
+
@graphql_client.mutation(query)
|
179
|
+
end
|
180
|
+
|
181
|
+
def create_url_link(linear_id, url, title = nil)
|
182
|
+
query = build_graphql_mutation("attachmentLinkURL", {
|
183
|
+
issueId: linear_id,
|
184
|
+
url:,
|
185
|
+
title:
|
186
|
+
}, %w[lastSyncId success])
|
187
|
+
|
188
|
+
@graphql_client.mutation(query)
|
189
|
+
end
|
190
|
+
|
191
|
+
private
|
192
|
+
|
193
|
+
def process_variables(vars)
|
194
|
+
vars.map do |key, value|
|
195
|
+
if value.is_a?(Hash)
|
196
|
+
inner_vars = process_variables(value)
|
197
|
+
"#{key}: { #{inner_vars} }"
|
198
|
+
else
|
199
|
+
"#{key}: #{value.to_json}"
|
200
|
+
end
|
201
|
+
end.join("\n")
|
202
|
+
end
|
203
|
+
|
204
|
+
def build_graphql_mutation(field, variables, return_fields)
|
205
|
+
variables_str = process_variables(variables)
|
206
|
+
return_fields_str = return_fields.join("\n")
|
207
|
+
|
208
|
+
<<~GRAPHQL
|
209
|
+
mutation {
|
210
|
+
#{field}(
|
211
|
+
#{variables_str}
|
212
|
+
) {
|
213
|
+
#{return_fields_str}
|
214
|
+
}
|
215
|
+
}
|
216
|
+
GRAPHQL
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rainbow"
|
4
|
+
|
5
|
+
module Gitlab
|
6
|
+
module Triage
|
7
|
+
module Linear
|
8
|
+
module Migrator
|
9
|
+
# Provides functions to log a query or result to the output.
|
10
|
+
module QueryLogger
|
11
|
+
def log_query(query_string, variables)
|
12
|
+
puts Rainbow("Query: #{query_string}").cyan
|
13
|
+
puts Rainbow("Variables: #{variables}").yellow
|
14
|
+
end
|
15
|
+
|
16
|
+
def log_response(response)
|
17
|
+
puts Rainbow("Response: #{response}").green
|
18
|
+
end
|
19
|
+
|
20
|
+
def log_cache_hit(cache_key)
|
21
|
+
puts Rainbow("Cache hit for key: #{cache_key}").green
|
22
|
+
end
|
23
|
+
|
24
|
+
def log_dry_run(mutation_string, variables)
|
25
|
+
puts Rainbow("DRY-RUN:").blue
|
26
|
+
puts Rainbow(mutation_string).blue
|
27
|
+
puts Rainbow(variables).blue
|
28
|
+
end
|
29
|
+
|
30
|
+
def log_error(errors)
|
31
|
+
puts Rainbow("GraphQL Error: #{errors}").red
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
stages:
|
2
|
+
- triage
|
3
|
+
|
4
|
+
migrate-to-linear:dry-run:
|
5
|
+
stage: triage
|
6
|
+
image: ruby:3.1
|
7
|
+
script:
|
8
|
+
- gem install gitlab-triage-linear-migrator
|
9
|
+
- gitlab-triage --dry-run --token $GITLAB_API_TOKEN --host-url $CI_SERVER_URL --source projects --source-id $CI_PROJECT_PATH --require ./triage_plugins.example.rb --policies-file 'triage_policies.example.yml'
|
10
|
+
when: manual
|
11
|
+
except:
|
12
|
+
- schedules
|
13
|
+
|
14
|
+
migrate-to-linear:run:
|
15
|
+
stage: triage
|
16
|
+
image: ruby:3.1
|
17
|
+
script:
|
18
|
+
- gem install gitlab-triage-linear-migrator
|
19
|
+
- gitlab-triage --token $GITLAB_API_TOKEN --host-url $CI_SERVER_URL --source projects --source-id $CI_PROJECT_PATH --require ./triage_plugins.example.rb --policies-file 'triage_policies.example.yml'
|
20
|
+
only:
|
21
|
+
- schedules
|
@@ -0,0 +1,31 @@
|
|
1
|
+
resource_rules:
|
2
|
+
issues:
|
3
|
+
rules:
|
4
|
+
- name: Create issue in Linear
|
5
|
+
limits:
|
6
|
+
most_recent: 500
|
7
|
+
conditions:
|
8
|
+
labels:
|
9
|
+
- "Linear::To Migrate" # Remove this line if you want all issues to be migrated.
|
10
|
+
#state: opened # Remove this if you want closed issues to be migrated as well.
|
11
|
+
forbidden_labels:
|
12
|
+
- "Linear::Migrated"
|
13
|
+
- "Linear::Migration Failed"
|
14
|
+
ruby: "!labels.map(&:name).grep(/^Guild::.+$/).empty?" # Change this to match team_label_prefix parameter below!
|
15
|
+
actions:
|
16
|
+
comment: |
|
17
|
+
#{create_issue_in_linear(set_state: true, prepend_project_name: false, team_label_prefix: "Guild")}
|
18
|
+
|
19
|
+
# Change the parameters as you wish:
|
20
|
+
# - set_state: set to true if you want your imported issues reflect the current state (group label `S::` in GitLab).
|
21
|
+
# Please note: issues in `S::Inbox` will be migrated to Triage if your team has Triage turned on in Linear.
|
22
|
+
# If Triage is turned off, issues will have the default issue state, which is indicated (and can be set) on your
|
23
|
+
# team's Workflow settings page in Linear.
|
24
|
+
# - prepend_project_name: true means all imported issues will have their titles starting with the GitLab project name.
|
25
|
+
# Set to false to simply copy the issue title as is.
|
26
|
+
# - team_label_prefix: The migration requires a label in Gitlab that can be used to set the team name in Linear.
|
27
|
+
# By default this label needs to be in this format `Team::Name of the team`. The team in Linear must be `Team: Name of the team`.
|
28
|
+
# You can change the `Team:` prefix by setting this parameter to anything you want (e.g. Guild as in the example above).
|
29
|
+
|
30
|
+
# This comment action will post a comment with a link to the new issue in Linear, add Linear::Migrated label and close the issue.
|
31
|
+
# If the migration fails, it will add Linear::Migration Failed label and also some error debug information into the comment.
|
metadata
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gitlab-triage-linear-migrator
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0.rc1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Laurent Arnoud
|
8
|
+
- Roland Molnár
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-02-04 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: gitlab
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.19'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.19'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: gitlab-triage
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.42'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.42'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rainbow
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: Extends GitLab Triage Bot with actions to import issues into Linear (https://linear.app).
|
56
|
+
Initial version was developed by Platform.sh
|
57
|
+
email:
|
58
|
+
- laurent.arnoud@platform.sh
|
59
|
+
- roland.molnar@gmail.com
|
60
|
+
executables: []
|
61
|
+
extensions: []
|
62
|
+
extra_rdoc_files: []
|
63
|
+
files:
|
64
|
+
- ".rspec"
|
65
|
+
- ".rubocop.yml"
|
66
|
+
- LICENSE.txt
|
67
|
+
- README.md
|
68
|
+
- Rakefile
|
69
|
+
- lib/gitlab/triage/linear/migrator.rb
|
70
|
+
- lib/gitlab/triage/linear/migrator/graphql_client.rb
|
71
|
+
- lib/gitlab/triage/linear/migrator/issue_extension.rb
|
72
|
+
- lib/gitlab/triage/linear/migrator/linear_connector.rb
|
73
|
+
- lib/gitlab/triage/linear/migrator/linear_interface.rb
|
74
|
+
- lib/gitlab/triage/linear/migrator/query_logger.rb
|
75
|
+
- lib/gitlab/triage/linear/migrator/version.rb
|
76
|
+
- sig/gitlab/triage/linear/migrator.rbs
|
77
|
+
- support/.gitlab-ci.example.yml
|
78
|
+
- support/triage_plugins.example.rb
|
79
|
+
- support/triage_policies.example.yml
|
80
|
+
homepage: https://github.com/upsun/gitlab-triage-linear-migrator
|
81
|
+
licenses:
|
82
|
+
- MIT
|
83
|
+
metadata:
|
84
|
+
allowed_push_host: https://rubygems.org
|
85
|
+
homepage_uri: https://github.com/upsun/gitlab-triage-linear-migrator
|
86
|
+
source_code_uri: https://github.com/upsun/gitlab-triage-linear-migrator
|
87
|
+
changelog_uri: https://github.com/upsun/gitlab-triage-linear-migrator
|
88
|
+
rubygems_mfa_required: 'true'
|
89
|
+
rdoc_options: []
|
90
|
+
require_paths:
|
91
|
+
- lib
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 3.1.0
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
requirements: []
|
103
|
+
rubygems_version: 3.6.2
|
104
|
+
specification_version: 4
|
105
|
+
summary: Triage Bot extension to migrate GitLab issues to Linear.
|
106
|
+
test_files: []
|