01 / apply it
yardr pack apply linearyardr carries this pack in its binary; the name is enough. yardr pack check linear shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/linear/From this site, as the files below.
02 / the files
Every file, as it ships.
# Linear as a pack of kind source: the integration, imported into the yard
# and into no depot. What Linear is: how its webhooks are verified and
# read, and how the yard replies, opens a thread and sets a status in it.
# What a stage means there is the flow pack's to say (sources.<name>:
# hears); until the yard has this source, an import of that pack skips the
# lines, and the next apply sets them.
#
# yardr pack apply linear
#
# The source is named linear: yardr knows Linear as this pack alone. The
# apply folds what the yard recorded under linear-declared into linear.
name: linear
kind: source
setup: SETUP.md
sources:
linear:
declaration: linear.yaml
#!/bin/sh
# A verified Linear webhook to the yard's envelope, as Linear{} makes it:
# stdin {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
# internal/hooks feeds it the Linear bodies its tests already have and
# compares the envelopes with Linear{}.Normalize's.
exec jq -c '
def first_of(f): [f | select(. != null and . != "")] | .[0] // "";
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. (\s
# here is ASCII space where Go splits on every unicode space; the
# envelopes differ only for a text with an exotic space in it.)
def trunc: (gsub("^\\s+|\\s+$"; "") | gsub("\\s+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
def ordered: reduce .[] as $x ([]; if index($x) then . else . + [$x] end);
def mentions($md; $doc):
( [ $doc | select(. != null) | (if type == "string" then (try fromjson catch null) else . end)
| .. | objects | select(.type == "suggestion_userMentions") | select(.attrs.id | type == "string") | .attrs.id ]
+ [ ($md // "") | scan("https://linear\\.app/[^/\\s]+/profiles/([A-Za-z0-9][A-Za-z0-9._-]*)")
| .[0] | ascii_downcase | sub("\\.+$"; "") ]
+ [ ($md // "") | gsub("(?s)```.*?(```|$)|`[^`\n]*`"; " ")
| scan("(?:^|[^A-Za-z0-9_/])@([A-Za-z0-9][A-Za-z0-9._-]*)")
| .[0] | ascii_downcase | sub("[.,:;!?]+$"; "") ]
) | ordered;
def fields($i):
( { team: [$i.team.key, $i.team.name],
project: [$i.project.name],
label: [$i.labels[]?.name] }
| map_values(map(select(. != null and . != ""))) | with_entries(select(.value | length > 0)) )
| if length == 0 then null else . end;
def changed($u):
( ($u | if type == "object" then . else {} end) | [keys[] | select(. != "updatedAt")] | sort | join(", ") ) as $ks
| if $ks == "" then "" else ("changed " + $ks | trunc) end;
.body as $p | ($p.data // {}) as $d
| { source: "linear", type: (($p.type // "") | ascii_downcase), action: ($p.action // ""), url: ($p.url // ""),
actor: ( if $p.actor != null then {id: ($p.actor.id // ""), name: ($p.actor.name // "")}
elif $d.user != null then {id: ($d.user.id // ""), name: ($d.user.name // "")}
else {id: ($d.userId // ""), name: ""} end ) }
| if .type == "comment" then . +
{ thread: ( if $d.issue != null
then {id: ($d.issue.id // ""), title: ($d.issue.title // ""),
url: (if ($d.issue.url // "") == "" then ($p.url // "" | sub("#.*$"; "")) else $d.issue.url end)}
else {id: (if ($d.issueId // "") == "" then ($d.id // "") else $d.issueId end),
title: "", url: ($p.url // "" | sub("#.*$"; ""))} end ),
text: (($d.body // "") | trunc), message: ($d.id // ""), parent: ($d.parentId // ""), entity: ($d.id // ""),
mentions: mentions($d.body; $d.bodyData), fields: fields($d.issue // {}) }
elif .type == "issue" then . +
{ thread: { id: ($d.id // ""), title: ($d.title // ""),
url: (if ($d.url // "") == "" then ($p.url // "") else $d.url end) },
assignee: first_of($d.assigneeId, $d.assignee.id),
delegate: first_of($d.delegateId, $d.delegate.id),
text: ( if $p.action == "update" then changed($p.updatedFrom) else (($d.description // "") | trunc) end ),
entity: ($d.id // ""), mentions: mentions($d.description; null), fields: fields($d) }
else . + { thread: { id: ($d.id // ""), title: ($d.title // ""), url: ($p.url // "") }, mentions: [] }
end
# sender is who did it, the id the envelope names as actor: the hear key
# matches it against the user identities the yard holds in linear, and the
# core stamps speaker from the match.
| (.actor.id // "") as $sender | if $sender != "" then .fields = ((.fields // {}) + {sender: [$sender]}) else . end
'
#!/bin/bash
# thread: what the yard does in a Linear thread: reply, posted, read, open,
# opened, state. One JSON object on stdin, one on stdout, as the thread key
# of the declaration says. LINEAR_API_KEY (a personal key, sent bare, or
# anything else, sent as a Bearer token) and YARDR_SOURCE_BASE, the
# declaration's base, are in the environment. Needs curl and jq.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }
case "$LINEAR_API_KEY" in
lin_api_*|"Bearer "*) auth="$LINEAR_API_KEY" ;;
*) auth="Bearer $LINEAR_API_KEY" ;;
esac
# raw <query> <variables as JSON>: the whole GraphQL answer. The
# credential goes to curl as a config line on its stdin, written by the
# shell itself: an argument can be read by every process on the machine
# while curl runs.
raw() {
printf 'header = "Authorization: %s"\n' "$auth" \
| curl -sS --fail-with-body -K - "$YARDR_SOURCE_BASE/graphql" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg q "$1" --argjson v "$2" '{query: $q, variables: $v}')"
}
# gql <query> <variables as JSON>: the data of the answer, or exit 1 with
# Linear's errors on stderr.
gql() {
local out
out=$(raw "$1" "$2")
jq -e '.errors | not' <<<"$out" >/dev/null || { jq -c .errors <<<"$out" >&2; exit 1; }
jq -c .data <<<"$out"
}
# missing: whether the answer on stdin is only that Linear has no such
# entity: what posted and opened answer {} to.
missing() {
jq -e '[.errors[]?.message // empty | select(contains("Entity not found"))] | length > 0' >/dev/null
}
case "$1" in
reply) # the key is the comment's own id: Linear takes ours, so a retry finds it
gql 'mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id url } } }' \
"$(jq -c '{input: ({id: .key, issueId: .thread, body: .body} + (if .parent != "" then {parentId: .parent} else {} end))}' <<<"$in")" \
| jq -c 'if .commentCreate.success then .commentCreate.comment else error("commentCreate did not succeed") end' ;;
posted) # {} for none: a retry asks before it posts, so nothing is posted twice
resp=$(raw 'query($id: String!) { comment(id: $id) { id url issue { id } } }' "$(jq -c '{id: .key}' <<<"$in")")
if jq -e '.errors | not' <<<"$resp" >/dev/null; then
jq -c --arg t "$(arg thread)" '.data.comment | if . != null and .issue.id == $t then {id, url} else {} end' <<<"$resp"
elif missing <<<"$resp"; then
printf '%s' '{}'
else
jq -c .errors <<<"$resp" >&2; exit 1
fi ;;
read) # every page, which a declared call cannot follow
cursor=null; all='[]'
while :; do
page=$(gql 'query($id: String!, $c: String) { issue(id: $id) { comments(first: 100, after: $c) {
nodes { id body createdAt url user { id name } botActor { id name } parent { id } } pageInfo { hasNextPage endCursor } } } }' \
"$(jq -c --argjson c "$cursor" '{id: .thread, c: $c}' <<<"$in")")
all=$(jq -c --argjson a "$all" '$a + .issue.comments.nodes' <<<"$page")
[ "$(jq -r .issue.comments.pageInfo.hasNextPage <<<"$page")" = true ] || break
cursor=$(jq -c .issue.comments.pageInfo.endCursor <<<"$page")
done
jq -c '{messages: [sort_by(.createdAt)[] | {id, parent: (.parent.id // ""), author: ((.user // .botActor // {}) | {id: (.id // ""), name: (.name // "")}), at: .createdAt, body, url: (.url // "")}]}' <<<"$all" ;;
open) # the team is given by key or name; the mutation wants its id. An assignee is a user's id
team=$(gql 'query($t: String!) { teams(filter: {or: [{key: {eq: $t}}, {name: {eq: $t}}]}) { nodes { id } } }' \
"$(jq -c '{t: .in}' <<<"$in")" | jq -r '.teams.nodes[0].id // ""')
[ -n "$team" ] || { echo "no team $(arg in)" >&2; exit 1; }
gql 'mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id title url } } }' \
"$(jq -c --arg t "$team" '{input: ({id: .key, teamId: $t, title: .title} + (if .body != "" then {description: .body} else {} end)
+ (if (.assignee // "") != "" then {assigneeId: .assignee} else {} end))}' <<<"$in")" \
| jq -c 'if .issueCreate.success then .issueCreate.issue else error("issueCreate did not succeed") end' ;;
opened) # posted's sibling for an open whose answer was lost: the key is the issue's own id
resp=$(raw 'query($id: String!) { issue(id: $id) { id title url } }' "$(jq -c '{id: .key}' <<<"$in")")
if jq -e '.errors | not' <<<"$resp" >/dev/null; then
jq -c '.data.issue // {}' <<<"$resp"
elif missing <<<"$resp"; then
printf '%s' '{}'
else
jq -c .errors <<<"$resp" >&2; exit 1
fi ;;
state) # started, review or done: each team has its own statuses, so pick one by type
cur=$(gql 'query($id: String!) { issue(id: $id) { state { id name type position }
team { states { nodes { id name type position } } } } }' "$(jq -c '{id: .thread}' <<<"$in")")
want=$(jq -c --arg s "$(arg state)" '
.issue.team.states.nodes | sort_by(.position) as $all
| if $s == "started" then [$all[] | select(.type == "started")][0]
elif $s == "review" then ([$all[] | select(.type == "started" and (.name | test("review"; "i")))][0]
// [$all[] | select(.type == "started")][0])
elif $s == "done" then [$all[] | select(.type == "completed")][0]
else null end' <<<"$cur")
[ "$want" != null ] || { echo "this team has no status for $(arg state)" >&2; exit 1; }
was=$(jq -r .issue.state.name <<<"$cur")
if [ "$(arg forward)" = true ]; then
# a stage map's move, which never moves backwards: an issue at or past
# the status stays where it is; one canceled, or in a status of an
# unknown type, is past every status
rank='{"triage":0,"backlog":1,"unstarted":2,"started":3,"completed":4,"canceled":5}'
if jq -e --argjson w "$want" --argjson r "$rank" \
'.issue.state as $c | ($r[$c.type] // 5) > $r[$w.type] or ($c.type == $w.type and $c.position >= $w.position)' \
<<<"$cur" >/dev/null; then
jq -c '{status: .issue.state.name, moved: false}' <<<"$cur"; exit 0
fi
elif jq -e --argjson w "$want" '.issue.state.id == $w.id' <<<"$cur" >/dev/null; then
# thread state moves exactly: only an issue already in the status stays
jq -c '{status: .issue.state.name, moved: false}' <<<"$cur"; exit 0
fi
gql 'mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { issue { state { name } } } }' \
"$(jq -c --argjson w "$want" '{id: .thread, input: {stateId: $w.id}}' <<<"$in")" \
| jq -c --arg was "$was" '{status: .issueUpdate.issue.state.name, moved: true, was: $was}' ;;
*) echo "unknown: $1" >&2; exit 2 ;;
esac
# Linear as a declared source, from the sketch
# (origin/sketch/source-in-a-pack:docs/design/source-in-a-pack/linear-pack):
# how the core verifies a Linear delivery, and the command that turns a
# verified body into the envelope; what the yard does in a Linear thread,
# by one command per thing; and Linear's issue URLs as a bead's link.
# internal/hooks holds it to golden files: the envelopes for the bodies
# its tests have, and what the stand-in receives for reply, posted,
# read, open and state.
#
# The name is linear: the pack is what yardr knows of Linear.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
name: linear
about: Linear issues and comments. Hears its webhooks and speaks in its threads.
# The name of the variable, never the value. A command gets it in its
# environment under this name, and gets no other variable of the yard.
# hook.normalize is handed no credential at all.
#
# The header is how a declared call, one of the calls below, sends it.
# Without one those calls go out with no credential at all. Linear takes
# an OAuth access token as a Bearer token, and the yard's own is an OAuth
# application's token, so Bearer is the form here. Linear's authentication
# docs, at linear.app/developers/graphql, show a personal API key sent
# bare, as "Authorization: <API_KEY>", and do not say that Bearer takes one
# too. A declared call therefore wants an OAuth token. linear/thread builds
# its own header, a lin_api_ key bare and anything else as Bearer, and
# takes either.
credential:
env: LINEAR_API_KEY
header: "Authorization: Bearer {credential}"
# ---- what it sends us ----------------------------------------------------
hook:
# Adapter.Verify stays data, checked by the core: the signing secret is
# handed to no command, and an unverified body reaches none.
verify:
scheme: hmac-sha256
signed: body
signature: { header: Linear-Signature, encoding: hex }
delivery: { header: Linear-Delivery }
timestamp: { body: webhookTimestamp, unit: ms, window: 1m }
bodies: unique
# Linear's deliveries follow issue activity, not a rhythm, so silence is never quiet.
untimed: true
# Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
# envelope. The core sets source and delivery itself.
normalize: [linear/normalize]
# The keys its events' fields may carry: a subscription's --where may
# name them beside type, action, team, project, repo and label. sender
# is the Linear user id of who did it, the field the hear key below
# matches.
keys: [team, project, label, sender]
# ---- who is heard ---------------------------------------------------------
# A delivery is kept with its speaker named where the sender is a user of
# the yard, matched by identity: fields.sender holds the Linear user id,
# which normalize also sets as actor.id. The core adds fields.speaker for
# a kept delivery (the yard user's name, or outside where the hook's
# --unknown word allows an unmapped sender); speaker is the derived
# filter key for this hear, and stands in no hook.keys above, which names
# only what normalize may set.
hear:
field: sender
by: identity
# ---- what the yard does in its threads ------------------------------------
# One command per thing, each handed its object on stdin and the
# credential under LINEAR_API_KEY, with YARDR_SOURCE_BASE from base below.
# linear/thread is the sketch's script: reply posts the body's comment with
# the key as its own id, posted finds what a key left ({} for none), read
# lists the thread's messages oldest first, open starts an issue in the
# team in, opened finds what an open left ({} for none: posted's sibling
# for open), and state moves to what the word means: exactly, or never
# backwards with forward (a stage map's move). The shapes are in
# docs/integrations.md.
thread:
reply: [linear/thread, reply] # in {thread, parent, body, key} out {id, url}
posted: [linear/thread, posted] # in {thread, key} out {id, url} or {}
read: [linear/thread, read] # in {thread} out {messages: [...]}
place: team # what open's in names
open: [linear/thread, open] # in {in, title, body, key, assignee?} out {id, title, url}
assignee: id # the assignee open takes: a user's id
opened: [linear/thread, opened] # in {in, key} out {id, title, url} or {}
state: [linear/thread, state] # in {thread, state, forward?} out {status, moved, was?}
states: [started, review, done] # the words it has a status for
# ---- its issues as a bead's link ------------------------------------------
# The host its issue URLs are on, what reads an issue from a URL's path,
# and over the groups: the issue's one fixed form, and the id a thread
# command takes for it.
link:
host: linear.app
issue: '^/([^/]+)/issue/([A-Z][A-Z0-9]*-[0-9]+)'
fixed: "https://linear.app/{1}/issue/{2}"
thread: "{2}"
# ---- (today) what an agent may ask of it, through `yardr call` ------------
# Unchanged, and separate from the above: declared HTTPS requests, granted
# one by one. A command is never something an agent calls.
base: https://api.linear.app
calls:
viewer:
kind: read
request: { method: POST, path: /graphql, body: { query: "{ viewer { id name } }" } }
check: viewer
# Setting up Linear
The pack wires Linear into the yard as the declared source `linear`: its
webhooks are verified and read, and the yard replies, opens issues and
sets their status through the commands the pack carries. What a stage
means in Linear is a flow pack's to say, not this file's.
1. **The webhook, in Linear.** Settings → API → Webhooks: add one with the
URL `https://<public host>/hooks/<hook name>` — the path ends in the
hook's name, the one `yardr hook add` is given below — and tick the
events Issues and Comments. The yard reads issue and comment deliveries
only; anything else it cannot use.
2. **The hook and its secret.** The webhook's signing secret goes in a file,
mode 0600, which the hook names. A new file takes its mode from the
shell's umask, so narrow it first. The secret is never a command's
word: that line would stay in the shell's history. Each command below
is one thing to type, in a block of its own; run the second, type or
paste the secret blind and press Enter:
```sh
umask 077
```
```sh
IFS= read -rs v && printf '%s' "$v" > ~/.yardr/linear.secret; unset v
```
```sh
chmod 600 ~/.yardr/linear.secret
```
```sh
yardr hook add linear --source linear --secret-file ~/.yardr/linear.secret --unknown allow
# hook linear saved: POST /hooks/linear (linear)
```
One hook serves every crew member. `--unknown allow` hears every
workspace member's comment: a delivery from a sender no yard user maps
is kept with the speaker `outside`. Give each person their identity
(step 6) and their deliveries arrive named as them, which a
subscription may filter with `--where speaker=<user>`.
A crew member sets the hook up without handling the secret, by two
asks on one bead, the secret first: it asks for the signing secret
(`yardr bead ask <id> "…" --secret-hook linear --source linear
--context "…"`) and then for the hook itself
(`yardr bead ask <id> "…" --run "hook add linear --source linear --unknown allow"
--context "…"`). The operator pastes the secret where Linear shows
it, typed unseen; the yard's server writes it to
`<home>/hooks/linear.secret` (mode 0600), which the hook then names,
and it is in no note or pane. Approving the hook then makes
`POST /hooks/linear`. `--map` and `--as`, and replacing a hook, stay
in a terminal; the manual steps above remain available.
3. **The crew member's key.** `LINEAR_API_KEY` in an env file of the crew
member's own, mode 0600, never in the store. Have the crew member ask
for it, and answer on its card, in footplate or in `yardr chat`
(`ctrl+o`), where the field shows dots:
```sh
yardr bead ask <bead> "The token it answers with" --secret linear --for-crew <name> --context "The token of the Linear OAuth application made for it."
```
The yard's server writes it into the crew member's env file, or with
none set into `~/.yardr/crew/<name>.env`, which is then set as `yardr
crew set` sets it. The running session does not see the new value:
restart the crew member after giving it. By hand it is yours instead.
A new file takes its mode from the shell's umask, so narrow it first.
The token is never a command's word: that line would stay in the
shell's history. Each command below is one thing to type, in a block
of its own; run the second, type or paste the token blind and press
Enter:
```sh
umask 077
```
```sh
IFS= read -rs v && printf 'LINEAR_API_KEY=%s' "$v" > ~/.yardr/crew/<name>.env; unset v
```
```sh
chmod 600 ~/.yardr/crew/<name>.env
```
```sh
yardr crew set <name> env_file=~/.yardr/crew/<name>.env
```
The token is that of a Linear OAuth application made for the crew
member, so it acts as the application's own user and not as a person.
Linear takes an OAuth token as `Authorization: Bearer <token>`, and the
pack sends it that way. A personal key makes the crew member speak as
that person. Linear's docs (https://linear.app/developers/graphql) show
one sent bare, as `Authorization: <key>`, and do not say that Bearer
takes it too. The thread commands send a `lin_api_` key bare, so they
work with either token. The declared calls (`yardr call linear viewer`,
`yardr source check linear`) send every key as Bearer and read it from
the source's own env file (`yardr source key linear`), so put an OAuth
token there. Restart the crew member after changing the file.
4. **Its identity.** The id and the profile key of the user the token acts
as, looked up in Linear by hand — both:
```sh
yardr crew identity <name> --source linear --id <user id> --key <handle>
```
A crew member setting itself up proposes it instead, from its session,
and the operator approves it on the card:
```sh
yardr bead ask <bead> "Who am I in Linear?" --run "crew identity <name> --source linear --id <user id> --key <handle>"
```
A mention is matched by the handle when the delivery carries markdown
only, and by the id otherwise: with the id alone, the test of 11 Oct
saw a mention arrive as `participant` instead of `mentioned`.
5. **The grants.** Opening issues and setting their status are the
operator's to grant:
```sh
yardr crew set <name> open_threads=linear set_state=linear
```
6. **Test it.** As the crew member: open an issue, mention it from Linear,
move it:
```sh
yardr thread open linear --in <team key> - -b - # stdin: the title (Hello from the yard), then the body (testing the wiring)
# comment @<handle> on the issue in Linear: it lands in the crew member's inbox as mentioned
yardr thread state <thread id> started
```
`yardr thread list <name>` shows the thread's id.
To open an issue assigned to a person, give the yard's user their
Linear user id once, then name them:
```sh
yardr user identity <user> --source linear --id <their user id>
yardr thread open linear --in <team key> --assign <user> - # stdin: the title (For you)
```
The same identity names who spoke on their deliveries: a comment or an
issue from them arrives with them as its speaker, and a subscription
may filter `--where speaker=<user>`. An id names one user: one that
another user already holds is refused.
7. **The stage map.** When a bead enters a stage, the issue it tracks under
this source moves with it: the hook carries the map, and the crew
member it acts as. `hook add` replaces the definition, so the whole
map is given each time, with `--unknown allow` again:
```sh
yardr hook add linear --source linear --secret-file ~/.yardr/linear.secret --unknown allow --as <name> \
--map task:new=started --map task:review=review --map task:merged=done
# moves tracked linear issues as <name>: task:new=started …
```