Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Migrate label-based release pipeline to Linear releases

This script helps teams that previously modeled release pipelines with **labels** (e.g. a parent label "Releases" with sub-labels per release) migrate to Linear's native **Releases & release pipelines** feature.

## What it does

- For each **sub-label** of the parent label: creates a **release** in your pipeline (same name as the label), sets **version** to that name (for continuous pipelines), and adds all issues with that label to the release.
- **Idempotent:** if a release with the same name or version already exists in the pipeline, the script reuses it instead of creating a duplicate. Safe to re-run after a partial failure.
- Supports optional **release stage ID** (for continuous vs scheduled pipelines) and **version** (set to the release name by default).

## Prerequisites

- Python 3 with `requests` installed: `pip install requests`
- A **release pipeline** already created in Linear (the script only creates releases inside an existing pipeline).
- A **parent label** whose sub-labels represent releases; each sub-label's name becomes a release name (and version).

## Setup

1. **Get your UUIDs** in Linear: open the label group (O+L) or pipeline, press **Cmd+K**, choose "Copy model UUID" and select the value.
2. At the top of `migrate_label_pipeline_to_releases.py`, paste:
- **API_KEY** – Linear API key (or set `LINEAR_API_KEY` env var).
- **LABEL_GROUP_ID** (or **PARENT_LABEL_ID** in some copies) – parent label whose sub-labels are the releases.
- **RELEASE_PIPELINE_ID** – pipeline where releases will be created.
- **RELEASE_STAGE_ID** (optional) – For continuous pipelines this sets the stage of new releases; for scheduled pipelines set this to avoid releases defaulting to a completed stage. To find pipeline stage IDs, use the [Linear API explorer](https://studio.apollographql.com/public/Linear-API/variant/current/explorer?explorerURLState=N4IgJg9gxgrgtgUwHYBcQC4QEcYIE4CeABAA4CWJCANmUggMooCGA5ggM4AUAJHtQk3YIAChWq0EASTDoijPLRYBCAJTAAOkiJE%2BVAUNGUadTmRlFe-QSLHGpYFUQ1btRdszbsnm166QQwDm8XX20kJkQfUO0zKN8AXziiRJcU%2BJB4oA).
3. Run with `--dry-run` first to see what would be created:
```bash
python3 migrate_label_pipeline_to_releases.py --dry-run
```
4. Run for real:
```bash
python3 migrate_label_pipeline_to_releases.py
```

## CLI options

| Option | Env var | Description |
|--------|---------|-------------|
| `--api-key` | `LINEAR_API_KEY` | Linear API key |
| `--parent-label-id` | `PARENT_LABEL_ID` or `LABEL_GROUP_ID` | Parent label UUID |
| `--pipeline-id` | `RELEASE_PIPELINE_ID` | Release pipeline UUID |
| `--stage-id` | `RELEASE_STAGE_ID` | Optional stage ID for new releases |
| `--dry-run` | — | List sub-labels and issue counts only; no creates |

## Notes

- The script does **not** delete labels; you can remove the label group in Linear after migration if you want. You can bulk delete labels or label groups in the UI.
- Re-running is safe: existing releases (matched by name or version) are reused; only missing ones are created.
- New releases get **version** set to the same value as the name (for continuous pipelines). If the API returns an error about stages, add a stage to your pipeline in Linear or set **RELEASE_STAGE_ID**.
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Migrate label-based release pipeline to Linear's native releases.

This script takes an API key, a parent label ID, and a release pipeline ID. For each sub-label of the parent label it creates a release (same name) in the given pipeline,
and adds all issues with that label to the release. It does not delete any labels; you can delete the label group after running the script if desired.
"""

# =============================================================================
# PASTE YOUR VALUES HERE
# UUIDs: open the label group (o+l) or pipeline in Linear → Cmd+K → "copy model uuid"
# =============================================================================

API_KEY = ""
PARENT_LABEL_ID = ""
RELEASE_PIPELINE_ID = ""
RELEASE_STAGE_ID = ""
# For continuous pipelines, this is optional; we'll fallback to completed if you do not fill this field.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's say instead that this is supported only for scheduled. We don't allow custom stages for continuous.

# If your pipeline is scheduled, set this field to the desired value. See README for how to find stage IDs.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also usually, comments are above the variable they are mentioning.


# =============================================================================

import argparse
import os
import sys
from typing import Optional

import requests

LINEAR_GRAPHQL = "https://api.linear.app/graphql"


def graphql(api_key: str, query: str, variables: dict | None = None) -> dict:
payload = {"query": query}
if variables:
payload["variables"] = variables
r = requests.post(
LINEAR_GRAPHQL,
json=payload,
headers={"Authorization": api_key, "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
data = r.json()
if "errors" in data:
raise RuntimeError(f"GraphQL errors: {data['errors']}")
return data.get("data", {})


def paginate(api_key: str, query: str, base_vars: dict, path: str) -> list:
"""Run paginated query; path is e.g. 'issueLabels' or 'issues'."""
nodes, cursor = [], None
while True:
vars_ = {**base_vars, "after": cursor}
page = graphql(api_key, query, vars_)[path]
nodes.extend(page["nodes"])
if not page["pageInfo"]["hasNextPage"]:
break
cursor = page["pageInfo"]["endCursor"]
return nodes


def get_sub_labels(api_key: str, parent_label_id: str) -> list[dict]:
q = """
query($filter: IssueLabelFilter, $first: Int!, $after: String) {
issueLabels(filter: $filter, first: $first, after: $after) {
nodes { id name }
pageInfo { hasNextPage endCursor }
}
}
"""
return paginate(api_key, q, {"filter": {"parent": {"id": {"eq": parent_label_id}}}, "first": 100}, "issueLabels")


def get_issues_with_label(api_key: str, label_id: str) -> list[dict]:
q = """
query($filter: IssueFilter, $first: Int!, $after: String) {
issues(filter: $filter, first: $first, after: $after) {
nodes { id }
pageInfo { hasNextPage endCursor }
}
}
"""
return paginate(api_key, q, {"filter": {"labels": {"some": {"id": {"eq": label_id}}}}, "first": 100}, "issues")


def get_releases_in_pipeline(api_key: str, pipeline_id: str) -> list[dict]:
"""Return all releases in the pipeline (id, name, version)."""
q = """
query($id: String!, $first: Int!, $after: String) {
releasePipeline(id: $id) {
releases(first: $first, after: $after) {
nodes { id name version }
pageInfo { hasNextPage endCursor }
}
}
}
"""
nodes: list[dict] = []
cursor: Optional[str] = None
while True:
vars_ = {"id": pipeline_id, "first": 100, "after": cursor}
data = graphql(api_key, q, vars_)
releases = data.get("releasePipeline") or {}
page = releases.get("releases") or {}
nodes.extend(page.get("nodes") or [])
if not page.get("pageInfo", {}).get("hasNextPage"):
break
cursor = page["pageInfo"].get("endCursor")
return nodes


def create_release(
api_key: str,
pipeline_id: str,
name: str,
stage_id: Optional[str] = None,
version: Optional[str] = None,
) -> dict:
input_: dict = {"name": name, "pipelineId": pipeline_id}
if stage_id:
input_["stageId"] = stage_id
if version is not None:
input_["version"] = version
data = graphql(api_key, """
mutation($input: ReleaseCreateInput!) {
releaseCreate(input: $input) { success release { id name version } }
}
""", {"input": input_})
if not data.get("releaseCreate", {}).get("success"):
raise RuntimeError(f"releaseCreate failed: {data}")
return data["releaseCreate"]["release"]


def add_issue_to_release(api_key: str, issue_id: str, release_id: str) -> None:
data = graphql(api_key, """
mutation($input: IssueToReleaseCreateInput!) {
issueToReleaseCreate(input: $input) { success }
}
""", {"input": {"issueId": issue_id, "releaseId": release_id}})
if not data.get("issueToReleaseCreate", {}).get("success"):
raise RuntimeError(f"issueToReleaseCreate failed: {data}")


def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--api-key", default=API_KEY or os.environ.get("LINEAR_API_KEY"))
p.add_argument("--parent-label-id", default=PARENT_LABEL_ID or os.environ.get("PARENT_LABEL_ID"))
p.add_argument("--pipeline-id", default=RELEASE_PIPELINE_ID or os.environ.get("RELEASE_PIPELINE_ID"))
p.add_argument("--stage-id", default=RELEASE_STAGE_ID or os.environ.get("RELEASE_STAGE_ID"), help="Stage ID for created releases (optional; see RELEASE_STAGE_ID at top of file)")
p.add_argument("--dry-run", action="store_true")
args = p.parse_args()

api_key = args.api_key or sys.exit("Missing API key. Paste it at the top of this file, or set LINEAR_API_KEY.")
parent_label_id = args.parent_label_id or sys.exit("Missing parent label ID. Paste it at the top of this file, or set PARENT_LABEL_ID.")
pipeline_id = args.pipeline_id or sys.exit("Missing pipeline ID. Paste it at the top of this file, or set RELEASE_PIPELINE_ID.")

release_stage_id: Optional[str] = (args.stage_id or "").strip() or None

if not api_key.startswith(("lin_api_", "Bearer ")):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be missing something, but I don't think this would ever evaluate in a way that would apply the logic below?

api_key = f"Bearer {api_key}"

sub_labels = get_sub_labels(api_key, parent_label_id)
if not sub_labels:
sys.exit("No sub-labels under parent label")

existing_releases: list[dict] = []
if not args.dry_run:
existing_releases = get_releases_in_pipeline(api_key, pipeline_id)

for lab in sub_labels:
name = lab["name"]
version = name # continuous pipelines use version (unique per pipeline)
issues = get_issues_with_label(api_key, lab["id"])
print(f"{name}: {len(issues)} issue(s)")
if not args.dry_run:
release = None
for r in existing_releases:
if r.get("version") == version or r.get("name") == name:
release = r
break
if release is None:
release = create_release(
api_key, pipeline_id, name, stage_id=release_stage_id, version=version
)
existing_releases.append(release)
for issue in issues:
add_issue_to_release(api_key, issue["id"], release["id"])


if __name__ == "__main__":
main()