> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How did we migrate issues from GitLab to Linear?

export const PostMeta = ({data = {}}) => {
  const {author, date, image} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const linkedin = entry.linkedin || null;
    const url = github ? `https://github.com/${github}` : linkedin || null;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (!image && authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} target="_blank" rel="noopener noreferrer" className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
      {image && <img src={image} alt="" className="post-meta-image" aria-hidden="true" />}
    </div>;
};

<PostMeta data={{ author: ["Roland Molnar"], date: "2024-12-18T10:56:29-05:00", image: "/images/posts/discussions/triage-bot/kier-in-sight-archives-0fL5XCrt_nU-unsplash.webp" }} />

We migrated our issues from GitLab to Linear due to GitLab's limited issue-tracking features. To handle thousands of issues and tight timelines, we developed a migration tool using GitLab Triage Bot and Linear's GraphQL API. The tool mapped issue states, assignees, labels, and comments, and transformed GitLab epics into Linear issues with sub-issues. It was successful overall and enabled team-by-team migration for flexibility. Now, [we’re open-sourcing the migrator](https://github.com/upsun/gitlab-triage-linear-migrator) to help others facing similar challenges.

## The background

More than a year ago we decided to find a better solution to manage our issues and initiatives. We found GitLab’s issue tracking features to be limited and not flexible enough to support our needs, so we started a research project to find a better issue management tool. In order to find the best match, we collected our use cases and requirements and ranked all potential solutions based on what is important to us vs. what the tools were capable of. This is how we came to the decision to migrate to [Linear](https://linear.app/), a cool and flexible solution to track issues and projects.

The problem is, Linear doesn’t have an official importer from GitLab. We had thousands of issues, so we had to come up with something—and the time was tight.

## Using GitLab Triage Bot

The first idea was a full, stand-alone migrator with a database (to track migration tasks, progress, errors and to map IDs between the two systems), but for various reasons we only had a few weeks to spin up an MVP to show teams how their issues would look in Linear. At that time, we used [GitLab Triage Bot](https://gitlab.com/gitlab-org/ruby/gems/gitlab-triage/) to help maintain healthy projects and issues across different teams.

We already had a few extensions that helped us to run Triage Bot on different projects and groups. It had a set of handy CI-related scripts and functions that allowed us to create multiple YAML files for the Triage Bot, and then set target projects/groups in that file. So, instead of running the bot with the target project as parameter, a script reads the folder that contains these policies and creates CI pipeline jobs for each file and target project combination.\
You can read more in this article…

## How does it work?

As Triage Bot can list issues and execute actions on them easily, the next part was just to call [Linear’s GraphQL API](https://developers.linear.app/docs/graphql/working-with-the-graphql-api) to import a given issue and set some basic properties. To create a Linear issue, we simply need two things: target team and issue title. We have the title, that’s easy, but how do we set the target team?

### Set the Linear team

As we currently have guilds in our engineering department and we already used scoped labels on issues to set which guild an issue belongs to, we decided to use this label. In GitLab, we use the following format: `Guild::Guild Name`. In Linear we decided to use `Guild: Guild Name` naming scheme for teams, so we just needed to do a lookup in Linear to find the team ID based on its name.

### Issue states

As we currently have guilds in our engineering department and we already used scoped labels on issues to set which guild an issue belongs to, we decided to use this label. In GitLab, we use the following format: `Guild::Guild Name`. In Linear we decided to use `Guild: Guild Name` naming scheme for teams, so we just needed to do a lookup in Linear to find the team ID based on its name.

### Closed issues

Some teams decided to leave closed issues in GitLab, some teams wanted to migrate them. As we use Triage Bot conditions to list issues, and we could have triage YAML files per team, it was just an extra condition in the Triage Bot rule.

### Assignee(s)

The migrator also sets issue assignee. As GitLab supports multiple assignees, but Linear doesn’t, it only takes the first assignee.

### Labels

Migrating labels was a bit tricky. Both GitLab and Linear support some sort of label grouping, but in Linear the group name is not included in the label name as in GitLab (where we have scoped labels, `::` acting as separator between the group and the label. Labels must be unique in Linear, not just within a group, so you can’t have a `colors/green` and `eco/green` label at the same time.

To solve this problem, we simply ignore the group name in GitLab, so `colors::green` and `eco/green` will be `green` in Linear (whether or not `green` is in a label group).

The migrator doesn't create any labels. If the label in Linear doesn't exist, it will simply ignore it. Labels should be created in Linear upfront, which gives flexibility to create them in the workspace or team level and within a group or stand-alone.

### Comments

After the migrator creates the issue, it imports all the comments from GitLab. It maintains the creation data and processes both threaded comments and normal comments.

### Epics

Linear doesn't have the concept of epics, so we decided to migrate epics as issues and issues under epics as sub-issues. But this is optional, if a team doesn't want to migrate their epics, only issues will be migrated. The hardest part was to keep track of the new IDs that the imported issues get in Linear to maintain parent-child relationships. As the migrator doesn't have a database, we decided to maintain a migration map in memory. But because the script can be run multiple times, it acts only as a cache, and if the linear issue ID is not in there, we use the comment that the migrator adds to the Linear issue to search for the issue (as comments are easily searchable in Linear).

### Author

Linear has a [`createAsUser`](https://developers.linear.app/docs/oauth/oauth-actor-authorization) attribute when creating issues and comments via the API (only available when using OAuth Actor Authorization) which allows us to set who created the issue or comment originally - even if that user doesn’t exist in Linear.

### Links to merge requests

Both in GitLab and Linear, issues can be linked to merge requests. The migrator migrates these links too.

### Status

How it tracks the status, use labels in Linear and GitLab.\
As there is no database behind the migrator, but we wanted to track progress, it sets the `Linear::Migrated` label when an issue was migrated, and `Linear::Migration Failed` when it failed for some reason.

In Linear, all new issues are created with the label `Migrating from GitLab - in progress`. If all the comments and links are migrated, it then removes this and adds `Migrated from GitLab`. This is useful if the migration fails in the middle - e.g. a comment cannot be migrated, so the issue is missing some data - better to delete and re-import again.

### Tests

As with software in general, it is hard to test all scenarios, we have a good test coverage in unit tests, where we test certain portions of the code. But because it relies on two external APIs, it’s very difficult to test everything, especially how it will behave with the real APIs. In the unit tests, we mock API responses, but the best test is if you can run it against the real APIs. Thankfully, GitLab Triage Bot has a dry-run mode, where it processes all the epics/issues, but instead of making any change, it simply prints what it would do.

The Linear part is more tricky, as we can’t have a real dry-run. We decided to use Linear’s API in read-only mode, when the script is in dry-run. This means it prints out the mutations instead of actually running them. But this is quite limited, as comments and links can only be migrated once the issue is created. So we have an extra option (in an environment variable) to run real Linear mutations even in dry-run. This allows us to test the migration in Linear but not change anything in GitLab.

Mass-deleting issues in Linear is easy, and it is also possible to use a test workspace. Once we were happy with the results, we could run the migration for real.

### Run in CI

We wanted the migration to be as self-service for teams as possible, we have set up CI jobs to run it every hour automatically. If a team wanted their issues being migrated, they could create a merge request, containing a new policy file that is for their projects. Once that merge request was merged, the bot picked it up and migrated the issues.\
Additionally, a dry-run was running for all merge requests commits, so it was easier to spot issues beforehand.

## Conclusion

Migration is always challenging, both technically and organisationally. Looking back, it was a good decision to do it team-by-team, and not in one go. Although we had many more ideas on how to improve the migration (e.g. it does not migrate attached images), we were quite happy with it in the end.

<img src="https://mintcdn.com/upsun-c9761871/ZBMwngApLEEeuab_/images/posts/discussions/triage-bot/triage-migration.webp?fit=max&auto=format&n=ZBMwngApLEEeuab_&q=85&s=36d239ac20c65e0acba1261556511767" alt="" width="945" height="1200" data-path="images/posts/discussions/triage-bot/triage-migration.webp" />

While we might not need it again (although we have set it up to be there and watch for any new issues, and move them across), we decided to open-source it, so others in the same situation don’t have to write it again.\
We hope you find it useful!

## Use it, fork it, contribute!

You can find our work on GitHub: [https://github.com/upsun/gitlab-triage-linear-migrator](https://github.com/upsun/gitlab-triage-linear-migrator)
