-
Notifications
You must be signed in to change notification settings - Fork 6
Migrate label-based releases to release pipelines #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| # If your pipeline is scheduled, set this field to the desired value. See README for how to find stage IDs. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ")): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
There was a problem hiding this comment.
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.