gitlab-triage-linear-migrator 1.0.0.rc1
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/.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: []
|