Skip to content

Commit 30c2ff5

Browse files
committed
Add workflows
0 parents  commit 30c2ff5

5 files changed

Lines changed: 396 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Sync repo labels to Linear
2+
# Periodically keeps Linear's repo labels up-to-date for easy selection.
3+
4+
on:
5+
workflow_dispatch:
6+
schedule:
7+
# Runs nightly at 02:00 UTC
8+
- cron: "0 2 * * *"
9+
10+
jobs:
11+
sync:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Sync org repos to Linear labels
20+
env:
21+
GH_TOKEN: ${{ secrets.SYNC_PAT }}
22+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
23+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
24+
LABEL_PREFIX: "🐙 "
25+
LINEAR_GROUP: repo
26+
run: bash scripts/sync-linear-labels.sh
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Transfer issue to repo
2+
# When a new issue comes in, routes it to the correct repo.
3+
4+
on:
5+
issues:
6+
types: labeled
7+
8+
jobs:
9+
transfer:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
issues: write
13+
contents: read
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Find routing label and transfer
19+
env:
20+
GH_TOKEN: ${{ secrets.TRANSFER_PAT }}
21+
LABEL_PREFIX: "🐙 "
22+
ISSUE_NUMBER: ${{ github.event.issue.number }}
23+
ISSUE_TITLE: ${{ github.event.issue.title }}
24+
ISSUE_LABELS: ${{ toJson(github.event.issue.labels) }}
25+
run: bash scripts/transfer-issue.sh

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# linear-issue-test — Issue Inbox
2+
3+
This repo acts as a GitHub/Linear issue inbox. Issues filed here are
4+
automatically routed to the appropriate repo based on their label, and
5+
two-way synced with Linear via the native GitHub Issues Sync integration.
6+
7+
## How it works
8+
9+
1. An issue is created here (or arrives via Linear sync).
10+
2. A routing label is applied — either at creation or afterwards.
11+
3. A GitHub Action transfers the issue to the target repo.
12+
4. A stub comment is left on the original issue with a redirect note.
13+
5. Linear's sync follows the transferred issue to its new repo (one-way).
14+
15+
## Routing labels
16+
17+
Labels follow the format `🐙 repo-name`, where `repo-name` is the name of a repo
18+
in the org. These labels are kept in sync automatically (see below). In Linear,
19+
they live under the `repo` label group.
20+
21+
## GitHub Actions
22+
23+
### `sync-linear-labels.yml`
24+
25+
Runs nightly (and on demand via workflow_dispatch). For every non-archived repo
26+
in the org (excluding this one), it ensures a corresponding label exists in
27+
Linear under the `repo` group, and a matching `🐙 repo-name` label exists in
28+
this repo.
29+
30+
**Secrets required:**
31+
32+
| Secret | What it is |
33+
|--------|-----------|
34+
| `LINEAR_API_KEY` | A Linear personal API key with Read and Write scopes (Settings → Security & Access → Personal API Keys) |
35+
| `SYNC_PAT` | A GitHub PAT with `read:org` scope, to list org repos |
36+
37+
### `transfer-issue.yml`
38+
39+
Triggers on `issues: [labeled, opened]`. When an issue has a `🐙 repo-name`
40+
label, it transfers the issue to that repo and leaves a redirect comment.
41+
42+
**Secrets required:**
43+
44+
| Secret | What it is |
45+
|--------|-----------|
46+
| `TRANSFER_PAT` | A GitHub PAT with `repo` scope (the default `GITHUB_TOKEN` cannot transfer issues across repos) |
47+
48+
> **Note:** Both workflows use separate PATs so you can scope permissions
49+
> independently. You can use the same PAT for both if you prefer.
50+
51+
## Setup checklist
52+
53+
- [ ] Create the repo and enable Issues
54+
- [ ] In Linear: Settings → Integrations → GitHub → GitHub Issues → **+**,
55+
select this repo, choose **Two-way** sync, link to your inbox team
56+
- [ ] Create a Linear personal API key and add it as `LINEAR_API_KEY` secret
57+
- [ ] Create a GitHub PAT (`repo` + `read:org`) and add as `SYNC_PAT`
58+
- [ ] Create a GitHub PAT (`repo`) and add as `TRANSFER_PAT`
59+
- [ ] Add both workflow files to `.github/workflows/`
60+
- [ ] Run **Sync repo labels to Linear** manually once to bootstrap labels
61+
- [ ] For each other repo you want as a target: in Linear, add a one-way
62+
GitHub Issues Sync connection (Settings → Integrations → GitHub →
63+
GitHub Issues → **+**, choose **One-way**)
64+
65+
## Caveats
66+
67+
- **Label must exist in the target repo** for it to survive the transfer.
68+
GitHub drops labels that don't exist at the destination. The routing label
69+
itself (`🐙 repo-name`) only needs to exist in this inbox repo — Linear
70+
creates it here automatically via the GitHub Issues Sync.
71+
- **Only newly created issues sync** via Linear's GitHub Issues Sync.
72+
Existing issues must be imported manually if needed.

scripts/sync-linear-labels.sh

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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

Comments
 (0)