Syncing Linear tickets to Silverbullet space

I use the following script to sync linear tickets assigned to me into my Silverbullet space. You can run something like ./script sb to sync all linear tickets assigned to you into your space along with any that you have manually added in your space. Run ./script sb <ticket> to sync a specific ticket.

This has two uses IMO:

  • I have a local copy of linear tickets I can check
  • I get auto-completions when linking to linear tickets in my todos.

Right now it fetches all the tickets assigned to you and in your space and so I don’t know how this will scale or how throttling on linear side will hit you, but posting it in case anyone finds this useful.

This was modified from what I have in my dotfiles. Feel free to check there for a more up-to-date version in future.


The ticket page in Silverbullet would look something like below

---
type: linear-issue
title: Title of ticket
url: https://linear.app/org/issue/ABC-7273
assignee: John Doe
state: In Review
tags: P4,Bug
---

[Title of ticket](https://linear.app/org/issue/ABC-7273)

Description, blah, blah, blah...

And here is the script

#!/bin/sh

# List and open linear tickets assigned to you
# https://developers.linear.app/docs
# https://studio.apollographql.com/public/Linear-API/variant/current/schema/reference

set -e

LINEAR_TOKEN="..."
LINEAR_USER_ID="..."
LINEAR_FOLDER_PREFIX="..."
SB_PATH="$HOME/.local/share/sbdb/"
listtempfile="/tmp/linear-tickets"

format_issues() {
    jq -r '.data.user.assignedIssues.nodes[]|"[\(.state.type)] \(.identifier) \(.title)"' <"$listtempfile"
}

list_issues() {
    if [ "$(find "$listtempfile" -mmin -10 2>/dev/null)" = "" ]; then
        printf "Fetching linear tickets...\r" >&2
        curl -sX POST \
            -H "Content-Type: application/json" \
            -H "Authorization: $LINEAR_TOKEN" \
            -d '{"query": "query {user(id: \"'"$LINEAR_USER_ID"'\") {id organization { urlKey } name assignedIssues {nodes {identifier state {type} title}}}}"}' \
            https://api.linear.app/graphql >"$listtempfile"
    fi

    {
        format_issues | grep -E "^\[started"
        format_issues | grep -E "^\[unstarted"
    }
}

list_and_open_issue() {
    urlkey="$(jq -r '.data.user.organization.urlKey' <"$listtempfile")"
    list_issues | ,picker -m | cut -d' ' -f2 | xargs -I{} open "https://linear.app/$urlkey/issue/{}"
}

get_issue_description() {
    team="$(echo "$1" | cut -d'-' -f1)"
    number="$(echo "$1" | cut -d'-' -f2)"

    tmpfile="/tmp/linear-issue-$1"
    printf "Fetching linear issue %s...\n" "$team-$number" >&2

    curl -sX POST \
        -H 'content-type: application/json' \
        -H "Authorization: $LINEAR_TOKEN" \
        --data '{"query":"query Issues { issues(filter: { number : { eq: '"$number"' } team : {key: {eq: \"'"$team"'\"}}}) { nodes { assignee {name}, state{name}, labels {nodes{name}}, title, description, team { organization {urlKey}}}}}"}' \
        https://api.linear.app/graphql >"$tmpfile"

    urlKey="$(jq -r '.data.issues.nodes[0].team.organization.urlKey' <"$tmpfile")"
    title="$(jq -r '.data.issues.nodes[0].title' <"$tmpfile")"
    desc="$(jq -r '.data.issues.nodes[0].description // empty' <"$tmpfile")"
    assignee="$(jq -r '.data.issues.nodes[0].assignee.name // empty' <"$tmpfile")"
    state="$(jq -r '.data.issues.nodes[0].state.name // empty' <"$tmpfile")"
    labels="$(jq -r '.data.issues.nodes[0].labels.nodes[].name' <"$tmpfile" | tr '\n' ',' | sed 's/,$//')"

    if [ -z "$title" ]; then
        printf "Issue %s not found\n" "$team-$number" >&2
        return 1
    fi

    echo "---"
    echo "type: linear-issue"
    echo "title: $title"
    echo "url: https://linear.app/$urlKey/issue/$1"
    if [ -n "$assignee" ]; then
        echo "assignee: $assignee"
    fi
    echo "state: $state"
    if [ -n "$labels" ]; then
        echo "labels: $labels"
    fi
    echo "---"

    printf "[%s](%s)\n" "$title" "https://linear.app/$urlKey/issue/$1"
    echo
    printf "%s" "$desc"
}

update_silverbullet() {
    if [ -n "$1" ]; then
        desc="$(get_issue_description "$1")"
        if [ -n "$desc" ]; then
            echo "$desc" >"$SB_PATH/$LINEAR_FOLDER_PREFIX/$1.md"
        fi
        return
    fi

    list_all_issues |
        while read -r issue; do
            desc="$(get_issue_description "$issue")"
            if [ -n "$desc" ]; then
                echo "$desc" >"$SB_PATH/$LINEAR_FOLDER_PREFIX/$issue.md"
            fi
        done
}

list_all_issues() {
    assigned_issues="$(list_issues | cut -d' ' -f2)"
    existing_issues="$(find "$SB_PATH/$LINEAR_FOLDER_PREFIX" -type f | xargs -n1 basename | cut -d'.' -f1)"
    printf "%s\n%s" "$assigned_issues" "$existing_issues" | sort -u
}

if [ -n "$1" ]; then
    case "$1" in
    list) list_issues ;;
    list-all) list_all_issues ;;
    sb) update_silverbullet "$2" ;;
    *) get_issue_description "$1" ;;
    esac
else
    list_and_open_issue
fi

Very nice, I think the idea that “these are just markdown files so any tool can generate them” is still under explored. This is a good example!