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 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: []