1+ #! /usr/bin/env bash
2+ # sync-linear-labels.sh
3+ #
4+ # For every non-archived repo in the GitHub org (excluding the inbox repo),
5+ # ensures a corresponding label exists in Linear under the configured group.
6+ # Labels in the group that no longer correspond to a repo are deleted.
7+ # If the inbox repo is private, only private repos are considered as targets
8+ # (GitHub cannot transfer issues from a private repo to a public one).
9+ #
10+ # Required environment variables:
11+ # GH_TOKEN — GitHub PAT with read:org scope
12+ # LINEAR_API_KEY — Linear personal API key
13+ # LINEAR_TEAM_ID — UUID of the Linear team to create labels in
14+ # LABEL_PREFIX — Prefix for label names (e.g. "🐙 ")
15+ # LINEAR_GROUP — Name of the Linear label group (e.g. "repo")
16+
17+ set -euo pipefail
18+
19+ ORG=" $GITHUB_REPOSITORY_OWNER "
20+ INBOX_REPO=" ${GITHUB_REPOSITORY#*/ } "
21+
22+ linear_post () {
23+ # Takes a jq expression that produces a {"query":..,"variables":..} object.
24+ # Using jq to build the payload means queries can be pretty multiline strings
25+ # and variables are safely encoded without manual escaping.
26+ local response
27+ if ! response=$( jq -n " $1 " \
28+ | curl -s --show-error -X POST https://api.linear.app/graphql \
29+ -H " Authorization: $LINEAR_API_KEY " \
30+ -H " Content-Type: application/json" \
31+ -d @-) ; then
32+ echo " Linear API request failed (HTTP error)" >&2
33+ exit 1
34+ fi
35+
36+ # Abort if the response contains a GraphQL errors field
37+ if echo " $response " | jq -e ' .errors' > /dev/null 2>&1 ; then
38+ echo " Linear API error:" >&2
39+ echo " $response " | jq ' .errors' >&2
40+ exit 1
41+ fi
42+
43+ echo " $response "
44+ }
45+
46+ # Detect inbox repo visibility and limit target repos accordingly.
47+ # GitHub cannot transfer issues from a private repo to a public one.
48+ echo " Detecting inbox repo visibility..."
49+ is_private=$( gh repo view " $ORG /$INBOX_REPO " --json isPrivate --jq ' .isPrivate' )
50+
51+ if [ " $is_private " = " true" ]; then
52+ echo " Inbox is private — limiting targets to private repos only."
53+ visibility_flag=" --visibility private"
54+ else
55+ echo " Inbox is public — including all repos as targets."
56+ visibility_flag=" "
57+ fi
58+
59+ echo " Fetching repos for org: $ORG "
60+ # shellcheck disable=SC2086
61+ repos=$( gh repo list " $ORG " \
62+ --no-archived \
63+ $visibility_flag \
64+ --json name \
65+ --limit 500 \
66+ --jq " [.[] | select(.name != \" $INBOX_REPO \" ) | .name]" )
67+
68+ echo " Repos found: $( echo " $repos " | jq length) "
69+
70+ # Fetch the label group and its children in one request.
71+ # Filter by name only — Linear does not support isGroup as a filter field.
72+ echo " Fetching label group '$LINEAR_GROUP ' and existing labels..."
73+ group_response=$( linear_post '
74+ {
75+ "query": "
76+ query($teamId: ID!, $groupName: String!) {
77+ issueLabels(filter: {
78+ team: { id: { eq: $teamId } }
79+ name: { eq: $groupName }
80+ }) {
81+ nodes {
82+ id
83+ name
84+ children {
85+ nodes {
86+ id
87+ name
88+ }
89+ }
90+ }
91+ }
92+ }
93+ ",
94+ "variables": {
95+ "teamId": $ENV.LINEAR_TEAM_ID,
96+ "groupName": $ENV.LINEAR_GROUP
97+ }
98+ }
99+ ' )
100+
101+ group_id=$( echo " $group_response " | jq -r ' .data.issueLabels.nodes[0].id // ""' )
102+
103+ if [ -z " $group_id " ]; then
104+ echo " Label group '$LINEAR_GROUP ' not found, creating..."
105+ create_response=$( linear_post '
106+ {
107+ "query": "
108+ mutation($name: String!, $teamId: String!) {
109+ issueLabelCreate(input: {
110+ name: $name
111+ isGroup: true
112+ teamId: $teamId
113+ color: \"#6e6e6e\"
114+ }) {
115+ success
116+ issueLabel {
117+ id
118+ name
119+ }
120+ }
121+ }
122+ ",
123+ "variables": {
124+ "name": $ENV.LINEAR_GROUP,
125+ "teamId": $ENV.LINEAR_TEAM_ID
126+ }
127+ }
128+ ' )
129+ group_id=$( echo " $create_response " | jq -r ' .data.issueLabelCreate.issueLabel.id' )
130+ # JSON array of {id, name} objects for existing labels in the group
131+ existing_labels=' []'
132+ echo " Created label group with id: $group_id "
133+ else
134+ existing_labels=$( echo " $group_response " | jq ' .data.issueLabels.nodes[0].children.nodes' )
135+ echo " Found label group id: $group_id "
136+ fi
137+
138+ echo " Existing label count: $( echo " $existing_labels " | jq length) "
139+
140+ export LINEAR_GROUP_ID=" $group_id "
141+
142+ # Delete labels in the group that no longer correspond to a repo
143+ deleted=0
144+
145+ while IFS= read -r label_id; do
146+ label_name=$( echo " $existing_labels " | jq -r --arg id " $label_id " ' .[] | select(.id == $id) | .name' )
147+ repo_name=" ${label_name# " ${LABEL_PREFIX} " } "
148+
149+ still_exists=$( echo " $repos " | jq -r --arg r " $repo_name " ' any(. == $r)' )
150+
151+ if [ " $still_exists " = " true" ]; then
152+ continue
153+ fi
154+
155+ echo " [delete] $label_name (repo '$repo_name ' not found)"
156+ LINEAR_LABEL_ID=" $label_id " linear_post '
157+ {
158+ "query": "
159+ mutation($id: String!) {
160+ issueLabelDelete(id: $id) {
161+ success
162+ }
163+ }
164+ ",
165+ "variables": {
166+ "id": $ENV.LINEAR_LABEL_ID
167+ }
168+ }
169+ ' > /dev/null
170+
171+ deleted=$(( deleted + 1 ))
172+
173+ done < <( echo " $existing_labels " | jq -r ' .[].id' )
174+
175+ # For each repo, ensure a corresponding Linear label exists
176+ created=0
177+ skipped=0
178+
179+ while IFS= read -r repo; do
180+ label_name=" ${LABEL_PREFIX}${repo} "
181+
182+ already_exists=$( echo " $existing_labels " | jq -r --arg n " $label_name " ' any(.[]; .name == $n)' )
183+
184+ if [ " $already_exists " = " true" ]; then
185+ echo " [skip] $label_name "
186+ skipped=$(( skipped + 1 ))
187+ continue
188+ fi
189+
190+ echo " [create] $label_name "
191+ LINEAR_LABEL_NAME=" $label_name " linear_post '
192+ {
193+ "query": "
194+ mutation($name: String!, $teamId: String!, $parentId: String!) {
195+ issueLabelCreate(input: {
196+ name: $name
197+ teamId: $teamId
198+ parentId: $parentId
199+ color: \"#6e6e6e\"
200+ }) {
201+ success
202+ issueLabel {
203+ id
204+ name
205+ }
206+ }
207+ }
208+ ",
209+ "variables": {
210+ "name": $ENV.LINEAR_LABEL_NAME,
211+ "teamId": $ENV.LINEAR_TEAM_ID,
212+ "parentId": $ENV.LINEAR_GROUP_ID
213+ }
214+ }
215+ ' > /dev/null
216+
217+ created=$(( created + 1 ))
218+
219+ done < <( echo " $repos " | jq -r ' .[]' )
220+
221+ echo " "
222+ echo " Done. Created: $created , Skipped: $skipped , Deleted: $deleted "
0 commit comments