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 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
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>&nbsp&nbsp&nbsp&nbsp&nbsp&nbsp
8
+ <a href="https://upsun.com/"><strong>Website</strong></a>&nbsp&nbsp&nbsp&nbsp&nbsp&nbsp
9
+ <a href="https://docs.upsun.com"><strong>Documentation</strong></a>&nbsp&nbsp&nbsp&nbsp&nbsp&nbsp
10
+ <a href="https://upsun.com/blog/"><strong>Blog</strong></a>&nbsp&nbsp&nbsp&nbsp&nbsp&nbsp
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Triage
5
+ module Linear
6
+ module Migrator
7
+ VERSION = "1.0.0.rc1"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "migrator/version"
4
+
5
+ module Gitlab
6
+ module Triage
7
+ module Linear
8
+ # Migrates issues from GitLab to Linear
9
+ module Migrator
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ module Gitlab
2
+ module Triage
3
+ module Linear
4
+ module Migrator
5
+ VERSION: String
6
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
7
+ end
8
+ end
9
+ end
10
+ 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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gitlab/triage/linear/migrator/issue_extension"
4
+ Gitlab::Triage::Resource::Context.include Gitlab::Triage::Linear::Migrator::IssueExtension
@@ -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: []