DevLab / integrations / linear-issue-syncer-from-github-prs
INTEGRATIONS PYTHON INTERMEDIATE

Linear Issue Syncer from GitHub PRs

In fast-moving software teams, sync overhead between code management platforms (GitHub) and issue trackers (Linear) creates visibility gaps. Developers fre

Python3.11+
OSLinux / macOS / Windows
Lines~223
Packages2 deps
Install Dependencies
$ pip install requests PyGithub
Usage
$ python3 linear_sync.py --repo org/repo --team-id abc123 --since 7d
#integrations#python#intermediate#requests#PyGithub

Problem Statement

In fast-moving software teams, sync overhead between code management platforms (GitHub) and issue trackers (Linear) creates visibility gaps. Developers frequently open pull requests without manually updating Linear tickets, resulting in stale project boards, untracked work, and disconnected context between engineering and product management.

This script automates the synchronization process. It scans recent GitHub pull requests for references to Linear issues, attaches PR links to existing Linear tickets, and automatically creates new Linear issues for unlinked pull requests. This ensures that every active PR is mapped to an actionable project management artifact without requiring manual input from developers.


How It Works

The script operates through a pipeline that bridges GitHub's REST API and Linear's GraphQL API:

  1. GitHub Pull Request Fetching: Using PyGithub, the script queries a specified repository for pull requests updated within a configurable time window (e.g., 7d, 24h).
  2. Issue Identifier Extraction: A regular expression scans each PR's title, body, and branch name for Linear issue keys (e.g., ENG-123 or PROD-45).
  3. Linear Attachment & Creation:
    • Matched Keys: The script queries Linear's GraphQL API to verify the ticket's existence and attaches the GitHub PR URL as an issue attachment.
    • Unmatched PRs: If no issue key is detected, the script issues a GraphQL mutation to create a new Linear ticket under the specified team ID and links the PR to it.
  4. Execution Safety: A --dry-run flag allows previewing changes without modifying Linear resources.
+------------------+     PyGithub      +---------------------+
| GitHub PR Scanner| ----------------> |  Filter PRs by Date |
+------------------+                   +---------------------+
                                                  |
                                            Extract Keys
                                                  |
                                                  v
                                       +--------------------+
                                       | Linear Key Found?  |
                                       +--------------------+
                                        /                  \
                                  YES  /                    \  NO
                                      v                      v
                           +-------------------+    +--------------------+
                           | Attach PR URL to  |    | Create New Linear  |
                           | Linear Issue      |    | Issue + Attach PR  |
                           +-------------------+    +--------------------+

Full Script

#!/usr/bin/env python3
"""
Linear Issue Syncer from GitHub PRs

Scans GitHub PRs updated within a given timeframe, extracts Linear issue keys,
attaches PRs to existing Linear issues, or creates new Linear issues for unlinked PRs.
"""

import argparse
import datetime
import logging
import os
import re
import sys
from typing import Any, Dict, List, Optional
import github
from github import Github, GithubException
import requests

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger("linear_sync")

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


class LinearClient:
    """Client wrapper for Linear GraphQL API."""

    def __init__(self, api_key: str):
        self.api_key = api_key
        self.headers = {
            "Authorization": self.api_key,
            "Content-Type": "application/json",
        }

    def _execute(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        """Execute a GraphQL query/mutation against Linear API."""
        payload = {"query": query, "variables": variables or {}}
        try:
            response = requests.post(LINEAR_API_URL, json=payload, headers=self.headers, timeout=15)
            response.raise_for_status()
            data = response.json()
            if "errors" in data:
                raise RuntimeError(f"Linear GraphQL Error: {data['errors']}")
            return data.get("data", {})
        except requests.RequestException as err:
            logger.error("HTTP request to Linear API failed: %s", err)
            raise

    def get_issue(self, issue_id: str) -> Optional[Dict[str, Any]]:
        """Fetch a Linear issue by identifier (e.g., 'ENG-123')."""
        query = """
        query GetIssue($id: String!) {
            issue(id: $id) {
                id
                identifier
                title
                url
            }
        }
        """
        data = self._execute(query, {"id": issue_id})
        return data.get("issue")

    def create_issue(self, team_id: str, title: str, description: str) -> Dict[str, Any]:
        """Create a new Linear issue for a team."""
        mutation = """
        mutation CreateIssue($teamId: String!, $title: String!, $description: String!) {
            issueCreate(input: { teamId: $teamId, title: $title, description: $description }) {
                success
                issue {
                    id
                    identifier
                    url
                }
            }
        }
        """
        data = self._execute(mutation, {"teamId": team_id, "title": title, "description": description})
        issue_data = data.get("issueCreate", {})
        if not issue_data.get("success"):
            raise RuntimeError(f"Failed to create Linear issue for title: {title}")
        return issue_data["issue"]

    def create_attachment(self, issue_id: str, title: str, url: str) -> bool:
        """Attach an external link (GitHub PR) to a Linear issue."""
        mutation = """
        mutation CreateAttachment($issueId: String!, $title: String!, $url: String!) {
            attachmentCreate(input: { issueId: $issueId, title: $title, url: $url }) {
                success
            }
        }
        """
        data = self._execute(mutation, {"issueId": issue_id, "title": title, "url": url})
        return data.get("attachmentCreate", {}).get("success", False)


def parse_since_argument(since_str: str) -> datetime.datetime:
    """Parse time range strings like '7d', '24h', '30m' into UTC datetime objects."""
    unit = since_str[-1].lower()
    if unit not in ("d", "h", "m"):
        raise ValueError("Time range unit must be 'd' (days), 'h' (hours), or 'm' (minutes).")

    try:
        value = int(since_str[:-1])
    except ValueError as err:
        raise ValueError(f"Invalid numeric value in time range: {since_str}") from err

    now = datetime.datetime.now(datetime.timezone.utc)
    if unit == "d":
        return now - datetime.timedelta(days=value)
    if unit == "h":
        return now - datetime.timedelta(hours=value)
    return now - datetime.timedelta(minutes=value)


def extract_linear_keys(text: str) -> List[str]:
    """Extract potential Linear issue keys (e.g., 'ENG-123') using regex."""
    if not text:
        return []
    pattern = r"\b[A-Z]{2,10}-\d+\b"
    return list(set(re.findall(pattern, text)))


def sync_github_prs(
    github_token: str,
    linear_api_key: str,
    repo_name: str,
    team_id: str,
    since_dt: datetime.datetime,
    dry_run: bool = False,
) -> None:
    """Main synchronization logic between GitHub PRs and Linear issues."""
    gh = Github(github_token)
    linear = LinearClient(linear_api_key)

    try:
        repo = gh.get_repo(repo_name)
    except GithubException as err:
        logger.error("Failed to access GitHub repository '%s': %s", repo_name, err)
        sys.exit(1)

    logger.info("Fetching PRs for %s updated since %s...", repo_name, since_dt.isoformat())
    pulls = repo.get_pulls(state="all", sort="updated", direction="desc")

    processed_count = 0
    synced_count = 0
    created_count = 0

    for pr in pulls:
        # Convert GitHub Naive/UTC datetime for comparison
        pr_updated_at = pr.updated_at.replace(tzinfo=datetime.timezone.utc)
        if pr_updated_at < since_dt:
            # PRs are sorted descending; once we cross the cutoff, stop iteration
            break

        processed_count += 1
        logger.info("Processing PR #%d: '%s'", pr.number, pr.title)

        # Search for issue keys across title, body, and branch name
        search_text = f"{pr.title}\n{pr.body or ''}\n{pr.head.ref}"
        found_keys = extract_linear_keys(search_text)

        if found_keys:
            for key in found_keys:
                logger.info("Found Linear reference key '%s' in PR #%d", key, pr.number)
                if dry_run:
                    logger.info("[DRY RUN] Would link PR #%d to Linear issue %s", pr.number, key)
                    continue

                linear_issue = linear.get_issue(key)
                if linear_issue:
                    success = linear.create_attachment(
                        issue_id=linear_issue["id"],
                        title=f"GitHub PR #{pr.number}: {pr.title}",
                        url=pr.html_url,
                    )
                    if success:
                        logger.info("Successfully attached PR #%d to Linear issue %s", pr.number, key)
                        synced_count += 1
                else:
                    logger.warning("Linear issue %s referenced in PR #%d was not found.", key, pr.number)
        else:
            logger.info("No Linear key found for PR #%d. Creating a new issue...", pr.number)
            if dry_run:
                logger.info("[DRY RUN] Would create new Linear issue for PR #%d", pr.number)
                continue

            issue_title = f"[PR #{pr.number}] {pr.title}"
            issue_desc = (
                f"Automated issue created from GitHub PR.\n\n"
                f"**PR Link:** {pr.html_url}\n"
                f"**Author:** {pr.user.login}\n"
                f"**State:** {pr.state}\n\n"
                f"--- Description ---\n{pr.body or 'No description provided.'}"
            )

            new_issue = linear.create_issue(team_id=team_id, title=issue_title, description=issue_desc)
            logger.info("Created Linear issue %s for PR #%d", new_issue["identifier"], pr.number)

            linear.create_attachment(
                issue_id=new_issue["id"],
                title=f"GitHub PR #{pr.number}: {pr.title}",
                url=pr.html_url,
            )
            created_count += 1

    logger.info("--- Sync Summary ---")
    logger.info("PRs Examined: %d", processed_count)
    logger.info("PRs Synced to Existing Issues: %d", synced_count)
    logger.info("New Linear Issues Created: %d", created_count)


def main() -> None:
    parser = argparse.ArgumentParser(description="Sync GitHub PRs with Linear Issues.")
    parser.add_argument("--repo", required=True, help="GitHub repository in 'owner/repo' format.")
    parser.add_argument("--team-id", required=True, help="Linear Team ID (UUID) for issue creation.")
    parser.add_argument(
        "--since",
        default="7d",
        help="Time window to scan for updated PRs (e.g., '7d', '24h', '60m'). Default: 7d",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Perform a dry run without modifying Linear issues.",
    )

    args = parser.parse_args()

    github_token = os.environ.get("GITHUB_TOKEN")
    linear_api_key = os.environ.get("LINEAR_API_KEY")

    if not github_token:
        logger.error("Environment variable GITHUB_TOKEN is required.")
        sys.exit(1)

    if not linear_api_key:
        logger.error("Environment variable LINEAR_API_KEY is required.")
        sys.exit(1)

    try:
        since_dt = parse_since_argument(args.since)
    except ValueError as err:
        logger.error("Invalid '--since' argument: %s", err)
        sys.exit(1)

    sync_github_prs(
        github_token=github_token,
        linear_api_key=linear_api_key,
        repo_name=args.repo,
        team_id=args.team_id,
        since_dt=since_dt,
        dry_run=args.dry_run,
    )


if __name__ == "__main__":
    main()

Configuration

Environment Variable / Flag Required Default Description
GITHUB_TOKEN Yes N/A GitHub Personal Access Token or Installation Token with repo read permissions.
LINEAR_API_KEY Yes N/A Linear API Key generated under Personal Settings > API in Linear.
--repo Yes N/A Target GitHub repository in owner/name format (e.g., acme/backend).
--team-id Yes N/A Linear Team UUID where unlinked issues should be created. Found under Team Settings > General in Linear.
--since No 7d Lookback period for updated PRs. Supports days (d), hours (h), or minutes (m).
--dry-run No False Run script in read-only mode to log planned actions without executing GraphQL mutations.

Usage Examples

Standard Sync Execution

Sync PRs updated in the last 7 days for the repository acme/webapp:

export GITHUB_TOKEN="ghp_xK89s20..."
export LINEAR_API_KEY="lin_api_83f..."

python3 linear_sync.py \
  --repo acme/webapp \
  --team-id "abc12345-6789-0123-4567-89abcdef0123" \
  --since 7d

Expected Output:

2026-03-31 10:00:00,102 [INFO] Fetching PRs for acme/webapp updated since 2026-03-24T10:00:00.102000+00:00...
2026-03-31 10:00:01,450 [INFO] Processing PR #104: 'fix(auth): correct token validation logic'
2026-03-31 10:00:01,451 [INFO] Found Linear reference key 'ENG-402' in PR #104
2026-03-31 10:00:02,110 [INFO] Successfully attached PR #104 to Linear issue ENG-402
2026-03-31 10:00:02,111 [INFO] Processing PR #105: 'feat(ui): add dark mode toggle'
2026-03-31 10:00:02,112 [INFO] No Linear key found for PR #105. Creating a new issue...
2026-03-31 10:00:03,021 [INFO] Created Linear issue ENG-512 for PR #105
2026-03-31
// RELATED_SCRIPTS
← Back to DevLab valtersit.com