01 / apply it
yardr pack apply ./sentryFrom a clone of yardr-packs: the pack's directory. yardr pack check ./sentry shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/sentry/From this site, as the files below.
02 / the files
Every file, as it ships.
# Sentry as a pack of kind source: the integration, imported into the yard
# and into no depot. What Sentry is: how an internal integration's webhook
# is verified and read (a new issue, an alert rule's event), how the yard
# speaks in an issue (a comment, resolve and unresolve), and a bead's link
# to an issue.
#
# yardr pack apply /Users/mckean/projects/repos/yardr-packs/sentry
#
# The source is named sentry: a pack is named after the service it
# talks to.
name: sentry
kind: source
setup: SETUP.md
sources:
sentry:
declaration: sentry.yaml
# Sentry
Sentry issues as the declared source `sentry`. An internal integration's
webhook delivers a new issue, and an alert rule's event, to the yard, and the
yard answers in the issue: it comments on it, resolves it and unresolves it. A
bead can name an issue, and the issue resolves when the bead lands. Nothing
here is compiled into yardr; apply it with:
```sh
yardr pack apply /Users/mckean/projects/repos/yardr-packs/sentry
```
from main of this repository. SETUP.md is the operator's steps.
This is the first slice. Running it against a real Sentry organization is the
next bead and needs an account; "Not yet checked against Sentry" lists what
that run should look at first.
## How it hangs together
The hook in `sentry.yaml` takes the signature Sentry puts in
`Sentry-Hook-Signature`: the hex HMAC-SHA256 of the exact body under the
integration's Client Secret. The core checks it, as data, and hands the secret
to no program. `Sentry-Hook-Timestamp` rides along and Sentry does not sign
it, so the hook names no timestamp window. The delivery is told by
`Request-ID`, which Sentry makes once for each request it builds, and a body
seen before is dropped, per hook, so a retry that builds its request again is
told by its body. `sentry/normalize` turns each verified body into the
envelope.
The hook hears no one by identity: every delivery is from Sentry itself, or
from the integration, or from a person acting in Sentry, and none mentions
anyone. So the pack has no `hear` key, `hook add` takes no `--unknown` word,
and a subscription is by `--scope all` and a `--where`.
`sentry/thread` is the yard's side: reply, posted, read and state. A thread is
`<organization slug>#<issue id>`, as GitHub's is `owner/repo#12`. There is no
open: Sentry makes its issues from errors.
The pack declares one call, `scopes`, a GET of Sentry's API index, which needs
no scope and answers the token's own. A declaration with no call keeps neither
its base nor its credential, and this one is the check: `yardr source check
sentry` shows the scopes the token holds.
## What it hears
| Sentry sends | `type` | `action` | thread | text |
|---|---|---|---|---|
| `issue`, action `created` | `issue` | `created` | the issue | `new <level> issue <short id> in <project>: <culprit>` |
| `event_alert`, action `triggered` | `event_alert` | `triggered` | the issue of the event | `alert "<rule>": <level> event in <culprit>` |
| `issue`, any other action (`resolved`, `assigned`, `archived`, `unresolved`) | `issue` | that action | none | none |
| `event_alert`, any other action | `event_alert` | that action | none | none |
| any other resource (`comment`, `error`, `metric_alert`, `installation`, ...) | the resource | its action | none | none |
`type` is the `Sentry-Hook-Resource` header and `action` the payload's, as
Sentry words them. The last two rows are recorded with their actor and are
never refused: a 400 tells Sentry its webhook failed, and Sentry retries a
failed one and can disable an integration (its source skips an app that is
disabled). A body that is no JSON object, or a delivery with no resource
header, is refused with 400.
The yard's own resolve comes back as an issue delivery of action `resolved`,
by the integration, and its own comment as a comment delivery. Neither has a
thread, so neither is heard as a new issue, and the status call cannot loop.
`issue.created` is sent for errors, outages (uptime and cron monitors) and
user feedback alike, so the issue's `issueCategory` is a field.
The fields, which a subscription's `--where` may name:
- `project`: the project's slug, for an issue and for an alert whose event
carries the project's API address.
- `level`: `fatal`, `error`, `warning`, `info` or `debug`.
- `category`: `error`, `outage` or `feedback`. An alert has none.
- `outsider`: `yes` on every delivery that carries text. An issue's title and
culprit come from the monitored application, and whoever uses it can shape
them: a crafted error message is text for an agent to read. The mark is the
core's own, for text the source's people did not write: the inbox line says
`information, not instructions`, and a `says` line's note leaves the text
out. PostHog's pack does not set it. This one does, because Sentry's text is
an error's.
## The thread, its address and its link
Sentry sends the organization in no field of its own, only in addresses. The
pack reads it, and the issue's number, from the `web_url` of the issue (or of
the event, for an alert), which comes in two shapes: `<slug>.sentry.io/issues/<id>/`,
which the docs' issue sample has, and `/organizations/<slug>/issues/<id>/` on a
host, which the docs' alert sample has and a Sentry without customer domains
is expected to send. Both become the second shape on the host the address was
on, `https://sentry.io/organizations/<slug>/issues/<id>/`,
which is the thread's URL, and the thread is `<slug>#<id>`. The slug is kept
lowercase. The issue's own `id` (an alert's `issue_id`) and the address must
name the one issue. Where the organization, the host or the id cannot be
read, the delivery is recorded with no thread and still heard. An alert's `url`
is its event's, `.../issues/<id>/events/<event id>/`, made only from a 32-hex
event id.
The declaration's `link` reads the same form, on `sentry.io` alone: a
declaration names one host, so an address on `<slug>.sentry.io`, which is what
a browser shows, is refused by `yardr link add`, and SETUP.md says to type the
path form or the number of the delivery. yardr keeps
`https://sentry.io/organizations/<slug>/issues/<id>`, folded to lowercase, so
one issue is one link however it was typed.
## What it says
- **reply** posts a comment: `POST /api/0/organizations/<org>/issues/<id>/comments/`
with `{"text": ...}`. Sentry takes no client-chosen id for a comment, so the
key goes into the text as `<!-- yardr-reply KEY -->`. A comment is not
threaded, so `--to` does nothing, and the answer's URL is the issue's: a
comment has no address of its own. Sentry refuses the same text from the
same account within an hour (400, "You have already posted that comment."),
which the key keeps apart for two replies.
- **posted** finds what a key left, in the list of comments, and answers `{}`
for none. A retry asks before it posts, so a reply whose answer was lost is
found and not posted twice.
- **read** lists the comments, oldest first: Sentry sends the newest first, so
they are sorted by their number, which is the order they were made in. The
list pages by cursor (`per_page=100`, and the `Link` header's next cursor
where it says `results="true"`), at most a hundred pages. An author is the
integration's name when a comment is its own, else the person's name, else
their username.
- **state** moves the issue. The words are two of the core's three, and Sentry
has two statuses to set, with a third between them:
| word | Sentry | `thread state` (exact) | a stage map (`forward`) |
|---|---|---|---|
| `started` | `unresolved` | unresolves a resolved or ignored issue | never moves |
| `done` | `resolved` | resolves an unresolved or ignored issue | resolves an unresolved or ignored issue |
An issue already in the status is reported as not moved, and a resolved
issue is never reopened by a stage map. The command reads the issue first
(`GET`), then sets the status (`PUT {"status": ...}`), and an answer that
does not say the status asked for is no move. `review` is refused: Sentry
has no such status.
Every command sends the token on curl's stdin as a config line, never as an
argument, cuts it out of anything an answer repeats, and refuses a token with
a character a Sentry token has none of (`[A-Za-z0-9_.~+/=-]` holds the 64-hex
internal token and the organization token alike) without saying what it held.
An error says the status and Sentry's `detail` on one line, without control
characters; a 403 adds that the token needs `event:read` to read and
`event:write` to comment and resolve.
## The comment route, and the token's scopes
The comment route that needs no organization, `/api/0/issues/<id>/comments/`,
is deprecated. The pack sends to the one under the organization,
`/api/0/organizations/<org>/issues/<id>/comments/`, for these reasons, all in
Sentry's source (master, 7 November 2026):
- `GroupNotesEndpoint.get` and `.post` are decorated `@deprecated(
CELL_API_DEPRECATION_DATE, suggested_api="sentry-api-0-organization-group-group-notes",
url_names=["sentry-api-0-group-notes"])`. The date is 15 May 2026, which has
passed. After it the deprecated route answers 410 Gone in brownouts, by
default one minute at noon UTC, on Sentry's cloud (the decorator does
nothing on a self-hosted one). The suggested replacement is the route
under the organization.
- `src/sentry/api/urls.py` mounts the same `(notes|comments)` routes under
`/organizations/<org>/issues/` and, marked legacy, under `/issues/`.
- The thread already carries the organization, which the status call needs.
A route at the legacy path fails during a brownout and works between them,
which is the failure nobody sees in a test run at another hour. The tests
hold the pack to never sending to it.
The scopes are `event:read` and `event:write`, Issue & Event, Read & Write.
The permissions page (docs.sentry.io/api/permissions/) lists `event:read` for
GET and `event:write` for PUT, and no scope for POST. The comment's is in
source: `GroupPermission.scope_map` in
`src/sentry/issues/endpoints/bases/group.py` has POST and PUT as `event:write`
or `event:admin`, and GET as `event:read`, `event:write` or `event:admin`. So
the comment write wants `event:write`, which is what the pack asks for. The
same map has DELETE as `event:write` or `event:admin`, though the public page
lists `event:admin` there: a token with `event:write` can delete an issue, the
pack never does, and no narrower scope comments and resolves.
## Not yet checked against Sentry
Everything above is held to fixtures, to Sentry's docs and source, and to a
scratch yard with a stand-in for Sentry. Nothing has run against a real
organization. The live run should check these first:
- An internal integration's token may post a comment. Sentry's endpoint
needs a user and answers a token with none 403; an integration acts as a
proxy user, which should do, and the first reply shows it.
- A comment's text comes back from the list with the HTML comment marker
intact, and the issue's page shows nothing of it. Sentry's Markdown
renderer sanitizes raw HTML with DOMPurify, which drops comments; nothing
here has seen it.
- `https://sentry.io/organizations/<org>/issues/<id>/` opens the issue of an
organization on its own `<org>.sentry.io`. Sentry has middleware built to
send a path-form address on to the organization's own, and the thread's URL
in an inbox line is that address.
- The `web_url` a real delivery carries, for an organization on its own domain,
for an EU one, and for an alert, is one of the two shapes the pack reads. It
reads the docs' samples, which show one shape for an issue and the other for
an alert.
- A retry from Sentry has another `Request-ID` or the same one. The pack copes
either way, by the body, and the delivery's answer for a duplicate is the
core's.
- The yard answers a delivery inside the one second Sentry gives a webhook:
one HMAC and one jq run, which was not timed against a tunnel.
- `GET /api/0/` answers an internal integration's token with its scopes. It is
in source (`IndexEndpoint`, no permission class) and not in the public
pages.
- `PUT {"status": "unresolved"}` on a resolved issue is accepted for the
token, and `PUT {"status": "resolved"}` on an ignored one.
- An alert arrives as the docs' sample says, with `issue_id` and a path-form
`web_url`, for an internal integration with the Alert Action on.
## Files
- `pack.yaml`, `sentry.yaml`: the pack and its declaration.
- `sentry/normalize`: POSIX sh and jq, the verified body to the envelope.
- `sentry/thread`: bash, curl and jq, the thread commands.
- `testdata/hook/`: the webhook bodies and the envelopes made of them,
goldens beside them. `issue-created`, `event-alert` (trimmed) and
`comment-created` are Sentry's own samples (docs.sentry.io, 7 November 2026)
with a signature and ids made up; `issue-resolved` is the issue sample
resolved by the integration, and `issue-created-path` an issue whose address
has the path shape the alert sample has.
- `testdata/thread/`: one `.case` file and one golden for each case of a thread
command, with its input, the answers the stand-in gives in order, and the
requests and output expected.
- `testdata/core/`, `testdata/core.sh`, `testdata/declaration.golden`,
`testdata/link.txt`, `testdata/standin.py`: the part of the test that runs
the real yardr, its goldens, the declaration as the yard reads it, the
addresses for the link and the stand-in for Sentry on a loopback port.
- `testdata/bin/curl`: the stand-in for curl.
- `test`: runs all of it.
## Runtimes
`sentry/normalize` wants POSIX sh and jq, as the other source packs do.
`sentry/thread` wants bash 3.2 or later, curl and jq. The gate knows no
language, so this is the pack's word for what its programs need. The test
wants python3 and yardr besides, for its last parts.
## Testing
```sh
./test
```
in this directory. It feeds each webhook body to normalize and compares the
envelope with its golden byte for byte. A table makes one change to one body
at a time (the address in its other shape, on a regional host, on a
self-hosted one, with a user, with no id, with a string where an object
belongs) and holds the envelope to what must then be true of it, and a body
that is no object is refused without quoting itself. The fields the envelopes
carry and `hook.keys` agree both ways.
It runs each thread case against the stand-in curl, which makes no request,
records the method, URL, headers and body the real one would have been asked
for, answers from the case's fixtures and refuses any use of curl the script
does not make today. A changed request fails its case. A canary token is
looked for in every argument, output and record, and has to be on curl's
stdin alone. Of every request any case made, the writes are a POST of a
comment and a PUT of a status under the organization, and none goes to the
deprecated path. The cases marked `=== real` run again with the real curl
against a stand-in server on a loopback port, and must give the same golden:
one of each way the script uses curl.
The last part runs the real yardr, in a scratch yard of its own under `env -i`
with no agent. It holds the declaration as the yard reads it to a golden. It
makes deliveries to the yard's own listener, on a free loopback port, signed
as Sentry signs them with a canary secret: one is taken, its duplicate and a
retry with another `Request-ID` are answered as duplicates, and a changed
body, a body signed with another secret, a body with no signature and the same
JSON laid out another way are each refused with 401 and leave no event. An
alert and the yard's own resolve are taken, the second with a timestamp from
1970, since the timestamp is not signed and not read. The events the yard
recorded are held to what normalize printed. Beads are linked to the
addresses in `testdata/link.txt` and to a delivery's number, the stage map is
taken and a map to `review` refused, and the check call and the thread
commands are made through `yardr` against the stand-in for Sentry: the status
change, a reply, a reply whose answer was lost and then found by its key and
not posted again, and the thread read. The token and the signing secret are
looked for in every file the run leaves. The depot's gate runs `yardr pack
check` on this directory first.
`UPDATE_GOLDEN=1 ./test` writes the goldens again from what the programs print.
Read the diff before keeping it.
## Left out
Self-hosted Sentry and regional hosts, beyond the note in SETUP.md; metric
alerts, and any resource but `issue` and `event_alert`; releases, source maps
and assignment; any issue action beyond `created`; declared calls an agent
makes with `yardr call`, and presets (the next slice); opening an issue, which
Sentry makes from errors; mentions in a comment; and a `hear` key, since no
delivery is from a person the yard maps.
#!/bin/sh
# A verified Sentry webhook to the yard's envelope: stdin
# {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
#
# Two deliveries are read. An issue that was created (Sentry-Hook-Resource
# issue, action created) and an alert rule's event (event_alert, action
# triggered) each become the thread of their issue, with the issue's title
# as the thread's title and a short line of what it is as the text. Any
# other delivery (an issue resolved, assigned, archived or unresolved, an
# alert with another action, a comment, an error, a metric alert) is recorded
# with its type, action and actor and no thread. Nobody is mentioned by it,
# and the yard does not hear its own resolve come back as a new issue. A
# event kind this does not read is never refused: a 400 tells Sentry its
# webhook failed, and Sentry retries it and can disable an integration (its
# source skips an app that is disabled). A body that is no JSON object, or a
# delivery with no resource header, is refused: a non-zero exit is answered
# 400, and the delivery reaches no subscription. Nothing of the body is ever
# printed to stderr.
#
# The type is the resource header and the action the payload action, as
# Sentry words them. The thread is <organization slug>#<issue id>, and its
# URL is built here, the same for both deliveries: Sentry sends the
# organization in no field of its own, only in addresses, so it is read from
# the web address of the issue (or of the event, for an alert), which comes
# in two shapes: <slug>.sentry.io/issues/<id>/, which the docs' issue sample
# has, and /organizations/<slug>/issues/<id>/ on a host, which the docs' alert
# sample has and a Sentry without customer domains is expected to send. Both
# become the second shape on the host the delivery came from, which is the
# one fixed form the declaration links.
# Where the organization, the host or the id cannot be read, or the two
# ids disagree, the delivery is recorded with no thread: it is still heard.
#
# What Sentry says about an issue is written by the monitored application
# (its error message, its culprit), so every delivery that carries text is
# marked outsider: the yard shows its line as information, not instructions.
exec jq -c '
def obj: if type == "object" then . else {} end;
def str($v): if ($v | type) == "string" then $v elif ($v | type) == "number" then "\($v)" else "" end;
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. The jq
# POSIX space class includes the Unicode spaces split by Go strings.Fields.
def trunc: (gsub("^[[:space:]]+|[[:space:]]+$"; "") | gsub("[[:space:]]+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
def header($in; $name): [$in.headers // {} | to_entries[] | select(.key | ascii_downcase == $name) | .value[]] | .[0] // "";
# A word that goes into a field: what Sentry spells a level, a category or a
# project slug with, or none.
def word: if test("^[A-Za-z0-9_-]{1,64}$") then . else "" end;
def field($o; $k; $v): if $v == "" then $o else $o + {($k): [$v]} end;
# Where an address says an issue is: its host, the slug of its organization
# and its number, or null. The host is the one the address is on, with no
# user, and the slug is lowercase as Sentry keeps it. The address may go on
# past the issue (an event, a query), which is not read.
def issue_at($u):
(if ($u | type) == "string" then $u else "" end) as $u
| (([$u | capture("^https://(?<host>[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?)/organizations/(?<org>[A-Za-z0-9_][A-Za-z0-9_-]*)/issues/(?<id>[1-9][0-9]*)(?:[/?#].*)?$")] | .[0])
// ([$u | capture("^https://(?<org>[A-Za-z0-9][A-Za-z0-9-]*)\\.(?<host>[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+(?::[0-9]{1,5})?)/issues/(?<id>[1-9][0-9]*)(?:[/?#].*)?$")] | .[0])
// null)
| if . == null or (.host | test("\\.\\.")) then null else . + {org: (.org | ascii_downcase)} end;
. as $in
| (.body | if type == "object" then . else error("sentry payload is not JSON") end) as $p
| (header($in; "sentry-hook-resource") | ascii_downcase | gsub("[^a-z0-9_]+"; "_") | sub("^_+"; "") | sub("_+$"; "")) as $resource
| if $resource == "" then error("sentry delivery has no Sentry-Hook-Resource") else . end
| str($p.action) as $action
| ($p.actor | obj) as $actor
| {source: "sentry", type: $resource, action: $action,
actor: (if str($actor.id) == "" and str($actor.name) == "" then {} else {id: str($actor.id), name: str($actor.name)} end),
thread: {}, mentions: []}
| if $resource == "issue" and $action == "created" then
($p.data | obj | .issue | obj) as $i
| issue_at($i.web_url) as $at
# The issue id the payload names, else the address; where both are given
# they must be the one issue.
| (str($i.id) | if test("^[1-9][0-9]*$") then . else ($at.id // "") end) as $id
| (if $at == null or $id == "" or $at.id != $id then null else $at end) as $found
| (if $found == null then "" else "https://\($found.host)/organizations/\($found.org)/issues/\($found.id)/" end) as $url
| (if $found == null then "" else "\($found.org)#\($found.id)" end) as $thread
| (str($i.project | obj | .slug) | word) as $slug
| (str($i.level) | word) as $level
| (str($i.issueCategory) | word) as $category
| str($i.shortId) as $short
| str($i.culprit) as $culprit
| ("new " + (if $level != "" then $level + " " else "" end) + "issue"
+ (if ($short | word) != "" then " " + $short else "" end)
+ (if $slug != "" then " in " + $slug else "" end)
+ (if $culprit != "" then ": " + $culprit else "" end) | trunc) as $text
| ({} | field(.; "project"; $slug) | field(.; "level"; $level) | field(.; "category"; $category) | field(.; "outsider"; "yes")) as $fields
| . + {text: $text, fields: $fields}
| if $found == null then .
else . + {thread: {id: $thread, title: (str($i.title) | trunc), url: $url}, url: $url, entity: $thread} end
elif $resource == "event_alert" and $action == "triggered" then
($p.data | obj) as $d
| ($d.event | obj) as $e
| issue_at($e.web_url) as $at
| (str($e.issue_id) | if test("^[1-9][0-9]*$") then . else ($at.id // "") end) as $id
| (if $at == null or $id == "" or $at.id != $id then null else $at end) as $found
| (if $found == null then "" else "https://\($found.host)/organizations/\($found.org)/issues/\($found.id)/" end) as $url
| (if $found == null then "" else "\($found.org)#\($found.id)" end) as $thread
# The event is a page of its own under the issue, made only from a
# 32-hex id.
| (str($e.event_id) | if test("^[0-9a-fA-F]{32}$") then . else "" end) as $event
| (if $url != "" and $event != "" then $url + "events/" + $event + "/" else $url end) as $to
# The event names its project by number and, in its API address, by slug.
| (([str($e.url) | capture("^https://[^/]+/api/0/projects/[^/]+/(?<slug>[A-Za-z0-9_-]+)/events/")] | .[0].slug // "") | word) as $slug
| (str($e.level) | word) as $level
| str($d.triggered_rule) as $rule
| str($e.culprit) as $culprit
| ("alert" + (if $rule != "" then " \"" + $rule + "\"" else "" end) + ": "
+ (if $level != "" then $level + " " else "" end) + "event"
+ (if $culprit != "" then " in " + $culprit else "" end) | trunc) as $text
| ({} | field(.; "project"; $slug) | field(.; "level"; $level) | field(.; "outsider"; "yes")) as $fields
| . + {text: $text, fields: $fields}
| if $found == null then .
else . + {thread: {id: $thread, title: (str($e.title) | trunc), url: $url}, url: $to} end
else .
end
'
#!/bin/bash
# thread: what the yard does in a Sentry issue: reply, posted, read, state.
# One JSON object on stdin, one on stdout, as the thread key of the
# declaration says. SENTRY_TOKEN (an internal integration's token, sent as a
# Bearer token) and YARDR_SOURCE_BASE, the declaration's base, are in the
# environment. Needs curl and jq.
#
# A thread is <organization slug>#<issue id>: the issue's number as Sentry's
# API takes it, in the organization it is in. Every request goes to the
# organization's path, comments included. The comment routes also answer
# without the organization, at /api/0/issues/<id>/comments/, but Sentry has
# deprecated that path (its source marks it, with a date that has passed)
# and answers it 410 Gone in brownouts, by default one minute at noon UTC;
# the organization's path is the one it names as the replacement.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
base=${YARDR_SOURCE_BASE%/}
# The address people read Sentry at, for a URL in an answer: the API's base
# without its /api/0. A base that is no /api/0 of a site (a stand-in's) has
# none, and an answer then carries no URL.
web=${base%/api/0}
if [ "$web" = "$base" ]; then web=; fi
# The token 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. A token that holds a character a Sentry token never has
# (a quote, a backslash, a line break) would end the config line early, so
# it is refused, without saying what it held. A Sentry token is hex, or
# letters, digits, _, + / and = for an organization token.
case ${SENTRY_TOKEN-} in
'' | *[!A-Za-z0-9_.~+/=-]*)
echo "SENTRY_TOKEN is not set, or holds a character a Sentry token has none of" >&2
exit 1
;;
esac
# scrub <text>: the text with the token cut out of it, in case an answer
# repeats what it was sent. Bash does it in process: the token is never an
# argument.
scrub() { printf '%s' "${1//"$SENTRY_TOKEN"/<token>}"; }
# plain <text>: the text with its control characters made spaces, for a
# message that repeats what it was given (a thread, a word), so that it
# cannot forge a line of the log or move a terminal.
plain() { printf '%s' "$1" | tr '\000-\037\177' ' '; }
# api <method> <url> [data]: the answer's body, or the failure on stderr.
# The response headers are kept in $tmp/hdr, for the cursor of a list. The
# data goes to curl as a file, not an argument: a long reply would not fit
# one.
api() {
local m u code
m=$1
u=$2
: >"$tmp/hdr"
if [ $# -ge 3 ]; then
printf '%s' "$3" >"$tmp/req"
code=$(printf 'header = "Authorization: Bearer %s"\n' "$SENTRY_TOKEN" \
| curl -sS -o "$tmp/out" -D "$tmp/hdr" -w '%{http_code}' -K - -X "$m" "$u" \
-H 'Content-Type: application/json' --data-binary @"$tmp/req") || {
echo "could not reach $u (curl exit $?)" >&2
return 1
}
else
code=$(printf 'header = "Authorization: Bearer %s"\n' "$SENTRY_TOKEN" \
| curl -sS -o "$tmp/out" -D "$tmp/hdr" -w '%{http_code}' -K - -X "$m" "$u") || {
echo "could not reach $u (curl exit $?)" >&2
return 1
}
fi
if [ "${code:0:1}" != 2 ]; then
# Sentry says why in detail (a string), or in message for a path it no
# longer serves; a refused body is an object of lists. What it says is
# one line of at most 300 characters without control characters, and
# without the token if it was repeated.
local why hint=
why=$(jq -r '(.detail // .message // .error // "no answer") | if type == "string" then . else tojson end | .[0:300]' "$tmp/out" 2>/dev/null || head -c 200 "$tmp/out")
why=$(plain "$why" | tr -s ' ')
if [ "$code" = 403 ]; then
hint=" (the token needs event:read to read an issue and event:write to comment on it and resolve it)"
fi
echo "$u: HTTP $code: $(scrub "$why")$hint" >&2
return 1
fi
cat "$tmp/out"
}
# pages <url>: every page of a list endpoint as one array, newest first as
# Sentry sends it. Sentry pages by cursor: a page asks for 100, and the Link
# header of an answer names the next page's cursor where results="true". A
# cursor that holds more than what Sentry makes one of is never followed, and
# neither is a hundred pages.
pages() {
local u cursor i
u=$1
cursor=
i=0
: >"$tmp/pages"
while :; do
i=$((i + 1))
if [ "$i" -gt 100 ]; then
echo "$u: more than 100 pages of comments" >&2
return 1
fi
api GET "$u?per_page=100${cursor:+&cursor=$cursor}" >"$tmp/page" || return 1
jq -e 'type == "array"' "$tmp/page" >/dev/null 2>&1 || {
echo "$u: the answer is no list" >&2
return 1
}
cat "$tmp/page" >>"$tmp/pages"
cursor=$(tr -d '\r' <"$tmp/hdr" | sed -n 's/.*rel="next"; results="true"; cursor="\([^"]*\)".*/\1/p' | head -n 1)
[ -n "$cursor" ] || break
[[ $cursor =~ ^[A-Za-z0-9:._-]{1,100}$ ]] || {
echo "$u: the next page's cursor is not one Sentry makes" >&2
return 1
}
done
jq -c -s 'add // []' "$tmp/pages"
}
# split <thread>: the organization and the issue a thread names, or why it
# is no thread. The slug is kept lowercase, as Sentry keeps it.
split() {
if [[ $1 =~ ^([A-Za-z0-9_][A-Za-z0-9_-]*)#([1-9][0-9]*)$ ]]; then
org=$(printf '%s' "${BASH_REMATCH[1]}" | tr 'A-Z' 'a-z')
id=${BASH_REMATCH[2]}
issue=$base/organizations/$org/issues/$id
# Where the issue is read, for an answer: none without a site.
url=
if [ -n "$web" ]; then url=$web/organizations/$org/issues/$id/; fi
return 0
fi
echo "thread $(plain "$1"): want <organization>#<issue id>" >&2
return 1
}
case "$1" in
reply) # Sentry's comment takes no client-chosen id, so the key goes into
# the text as a marker that renders as nothing. A comment is not
# threaded, so the parent is left out: every reply is top-level. Sentry
# refuses the same text from the same account within an hour, which the
# key keeps apart for two replies.
thread=$(arg thread)
body=$(arg body)
key=$(arg key)
split "$thread" || exit 1
[ -n "$key" ] && body="$body
<!-- yardr-reply $key -->"
note=$(printf '%s' "$body" | jq -Rsc '{text: .}')
api POST "$issue/comments/" "$note" |
jq -c --arg url "$url" '((.id // "") | tostring) as $id
| if $id == "" then error("the answer names no comment") else {id: $id} + (if $url == "" then {} else {url: $url} end) end' ;;
posted) # {} for none: a retry asks before it posts, so nothing is posted twice
thread=$(arg thread)
key=$(arg key)
split "$thread" || exit 1
pages "$issue/comments/" |
jq -c --arg m "<!-- yardr-reply $key -->" --arg url "$url" '[.[] | select((.data.text // "") | contains($m))][0]
| if . == null then {}
else ((.id // "") | tostring) as $id | {id: $id} + (if $url == "" then {} else {url: $url} end) end' ;;
read) # every page, oldest first: Sentry sends the newest first, and a
# comment is numbered as it was made.
thread=$(arg thread)
split "$thread" || exit 1
pages "$issue/comments/" |
jq -c --arg url "$url" '{messages: (map(select((.type // "note") == "note"))
| sort_by(try (.id | tostring | tonumber) catch 0)
| map({id: ((.id // "") | tostring), parent: "",
author: {id: ((.sentry_app.id // .user.id // "") | tostring),
name: ([.sentry_app.name, .user.name, .user.username] | map(select(. != null and . != "")) | .[0] // "")},
at: (.dateCreated // "0001-01-01T00:00:00Z"), body: (.data.text // ""), url: $url}))}' ;;
state) # started is unresolved, done is resolved. An issue that is ignored
# (archived) was put aside by a person: setting it moves it exactly, and a
# stage map's move resolves it, since it is not yet done, but leaves it
# where it is for started, which it stands past.
thread=$(arg thread)
state=$(arg state)
forward=$(arg forward)
split "$thread" || exit 1
# The word first: nothing is asked of Sentry for one it has no status for.
case "$state" in
started) want=unresolved ;;
done) want=resolved ;;
review)
echo "a Sentry issue has no review status: the states it has are started and done" >&2
exit 1
;;
*)
echo "\"$(plain "$state")\" is no state here: want started, done" >&2
exit 1
;;
esac
item=$(api GET "$issue/") || exit 1
# now is Sentry's word for where it stands.
now=$(jq -r '.status // ""' <<<"$item")
case "$now" in
unresolved | resolved | ignored) ;;
*)
echo "the answer names no state for $thread" >&2
exit 1
;;
esac
if [ "$forward" = true ]; then
# A stage map's move, which never moves backwards: an issue that is
# not unresolved stands at or past started, and a resolved one is
# never reopened.
if [ "$want" = unresolved ] || [ "$now" = resolved ]; then
jq -n -c --arg s "$now" '{status: $s, moved: false}'
exit 0
fi
elif [ "$now" = "$want" ]; then
# thread state moves exactly: only an issue already in the status stays.
jq -n -c --arg s "$now" '{status: $s, moved: false}'
exit 0
fi
moved=$(api PUT "$issue/" "$(jq -n -c --arg s "$want" '{status: $s}')") || exit 1
# An answer that says it is not where it was sent is no move.
[ "$(jq -r '.status // ""' <<<"$moved")" = "$want" ] || {
echo "Sentry answered, but $thread is not $want" >&2
exit 1
}
jq -n -c --arg s "$want" --arg was "$now" '{status: $s, moved: true, was: $was}' ;;
*) echo "unknown: $1" >&2; exit 2 ;;
esac
# Sentry as a declared source: how the core verifies a delivery of an
# internal integration's webhook, and the command that turns a verified body
# into the envelope; what the yard does in an issue's thread, by one command
# per thing (a comment, resolve, unresolve); and Sentry's issue URLs as a
# bead's link.
#
# The first slice: hearing a new issue and an alert rule's event, answering
# in the issue, and resolving it when a bead lands. What is not here:
# self-hosted Sentry and regional hosts (SETUP.md says what to change),
# metric alerts, releases, source maps, assignment, any issue action beyond
# created, and calls an agent makes with `yardr call` beyond the one that
# checks the token.
#
# Checked against Sentry's docs and source on 7 November 2026
# (docs.sentry.io; github.com/getsentry/sentry, master): the integration
# platform's webhooks pages (headers, signature, the issue and the issue
# alert payloads), the Update an Issue API page and its scopes, the API's
# permissions page, the token formats page; and in source, the URL registry
# (src/sentry/api/urls.py), GroupNotesEndpoint and NoteSerializer, the
# issue group endpoints and their scope map (src/sentry/issues/endpoints/),
# the deprecation helper (src/sentry/api/helpers/deprecation.py) and the
# API index (src/sentry/api/endpoints/index.py). The comment routes and the
# index are in source and not in the public API nav: the pack's tests pin
# what they send and what they read back.
#
# 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: sentry
about: "Sentry issues. Hears an internal integration's webhook for a new issue and for an alert rule, and speaks in the issue: a comment, resolve, unresolve."
# The name of the variable, never the value. An internal integration's
# token, sent as a Bearer token: 64 hex characters, with the scopes
# event:read and event:write (the permission Issue & Event, Read & Write,
# and nothing wider). hook.normalize is handed no credential at all.
credential:
env: SENTRY_TOKEN
header: "Authorization: Bearer {credential}"
# ---- what it sends us ----------------------------------------------------
hook:
# Adapter.Verify stays data, checked by the core: the integration's
# client secret is handed to no command, and an unverified body reaches
# none. Sentry signs the exact body, hex HMAC-SHA256 under the client
# secret, in Sentry-Hook-Signature. Sentry-Hook-Timestamp rides along
# and is not signed, and the core refuses a timestamp its signature does
# not cover, so none is named. The delivery is told by Request-ID, which
# Sentry makes (a uuid) once for each request it builds: a retry that
# builds the request again has another, so a retry is told by its body
# as well, which it repeats.
verify:
scheme: hmac-sha256
signed: body
signature: { header: Sentry-Hook-Signature, encoding: hex }
delivery: { header: Request-ID }
# Per hook, not per source: a delivery's body is the same for every hook
# aimed at the one integration, and each hook records its own.
bodies: unique-per-hook
# Sentry's deliveries follow errors, not a rhythm: an idle hour after
# the last issue is no break, so the quiet check does not time them.
untimed: true
# Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
# envelope. The core sets source and delivery itself.
normalize: [sentry/normalize]
# The keys its events' fields may carry: a subscription's --where may
# name them beside type, action, team, project, repo and label. project
# is the Sentry project's slug. level is the issue's level (fatal, error,
# warning, info, debug) and category its issueCategory (error, outage or
# feedback: the issue webhook is sent for all three). outsider is yes on
# every delivery that carries text: an issue's title and culprit come
# from the monitored application's runtime, and whoever uses that
# application can shape them, so the yard marks them as information, not
# instructions.
keys: [level, category, outsider]
# ---- what the yard does in its threads ------------------------------------
# One command per thing, each handed its object on stdin and the
# credential under SENTRY_TOKEN, with YARDR_SOURCE_BASE from base below.
# sentry/thread is the script: reply posts a comment on the issue with the
# key in a marker, posted finds what a key left ({} for none), read lists
# the issue's comments oldest first, and state moves the issue to what the
# word means: exactly, or never backwards with forward (a stage map's
# move). A thread is <organization slug>#<issue id>, the issue's number in
# Sentry's own API. There is no open: Sentry makes its issues from errors.
# The shapes are in docs/integrations.md in the yardr repository.
#
# The words are the three the core has, of which Sentry has two statuses to
# set: started is unresolved and done is resolved. An issue that is ignored
# (archived) stands between them.
thread:
reply: [sentry/thread, reply] # in {thread, parent, body, key} out {id, url}
posted: [sentry/thread, posted] # in {thread, key} out {id, url} or {}
read: [sentry/thread, read] # in {thread} out {messages: [...]}
state: [sentry/thread, state] # in {thread, state, forward?} out {status, moved, was?}
states: [started, done] # the words it has a status for
# ---- its issues as a bead's link ------------------------------------------
# The host its URLs are on, what reads an issue from a URL's path, and over
# the groups: the organization's slug and the issue's number. The one fixed
# form is the path form on sentry.io, which Sentry has middleware to send on
# to an organization's own address, <slug>.sentry.io (not yet tried: see
# README.md). The file can name one host only, so an address on
# <slug>.sentry.io is not read as a link: SETUP.md says to link the fixed
# form, which is also the URL a heard delivery's thread has. An
# organization's slug is lowercase, so the groups fold.
link:
host: sentry.io
issue: '^/organizations/([A-Za-z0-9_][A-Za-z0-9_-]*)/issues/([1-9][0-9]*)(?:/.*)?$'
fixed: "https://sentry.io/organizations/{1}/issues/{2}"
thread: "{1}#{2}"
fold: lower
# ---- what an agent may ask of it, through `yardr call` -------------------
# The scopes the token holds, and nothing else: the file's calls are what
# keep its base and credential (a file with none has neither), and one read
# call is what that takes. It is the API's index, which needs no scope and
# answers a token's own, so `yardr source check` shows at once whether
# event:write is there, and fails on a token Sentry does not take.
#
# base is the REST API every thread command and every call sends to, as
# YARDR_SOURCE_BASE.
base: https://sentry.io/api/0
check: scopes
calls:
scopes:
kind: read
about: What the credential may do, as the scopes its token holds.
request: { method: GET, path: / }
answer: { select: [auth] }
# Setting up Sentry
The pack wires Sentry into the yard as the declared source `sentry`. The
yard hears a new issue, and an alert rule's event, through an internal
integration's webhook, and speaks in that issue: it comments on it, resolves
it and unresolves it. A bead that tracks an issue resolves it when the bead
lands. What a stage means in the yard is a flow pack's to say, not this
file's.
The file names sentry.io. Step 11 says what another Sentry changes.
1. **Apply the pack.** From main of the yardr-packs repository, as an
operator. The pack runs two programs of its own as you, `sentry/normalize`
and `sentry/thread`, and they need `jq`, `curl` and `bash`.
```sh
yardr pack apply /Users/mckean/projects/repos/yardr-packs/sentry
```
2. **The integration, in Sentry.** The yard hears Sentry through an internal
integration, which belongs to one organization. In sentry.io open
Settings, Developer Settings, and make a new internal integration. Name it
for the yard: Sentry names it as who did what the yard does there. Then:
- Webhook URL: `https://<public host>/hooks/sentry`, where the path ends in
the hook's name from step 3. Sentry calls it from the internet, so it
wants a public https address in front of the yard's listener
(`yardr docs integrations`, "An address the service can reach"). Sentry's
docs give a webhook one second to answer.
- Permissions: Issue & Event, Read & Write, and nothing else. That is the
scopes `event:read` and `event:write`, the least that reads an issue,
comments on it and resolves it. Know what `event:write` is: in Sentry's
source its DELETE route takes it too (the public permissions page lists
`event:admin` there), so the token can delete an issue, though the pack
never does. Keep it to this integration, and revoke it when the hook goes
(step 12).
- Webhooks: tick Issue. Leave Comment, Error and the rest off. The pack
records them and does nothing with them, Error webhooks are for Business
and Enterprise plans, and the yard's own comments would come back as
Comment deliveries.
- Alert Action: switch it on only if alert rules should send events to the
yard. Step 7 says when.
Save it. The integration's page then shows its Client Secret, which signs
every delivery, and a Token, which is what the yard answers with. Steps 3
and 4 put each where the yard reads it, typed blind.
3. **The hook and its secret.** Sentry signs each delivery with the
integration's Client Secret: the hex HMAC-SHA256 of the body, in
`Sentry-Hook-Signature`. The yard checks it against the secret file the
hook names, and the secret is handed to no program. A new file takes its
mode from the shell's umask, so narrow it first. The secret is never a
command's word, because that line would stay in the shell's history. Run
the second command, paste the Client Secret blind and press Enter:
```sh
umask 077
```
```sh
IFS= read -rs v && printf '%s' "$v" > ~/.yardr/sentry.secret; unset v
```
```sh
chmod 600 ~/.yardr/sentry.secret
```
```sh
yardr hook add sentry --source sentry --secret-file ~/.yardr/sentry.secret
# hook sentry saved: POST /hooks/sentry (sentry)
```
The hook takes no `--unknown` word: everything it hears is from Sentry or
from the integration itself, so there is no sender for a user to map, and
yardr refuses the flag for a source that hears no one by identity. One hook
serves every crew member, and `yardr hook list` shows it with STATE `ok`.
The file is read on every delivery, so a new Client Secret is the file
written again.
4. **The yard's token.** The yard acts in Sentry as the integration. Its
Token is on the integration's page in Sentry, as 64 hex characters. A
token has the integration's scopes and does not expire, and you can revoke
it there.
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 sentry --for-crew <name> --env SENTRY_TOKEN --context "In Sentry, Settings, Developer Settings, the yard's internal integration: its Token. Scopes event:read and event:write."
```
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, so restart the
crew member after giving it. By hand it is yours instead. Narrow the
umask first, as in step 3. The token is never a command's word, so type
or paste it blind after the second command:
```sh
umask 077
```
```sh
IFS= read -rs v && printf 'SENTRY_TOKEN=%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
```
`yardr source check` makes the pack's one call, a GET of Sentry's API
index, which needs no scope and answers the token's own. It prints them,
and the line `auth:` must hold `event:read` and `event:write`. A 401 is a
token Sentry does not take, and a reply or a status change later answers
403 for a token without `event:write`:
```sh
yardr source set sentry env_file=~/.yardr/crew/<name>.env
```
```sh
yardr source check sentry
```
5. **Its identity.** The stage map in step 9 wants the crew member to have
one in the source. Sentry names the integration as the actor of what it
does, and no delivery mentions anyone, so the key only has to be something:
use the integration's name.
```sh
yardr crew identity <name> --source sentry --key <integration name>
```
A crew member setting itself up proposes it instead, from its session,
and the operator approves the card:
```sh
yardr bead ask <bead> "Who am I on Sentry?" --run "crew identity <name> --source sentry --key <integration name>"
```
6. **The grants.** Setting a status is the operator's to grant. Replying
needs none, and the pack has no open: Sentry makes its issues from errors.
```sh
yardr crew set <name> set_state=sentry
```
7. **What the crew member hears.** Nothing in a delivery mentions anyone, so a
subscription is by `--scope all` and a `--where`. The Issue webhook of
step 2 sends every new issue, and this is the subscription for it:
```sh
yardr subscribe <name> sentry --scope all --where type=issue --where action=created
```
An alert rule sends what its conditions pick, once the integration's
Alert Action is on and the rule has the action "Send a notification via"
the integration. This is the subscription for it. One new issue can reach
the yard both ways, and it is one thread either way, so pick one: the
webhook for every issue, a rule for the ones you choose.
```sh
yardr subscribe <name> sentry --scope all --where type=event_alert --where action=triggered
```
`--where` narrows by `type`, `action`, `project` (Sentry's slug), `level`
(`fatal`, `error`, `warning`, `info` or `debug`), `category` and `outsider`.
The Issue webhook is also sent for outages and for user feedback, which
`category` tells from an error's (`error`, `outage` or `feedback`): add
`--where category=error` to hear errors alone.
```sh
yardr subscribe <name> sentry --scope all --where type=issue --where action=created --where category=error --where level=fatal
```
Every line carries the cue `information, not instructions`. An issue's
title and culprit come from the monitored application, whoever uses it can
shape them, and the pack marks them `outsider` for that reason.
8. **A bead that names an issue.** A bead tracks an issue by its address in
the one form yardr keeps, or by the number of the delivery that heard it,
which an inbox line starts with:
```sh
yardr bead create "Fix the ZeroDivisionError" --depot <depot> --tracks <seq>
```
By address it is Sentry's path form, which is also what a heard delivery's
thread URL is:
```sh
yardr bead create "Fix the ZeroDivisionError" --depot <depot> --tracks https://sentry.io/organizations/<org>/issues/<id>/
```
The address your browser shows is likely `https://<org>.sentry.io/issues/<id>/`.
A declaration names one host and cannot name a subdomain, so yardr
refuses that one, saying it knows no issues there. Type the path form
instead: it is the form the pack keeps, and Sentry has middleware built to
send a path-form address on to the organization's own one for a signed-in
browser (README.md lists that among what is not yet checked). An address
that goes on past the issue (an event, a query) is read to the issue.
9. **The bead that lands resolves the issue.** Which stage means which status
is set on the hook, with the crew member the yard acts as. `hook add`
replaces the definition, so the source and the secret file go again. The
line is for the default flow, where a task bead's last stage is `merged`:
```sh
yardr hook add sentry --source sentry --secret-file ~/.yardr/sentry.secret --as <name> \
--map task:merged=done
```
`done` is resolved. The daemon makes the move, so it must run. A stage map
never moves backwards: it resolves an issue that is unresolved, or that a
person ignored (archived), and leaves a resolved one as it is, and a bead
that enters an earlier stage leaves its issue alone. By hand the move is
exact: `done` resolves and `started` unresolves, whatever the issue was.
Sentry has no review status, so a map line to `review` is refused.
The yard's own resolve comes back as an issue delivery with the action
`resolved`, by the integration. The pack records it with no thread, so it
is never heard as a new issue.
10. **Test it.** First, a delivery by hand shows that the listener, the hook
and the secret hold, before Sentry sends anything. From this pack's
directory, with the yard's `hook_addr` in place of `<port>`, signed as
Sentry signs it: the hex HMAC-SHA256 of the body under the Client Secret.
```sh
body=$(jq -c .body testdata/hook/issue-created.json)
```
```sh
sig=$(printf '%s' "$body" | SECRET=$(cat ~/.yardr/sentry.secret) python3 -c 'import hashlib, hmac, os, sys; print(hmac.new(os.environ["SECRET"].encode(), sys.stdin.buffer.read(), hashlib.sha256).hexdigest())')
```
```sh
curl -s -X POST http://127.0.0.1:<port>/hooks/sentry -H "Sentry-Hook-Signature: $sig" -H "Request-ID: $(uuidgen)" -H 'Sentry-Hook-Resource: issue' -H 'Content-Type: application/json' --data-binary "$body"
# {"seq":16}
```
The same body again is answered as a duplicate, and the same body signed
with another secret is answered 401 and shows in `yardr hook list` under
LAST REFUSED. The issue in that body is made up, so a reply or a status
change on it fails at Sentry.
Then a real one: raise an error in a project the integration sees, from
any SDK. The new issue lands in the crew member's inbox, as a line that
starts with its number. Answer it by that number, then resolve the issue
and unresolve it. Replying makes the crew member a participant in the
issue's thread, which setting a status wants (`yardr thread follow` does
it without a reply):
```sh
yardr reply <seq> "Seen. Looking into it."
```
```sh
yardr thread state <seq> done
```
```sh
yardr thread state <seq> started
```
The comment shows in the issue's activity, as the integration's. It
carries a marker the yard finds a retry by, and Sentry's page should show
nothing of it. `yardr thread show <seq>` lists the comments, oldest first.
A thread is `<org>#<id>`, the organization's slug and the issue's number,
and `yardr thread list <name>` shows the threads the crew member takes
part in.
11. **Another Sentry.** The file names sentry.io in three places: `base`, the
API every call goes to, and `link.host` and `link.fixed`, under `link`.
For a self-hosted Sentry at `sentry.example.com`, copy the pack's
directory, set `base` to `https://sentry.example.com/api/0`, `link.host`
to `sentry.example.com` and `link.fixed` to
`https://sentry.example.com/organizations/{1}/issues/{2}`. Apply the copy
instead. The programs need no change: `sentry/normalize` builds a thread's
URL on the host the delivery's own address is on, and `sentry/thread` goes
to its base. A second Sentry is a second copy under another source name,
which also changes `name` in `pack.yaml` and `sentry.yaml`. The copy's
tests expect sentry.io, so update them or leave them out of your copy.
Sentry's docs give an organization whose data is stored in the EU an API
domain of its own, `de.sentry.io`, for requests that must be processed
there, and say that data in the US uses `sentry.io`. The file's `base` is
right for the US. For the EU it would be `https://de.sentry.io/api/0`, and
which host such an organization's web addresses take in `link.host` has
not been checked: look at what its webhook sends as `web_url`.
12. **Ending hearing.** Remove the hook, then delete the integration in
Sentry, which takes its Client Secret and its tokens with it:
```sh
yardr hook rm sentry
```
Events the hook accepted are kept.
Two limits matter before the yard writes in an issue, and README.md has both
in full. The comment route the pack uses is the one under the organization,
`/api/0/organizations/<org>/issues/<id>/comments/`: Sentry's source marks the
older one without the organization deprecated, and answers it 410 Gone in
brownouts. And that route is in Sentry's source and not in its public API
pages. The tests pin what the pack sends to it and reads back, so a change to
the pack's requests is a changed golden; they cannot see Sentry move a route.
A route that moved answers 404 or 410, which the command reports with its
address.
#!/bin/sh
# The sentry pack's tests: what the gate runs beside yardr pack check.
# Needs no network and no secret, done in seconds; needs jq and bash, which
# the pack's programs need, and python3 and yardr for the last two parts,
# which fail without them since a skipped part would leave what they pin
# unpinned.
#
# hook each webhook body in testdata/hook is turned into the yard's
# envelope by sentry/normalize; its output must match the golden
# beside it byte for byte. issue-created, event-alert (trimmed)
# and comment-created are Sentry's own samples (docs.sentry.io,
# 7 November 2026) with the signature and the ids made up;
# issue-resolved is the issue sample resolved by the integration,
# and issue-created-path an issue whose address has the path shape
# the docs' alert sample has. The variants below each change one
# thing in one of them. The fields those envelopes carry and
# hook.keys must agree both ways.
# thread each testdata/thread/*.case is one run of sentry/thread against
# a stand-in curl (testdata/bin/curl) that records each request
# and answers from the case. The case's golden holds the requests
# (a changed request is a changed golden), the exit status,
# stdout and stderr. The token is a canary, looked for in every
# argument, output and record. Then the cases that name it run
# again with the real curl against a stand-in server on a loopback
# port (testdata/standin.py), which must give the same golden:
# what the stand-in curl takes is what curl does.
# core the declaration as the yard reads it, a delivery signed as
# Sentry signs it (taken, a tampered body and a wrong secret
# refused), a bead linked by the address of what a delivery
# names, and the check call, through the real yardr in a scratch
# yard (testdata/core.sh).
#
# UPDATE_GOLDEN=1 ./test writes the goldens again: read the diff before
# committing it.
set -u
cd "$(dirname "$0")" || exit 1
here=$(pwd)
fail=0
tmp=$(mktemp -d)
# The core part's scratch yard and stand-in are made when its turn comes,
# and the yard's server, which its first command starts, would outlive the
# directory it owns: stop it first. core is empty until then.
core=
standin_pid=
YARDR=${YARDR:-$(command -v yardr || echo /Users/mckean/go/bin/yardr)}
cleanup() {
[ -z "${standin_pid:-}" ] || kill "$standin_pid" 2>/dev/null
if [ -n "${core:-}" ]; then
env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester server stop >/dev/null 2>&1
rm -rf "${core:?}"
fi
rm -rf "${tmp:?}"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
# check <name> <actual file> <golden file>
check() {
if [ "${UPDATE_GOLDEN:-}" = 1 ]; then
if ! cmp -s "$2" "$3"; then cp "$2" "$3"; echo "test: $1 golden written"; fi
return
fi
if [ ! -e "$3" ]; then
echo "test: $1 has no golden $3 (UPDATE_GOLDEN=1 ./test writes it)"
fail=1
elif cmp -s "$2" "$3"; then
echo "test: $1 ok"
else
echo "test: $1 differs from $3:"
diff "$3" "$2" | sed 's/^/ /' | head -30
fail=1
fi
}
# ok <name> <condition's exit status> [detail]
ok() {
if [ "$2" = 0 ]; then echo "test: $1 ok"; else echo "test: $1 FAILED${3:+: $3}"; fail=1; fi
}
if ! command -v jq >/dev/null 2>&1; then
echo "test: jq is not on PATH: the pack's programs need it"
exit 1
fi
if ! command -v bash >/dev/null 2>&1; then
echo "test: bash is not on PATH: sentry/thread needs it"
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "test: python3 is not on PATH: the stand-in for Sentry is one"
exit 1
fi
if [ ! -x "$YARDR" ]; then
echo "test: needs yardr ($YARDR)"
exit 1
fi
# A canary, not a secret: a token of the shape Sentry's has, which no output
# of the pack may hold. The signing secret is another canary, which no file
# of the scratch yard may hold but the one the hook names.
token=canary_5e47a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6
# ---- hook ------------------------------------------------------------------
for f in testdata/hook/*.json; do
n=$(basename "$f" .json)
case $n in not-an-object | no-resource) continue ;; esac
env -i PATH="$PATH" ./sentry/normalize <"$f" >"$tmp/$n.out" 2>"$tmp/$n.err"
check "normalize $n" "$tmp/$n.out" "testdata/hook/$n.golden"
[ ! -s "$tmp/$n.err" ]
ok "normalize $n prints nothing to stderr" $? "$(head -c 200 "$tmp/$n.err")"
done
# refuse <name> <fixture> <stderr wanted>: a body normalize will not take is
# refused with a fixed line that quotes none of it.
refuse() {
env -i PATH="$PATH" ./sentry/normalize <"testdata/hook/$2.json" >"$tmp/r.out" 2>"$tmp/r.err"
rc=$?
line=$(sed 's/^jq: error (at [^)]*): //' "$tmp/r.err")
[ "$rc" != 0 ] && [ ! -s "$tmp/r.out" ] && [ "$line" = "$3" ] && ! grep -q QUOTED-BODY-TEXT "$tmp/r.err"
ok "normalize refuses $1" $? "exit $rc, stdout $(wc -c <"$tmp/r.out") bytes, stderr: $line"
}
refuse "a body that is no object" not-an-object "sentry payload is not JSON"
refuse "a delivery with no resource header" no-resource "sentry delivery has no Sentry-Hook-Resource"
# Each row is one change to one fixture and what must then hold of the
# envelope: label ;; fixture ;; jq edit ;; jq condition. The address
# an organization and an issue are read from comes in two shapes, and goes
# out as one; what cannot be read is heard with no thread, and never refused.
while IFS= read -r line; do
[ -n "$line" ] || continue
label=${line%% ;; *}
rest=${line#* ;; }
file=${rest%% ;; *}
rest=${rest#* ;; }
edit=${rest%% ;; *}
want=${rest#* ;; }
jq -c "$edit" "testdata/hook/$file.json" | env -i PATH="$PATH" ./sentry/normalize >"$tmp/v.out" 2>"$tmp/v.err"
rc=$?
if [ "$rc" = 0 ] && [ ! -s "$tmp/v.err" ] && [ "$(jq -c "$want" "$tmp/v.out" 2>/dev/null)" = true ]; then
echo "test: normalize $label ok"
else
echo "test: normalize $label FAILED ($rc): $(head -c 300 "$tmp/v.err") $(head -c 600 "$tmp/v.out")"
fail=1
fi
done <<'EOF'
an issue on an organization's own address, on a regional host ;; issue-created ;; .body.data.issue.web_url = "https://example-org.de.sentry.io/issues/1234567890/" ;; .thread.id == "example-org#1234567890" and .thread.url == "https://de.sentry.io/organizations/example-org/issues/1234567890/" and .url == .thread.url
an issue on a self-hosted address with a port and a capital in the slug ;; issue-created ;; .body.data.issue.id = "42" | .body.data.issue.web_url = "https://sentry.example.com:9000/organizations/Acme/issues/42/" ;; .thread.id == "acme#42" and .thread.url == "https://sentry.example.com:9000/organizations/acme/issues/42/"
an issue whose address carries a query ;; issue-created ;; .body.data.issue.web_url = "https://example-org.sentry.io/issues/1234567890/?query=is%3Aunresolved" ;; .thread.url == "https://sentry.io/organizations/example-org/issues/1234567890/"
an issue with no id of its own, read from the address ;; issue-created ;; del(.body.data.issue.id) ;; .thread.id == "example-org#1234567890"
an issue with no web_url is heard with no thread ;; issue-created ;; del(.body.data.issue.web_url) ;; .thread == {} and (has("url") | not) and (has("entity") | not) and (.text | startswith("new fatal issue PYTHON-Y")) and .fields.outsider == ["yes"]
an issue at an http address has no thread ;; issue-created ;; .body.data.issue.web_url = "http://example-org.sentry.io/issues/1234567890/" ;; .thread == {}
an issue at an address with a user has no thread ;; issue-created ;; .body.data.issue.web_url = "https://x@sentry.io/organizations/example-org/issues/1234567890/" ;; .thread == {}
an issue at a host with two dots in a row has no thread ;; issue-created ;; .body.data.issue.web_url = "https://a..b/organizations/o/issues/1234567890/" ;; .thread == {}
an issue whose id is not the address's has no thread ;; issue-created ;; .body.data.issue.id = "999" ;; .thread == {}
an issue id that is no number falls back on the address's ;; issue-created ;; .body.data.issue.id = "../x" ;; .thread.id == "example-org#1234567890"
an issue that is a string is heard, quoting nothing ;; issue-created ;; .body.data.issue = "QUOTED-BODY-TEXT" ;; .thread == {} and .text == "new issue" and (tojson | contains("QUOTED") | not)
data that is an array is heard ;; issue-created ;; .body.data = ["QUOTED-BODY-TEXT"] ;; .thread == {} and (tojson | contains("QUOTED") | not)
a created issue with no data is heard ;; issue-created ;; del(.body.data) ;; .thread == {} and .type == "issue" and .action == "created"
a level that is not a word is left out of the fields ;; issue-created ;; .body.data.issue.level = "a b" ;; (.fields | has("level") | not) and (.text | startswith("new issue PYTHON-Y"))
a project slug that is not a word is left out of the fields ;; issue-created ;; .body.data.issue.project.slug = "a/b" ;; (.fields | has("project") | not)
no actor ;; issue-created ;; del(.body.actor) ;; .actor == {} and .thread.id == "example-org#1234567890"
an actor that is a string ;; issue-created ;; .body.actor = "QUOTED-BODY-TEXT" ;; .actor == {} and (tojson | contains("QUOTED") | not)
a header in other case ;; issue-created ;; .headers = {"sentry-hook-resource": ["issue"]} ;; .type == "issue" and .thread.id == "example-org#1234567890"
a title of many lines is one line ;; issue-created-path ;; . ;; .thread.title == "ZeroDivisionError: division by zero second line of the message"
an alert on an organization's own address ;; event-alert ;; .body.data.event.web_url = "https://test-org.sentry.io/issues/1117540176/events/e4874d664c3540c1a32eab185f12c5ab/" ;; .thread.id == "test-org#1117540176" and .url == "https://sentry.io/organizations/test-org/issues/1117540176/events/e4874d664c3540c1a32eab185f12c5ab/"
an alert with another action is recorded without a thread ;; event-alert ;; .body.action = "updated" ;; .type == "event_alert" and .action == "updated" and .thread == {} and .mentions == [] and (has("text") | not) and (has("fields") | not) and (has("url") | not)
an alert with no issue_id, read from the address ;; event-alert ;; del(.body.data.event.issue_id) ;; .thread.id == "test-org#1117540176"
an alert whose issue_id is a number ;; event-alert ;; .body.data.event.issue_id = 1117540176 ;; .thread.id == "test-org#1117540176"
an alert whose issue_id is not the address's has no thread ;; event-alert ;; .body.data.event.issue_id = "5" ;; .thread == {}
an alert with an event id that is not 32 hex points at the issue ;; event-alert ;; .body.data.event.event_id = "../x" ;; .url == .thread.url
an alert with no rule ;; event-alert ;; del(.body.data.triggered_rule) ;; .text == "alert: error event in ?(<anonymous>)"
an alert with no API address has no project ;; event-alert ;; del(.body.data.event.url) ;; (.fields | has("project") | not) and .fields.level == ["error"]
an alert with no web_url is heard with no thread ;; event-alert ;; del(.body.data.event.web_url) ;; .thread == {} and (has("url") | not) and .fields.outsider == ["yes"]
an alert with no data is heard ;; event-alert ;; del(.body.data) ;; .thread == {} and .text == "alert: event"
EOF
# Any other issue action, and any other resource, is recorded with its type,
# action and actor and nothing else: no thread, no text, no fields. An issue
# resolved by the yard's own integration is one, which is why the yard never
# hears its own resolve as a new issue.
for action in resolved assigned archived unresolved; do
jq -c --arg a "$action" '.body.action = $a' testdata/hook/issue-created.json | env -i PATH="$PATH" ./sentry/normalize >"$tmp/a.out" 2>"$tmp/a.err"
jq -e --arg a "$action" '.type == "issue" and .action == $a and .thread == {} and .mentions == [] and (has("text") | not) and (has("fields") | not) and (has("url") | not) and (has("entity") | not)' "$tmp/a.out" >/dev/null 2>&1 && [ ! -s "$tmp/a.err" ]
ok "normalize an issue $action has no thread and no text" $? "$(head -c 300 "$tmp/a.out")"
done
for resource in error metric_alert installation comment seer preprod_artifact something_new; do
jq -c --arg r "$resource" '.headers["Sentry-Hook-Resource"] = [$r] | .body.action = "created"' testdata/hook/issue-created.json | env -i PATH="$PATH" ./sentry/normalize >"$tmp/a.out" 2>"$tmp/a.err"
jq -e --arg r "$resource" '.type == $r and .action == "created" and .thread == {} and (has("text") | not) and (has("fields") | not)' "$tmp/a.out" >/dev/null 2>&1 && [ ! -s "$tmp/a.err" ]
ok "normalize a $resource delivery is recorded with no thread" $? "$(head -c 300 "$tmp/a.out")"
done
# A field normalize sets is one a subscription may name only when hook.keys
# lists it (or it is a common key), and a key the file lists that no
# envelope here sets is stale.
keys=$(sed -n 's/^ keys: \[\(.*\)\]$/\1/p' sentry.yaml | tr -d ' ' | tr ',' ' ')
seen=$(for g in testdata/hook/*.golden; do jq -r '.fields // {} | keys[]' "$g"; done | sort -u | tr '\n' ' ')
bad=
for k in $seen; do
case " $keys team project repo label " in *" $k "*) ;; *) bad="$bad $k is set and in no hook.keys;" ;; esac
done
for k in $keys; do
case " $seen " in *" $k "*) ;; *) bad="$bad hook.keys lists $k and no envelope sets it;" ;; esac
done
[ -z "$bad" ]
ok "fields and hook.keys agree" $? "$bad"
# ---- thread ----------------------------------------------------------------
stand=$here/testdata/bin
# Prove the stand-in is what the program would run, before anything runs.
if [ "$(PATH="$stand:$PATH" command -v curl)" != "$stand/curl" ]; then
echo "test: the stand-in curl is not the first curl on PATH"
exit 1
fi
# The stand-in server of the second pass, one process for every case.
sd=$tmp/standin
mkdir "$sd"
python3 -I testdata/standin.py "$sd" 2>"$tmp/standin.err" <<EOF &
$token
EOF
standin_pid=$!
i=0
while [ ! -s "$sd/port" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i + 1)); done
if [ ! -s "$sd/port" ]; then
echo "test: the stand-in server did not listen: $(cat "$tmp/standin.err")"
exit 1
fi
port=$(cat "$sd/port")
# golden <requests> <status> <stdout> <stderr>: what a run is held to.
golden() {
echo "--- requests"
cat "$1"
echo "--- status"
echo "$2"
echo "--- stdout"
cat "$3"
[ ! -s "$3" ] || [ "$(tail -c 1 "$3" | wc -l)" -ne 0 ] || echo
echo "--- stderr"
cat "$4"
}
# A case file is sections, each opened by a line "=== name": cmd (the
# command), base (YARDR_SOURCE_BASE, the shipped one otherwise), in (the
# object on stdin), answer_n (the nth request's answer: its status, then its
# body), headers_n (the nth answer's response headers, as curl -D writes
# them), curl_exit_n (the nth request fails as a refused connection does) and
# real (the case is run again with the real curl, below).
# @TOKEN@ is the canary, where an answer repeats the credential it was sent.
for c in testdata/thread/*.case; do
n=$(basename "$c" .case)
d="$tmp/case_$n"
mkdir "$d"
sed -e "s|@TOKEN@|$token|g" "$c" | awk -v dir="$d" '/^=== /{ name = substr($0, 5); f = dir "/" name; printf "" > f; next } { print >> f }'
# curl -D writes its header lines with a carriage return.
for h in "$d"/headers_*; do
[ -e "$h" ] || continue
sed 's/$/\r/' "$h" >"$h.crlf"
mv "$h.crlf" "$h"
done
base=https://sentry.io/api/0
[ ! -e "$d/base" ] || base=$(cat "$d/base")
cmd=$(cat "$d/cmd")
: >"$tmp/requests"
rm -f "$tmp/requests.n"
env -i PATH="$stand:$PATH" HOME="$tmp" TMPDIR="$tmp" SENTRY_TOKEN="$token" YARDR_SOURCE_BASE="$base" \
FAKE_CASE="$d" FAKE_LOG="$tmp/requests" FAKE_AUTH="$token" FAKE_SECRETS="$token" \
./sentry/thread "$cmd" <"$d/in" >"$tmp/stdout" 2>"$tmp/stderr"
status=$?
golden "$tmp/requests" "$status" "$tmp/stdout" "$tmp/stderr" >"$tmp/golden"
check "thread $n" "$tmp/golden" "testdata/thread/$n.golden"
# No secret anywhere a program can put one, and nothing left behind.
! grep -qF "$token" "$tmp/golden"
ok "thread $n leaks no token" $?
! grep -q "LEAK" "$tmp/requests"
ok "thread $n passes no token in an argument" $?
[ -z "$(find "$tmp" -mindepth 1 -maxdepth 1 -name 'tmp.*')" ]
ok "thread $n leaves no temporary directory" $?
! grep -E '^ auth: (WRONG|missing)$' "$tmp/requests" >/dev/null
ok "thread $n sent the token to every request, on stdin" $?
cat "$tmp/requests" >>"$tmp/allrequests"
# The second pass, for the cases that name it with a real section: the same
# case, the real curl, the stand-in server. The base is the case's with the
# host the server's, and what the run says of it is put back before the two
# are compared. The cases that name it are one of each way the program uses
# curl (a GET, a POST and a PUT with a body, a list read by cursor from the
# headers, an answer that is no 2xx), which is what the stand-in curl can get
# wrong; the rest differ only in what they hold.
[ -e "$d/real" ] || continue
origin=$(printf '%s' "$base" | sed 's|^\([a-z]*://[^/]*\).*|\1|')
printf '%s\n' "$d" >"$sd/case"
rm -f "$sd/n"
: >"$sd/requests"
env -i PATH="$PATH" HOME="$tmp" TMPDIR="$tmp" SENTRY_TOKEN="$token" YARDR_SOURCE_BASE="http://127.0.0.1:$port${base#"$origin"}" \
./sentry/thread "$cmd" <"$d/in" >"$tmp/rstdout" 2>"$tmp/rstderr"
rstatus=$?
for f in requests; do sed "s|http://127.0.0.1:$port|$origin|g" "$sd/$f" >"$tmp/r$f"; done
sed -i.bak "s|http://127.0.0.1:$port|$origin|g" "$tmp/rstdout" "$tmp/rstderr"
golden "$tmp/rrequests" "$rstatus" "$tmp/rstdout" "$tmp/rstderr" >"$tmp/rgolden"
if cmp -s "$tmp/golden" "$tmp/rgolden"; then
echo "test: thread $n with the real curl ok"
else
echo "test: thread $n with the real curl differs from the stand-in curl's run:"
diff "$tmp/golden" "$tmp/rgolden" | sed 's/^/ /' | head -30
fail=1
fi
done
# A token Sentry's has no characters like is refused before anything is
# sent, and is not repeated.
for target in "reply reply" "read read" "state state-done-moves" "posted posted-none"; do
# shellcheck disable=SC2086 # two words, on purpose
set -- $target
d="$tmp/case_$2"
[ -e "$d/in" ] || d="$tmp/case_reply"
for t in '' 'bad"token' 'with space' 'back\slash' 'two
lines'; do
: >"$tmp/requests"
rm -f "$tmp/requests.n"
env -i PATH="$stand:$PATH" HOME="$tmp" TMPDIR="$tmp" SENTRY_TOKEN="$t" YARDR_SOURCE_BASE=https://sentry.io/api/0 \
FAKE_CASE="$d" FAKE_LOG="$tmp/requests" FAKE_AUTH="$token" FAKE_SECRETS="" \
./sentry/thread "$1" <"$d/in" >"$tmp/stdout" 2>"$tmp/stderr"
rc=$?
[ "$rc" != 0 ] && [ ! -s "$tmp/stdout" ] && [ ! -s "$tmp/requests" ] && [ "$(cat "$tmp/stderr")" = "SENTRY_TOKEN is not set, or holds a character a Sentry token has none of" ]
ok "thread $1 with the token '$(printf '%s' "$t" | tr '\n' '~')' is refused, sending nothing and saying nothing of it" $? "exit $rc: $(head -c 200 "$tmp/stderr")"
done
done
# The standard organization-token shape is taken: + / and = are in it.
: >"$tmp/requests"
rm -f "$tmp/requests.n"
env -i PATH="$stand:$PATH" HOME="$tmp" TMPDIR="$tmp" SENTRY_TOKEN="sntrys_eyJpYXQiOjE3NjI4MDczODAuMTIz+/=_AbC123+/AbC123+/AbC123+/AbC123+/AbC123+/AbC1" YARDR_SOURCE_BASE=https://sentry.io/api/0 \
FAKE_CASE="$tmp/case_read-empty" FAKE_LOG="$tmp/requests" FAKE_AUTH="sntrys_eyJpYXQiOjE3NjI4MDczODAuMTIz+/=_AbC123+/AbC123+/AbC123+/AbC123+/AbC123+/AbC1" FAKE_SECRETS="" \
./sentry/thread read <"$tmp/case_read-empty/in" >"$tmp/stdout" 2>"$tmp/stderr"
[ "$?" = 0 ] && [ "$(cat "$tmp/stdout")" = '{"messages":[]}' ] && grep -q '^ auth: ok$' "$tmp/requests"
ok "thread takes an organization token, with its + / and =" $? "$(head -c 200 "$tmp/stderr")"
kill "$standin_pid" 2>/dev/null
standin_pid=
# The pack writes two things in Sentry and no more: of every request the
# cases made, the writes are a POST of a comment and a PUT of an issue's
# status, each on the organization's path, and no request is a DELETE.
methods=$(awk '/^[A-Z]+ /{ print $1 }' "$tmp/allrequests" | sort -u | tr '\n' ' ')
[ "$methods" = "GET POST PUT " ]
ok "no request of any case is anything but a GET, a POST or a PUT" $? "methods seen: $methods"
bad=$(awk '$1 == "POST" && $2 !~ /^https:\/\/sentry\.io\/api\/0\/organizations\/[a-z0-9_-]+\/issues\/[0-9]+\/comments\/$/ || $1 == "PUT" && $2 !~ /^https:\/\/sentry\.io\/api\/0\/organizations\/[a-z0-9_-]+\/issues\/[0-9]+\/$/ { print $1, $2 }' "$tmp/allrequests" | grep -v '127\.0\.0\.1' | head -3)
[ -z "$bad" ]
ok "every POST is a comment on an issue and every PUT an issue, under its organization" $? "$bad"
! grep -q '/api/0/issues/' "$tmp/allrequests"
ok "no request goes to the deprecated path without the organization" $?
# ---- core ------------------------------------------------------------------
# Through the real yardr, in a scratch yard (testdata/core.sh).
. ./testdata/core.sh
if [ "$fail" = 0 ]; then echo "test: sentry ok"; else echo "test: sentry FAILED"; fi
exit $fail
#!/bin/bash
# A stand-in curl for the thread tests, put first on the program's PATH. It
# makes no request. sentry/thread uses curl in one way only,
#
# curl -sS -o FILE -D FILE -w '%{http_code}' -K - -X METHOD
# [-H HEADER] [--data-binary @FILE] URL
#
# and any other word is refused, so a changed invocation fails the test
# instead of passing unseen. It records each request in $FAKE_LOG and answers
# from the case's fixtures: the nth request gets $FAKE_CASE/answer_n, whose
# first line is the status and the rest the body, and the response headers
# $FAKE_CASE/headers_n where there is one (a case with no answer for it
# answers 599, and a file named curl_exit_n makes the nth request fail as a
# refused connection does).
#
# The credential comes on the stdin config line, as the real program sends
# it. The log says only whether it was the one expected ($FAKE_AUTH) and
# whether any argument held a secret ($FAKE_SECRETS): the test fails if a
# secret reaches an argument, and the log never holds one.
set -u
args="$*"
config=$(cat)
out='' dump='' fmt='' method='' url='' data='' hdrs=''
while [ $# -gt 0 ]; do
case $1 in
-sS) ;;
-o) out=$2; shift ;;
-D) dump=$2; shift ;;
-w) fmt=$2; shift ;;
-K) [ "$2" = - ] || { echo "stand-in curl: the token is to come on stdin (-K -)" >&2; exit 2; }; shift ;;
-X) method=$2; shift ;;
-H) hdrs="$hdrs header: $2
"; shift ;;
--data-binary)
case $2 in
@*) data=$(cat "${2#@}") ;;
*) echo "stand-in curl: the data is to be a file (--data-binary @FILE)" >&2; exit 2 ;;
esac
shift ;;
-*) echo "stand-in curl: no option $1" >&2; exit 2 ;;
*)
[ -z "$url" ] || { echo "stand-in curl: two urls" >&2; exit 2; }
url=$1 ;;
esac
shift
done
[ "$fmt" = '%{http_code}' ] || { echo "stand-in curl: -w is to be the status code" >&2; exit 2; }
[ -n "$out" ] && [ -n "$dump" ] && [ -n "$method" ] && [ -n "$url" ] || { echo "stand-in curl: wants -o, -D, -X and a url" >&2; exit 2; }
n=$(cat "$FAKE_LOG.n" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" >"$FAKE_LOG.n"
got=$(printf '%s\n' "$config" | sed -n 's/^header = "Authorization: Bearer \(.*\)"$/\1/p')
if [ "$got" = "$FAKE_AUTH" ]; then auth=ok; elif [ -z "$got" ]; then auth=missing; else auth=WRONG; fi
leak=
for s in $FAKE_SECRETS; do
case $args in *"$s"*) leak=" LEAK: an argument holds a secret
" ;; esac
done
{
printf '%s %s\n' "$method" "$url"
printf ' auth: %s\n' "$auth"
printf '%s' "$hdrs"
[ -z "$data" ] || printf ' body: %s\n' "$(printf '%s' "$data" | jq -S -c . 2>/dev/null || echo "NOT JSON")"
printf '%s' "$leak"
} >>"$FAKE_LOG"
if [ -e "$FAKE_CASE/curl_exit_$n" ]; then
echo "curl: (7) Failed to connect to stand-in" >&2
exit 7
fi
: >"$dump"
if [ -r "$FAKE_CASE/answer_$n" ]; then
status=$(sed -n 1p "$FAKE_CASE/answer_$n")
sed 1d "$FAKE_CASE/answer_$n" >"$out"
[ ! -r "$FAKE_CASE/headers_$n" ] || cp "$FAKE_CASE/headers_$n" "$dump"
else
status=599
printf '%s' '{"detail":"the stand-in has no answer for this request"}' >"$out"
fi
printf '%s' "$status"
200
{"version":"0","auth":{"scopes":["event:read","event:write"]},"user":{"id":"9","name":"yardr","email":"hidden@example.com"}}
401
{"detail":"Invalid token"}
--- output yardr: source sentry: the check call scopes (GET /) was answered 401: the service does not take what SENTRY_TOKEN is set to --- status 1 --- requests GET http://<stand-in>/api/0/ auth: ok
--- output
sentry: credential: SENTRY_TOKEN is set in <yard>/y/sources/sentry.env
sentry: check call scopes (GET /): 200 in <n>ms, 125 bytes
sentry: auth: {"scopes":["event:read","event:write"]}
sentry: command thread.reply (sentry/thread reply): can be run
sentry: command thread.posted (sentry/thread posted): can be run
sentry: command thread.read (sentry/thread read): can be run
sentry: command thread.state (sentry/thread state): can be run
sentry: command hook.normalize (sentry/normalize): can be run
--- status
0
--- requests
GET http://<stand-in>/api/0/
auth: ok
201
{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":null,"type":"note","data":{"text":"x","comment_id":7050},"dateCreated":"2026-11-07T10:02:11.482113Z","source":null}
--- output already posted as http://<stand-in>/organizations/example-org/issues/1234567890/; not posted again http://<stand-in>/organizations/example-org/issues/1234567890/ --- status 0 --- requests GET http://<stand-in>/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100 auth: ok
--- output
yardr: reply to event 12 in sentry: thread.reply of source sentry: exit status 1; its stderr ended: http://<stand-in>/api/0/organizations/example-org/issues/1234567890/comments/: HTTP 500: the stand-in broke
--- status
1
--- requests
POST http://<stand-in>/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"A reply that landed and was lost.\n\n<!-- yardr-reply <key> -->"}
--- output
http://<stand-in>/organizations/example-org/issues/1234567890/
--- status
0
--- requests
POST http://<stand-in>/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply <key> -->"}
200
[{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":null,"type":"note","data":{"text":"Fix landed in 4d2c1ab.","comment_id":7050},"dateCreated":"2026-11-07T10:02:11Z","source":null},{"id":"7042","commentId":"7042","user":{"id":"1","name":"Ada Lovelace","username":"ada"},"sentry_app":null,"type":"note","data":{"text":"Looking into it.","comment_id":7042},"dateCreated":"2026-11-07T09:15:30.123456Z","source":null}]
--- output
sentry thread example-org#1234567890 "Error generated with event_id: 495d375a-1df6-45c0-9890-34dae8e1b6a4(Priority: HIGH)" https://sentry.io/organizations/example-org/issues/1234567890/
2 message(s)
7042 2026-11-07 09:15 Ada Lovelace (1)
Looking into it.
7050 2026-11-07 10:02 yardr (9)
Fix landed in 4d2c1ab.
--- status
0
--- requests
GET http://<stand-in>/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"ongoing","statusDetails":{}}
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{}}
--- output
sentry thread example-org#1234567890 is now resolved (was unresolved)
--- status
0
--- requests
GET http://<stand-in>/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT http://<stand-in>/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"resolved"}
# The core part of the sentry test, sourced by ./test: the declaration as the
# yard reads it, deliveries made to the yard's own listener (signed as Sentry
# signs them, and not), the links a bead makes to an issue, the stage map, and
# the check call and the thread commands made through the real yardr against a
# stand-in on a loopback port (testdata/standin.py). Everything runs in a
# scratch yard under a short path (a unix socket's path is held to 100 bytes)
# with its own home and an environment of nothing but PATH. No daemon is
# started, so no agent is, and the only listeners are the yard's own hook
# listener, on 127.0.0.1 and a port found free here (yardr takes no port 0),
# and the stand-in's, on a port the system chooses.
#
# ./test has set $YARDR, $here, $tmp, $token, check, ok and cleanup (which
# stops the stand-in by its pid, stops the scratch yard's server, and removes
# the scratch yard, whatever core is by then).
core=$(mktemp -d /tmp/sentryt.XXXXXX)
mkdir -p "$core/h"
#
# The zone is UTC, so that the times thread show prints are the same on every
# machine.
yrun() { env -i HOME="$core/h" PATH="$PATH" TZ=UTC "$YARDR" --home "$core/y" --actor tester "$@"; }
# yrunc is yrun with the token in the command's environment, as an agent's
# pane has it: the thread commands are made from the process that runs them.
yrunc() { env -i HOME="$core/h" PATH="$PATH" TZ=UTC SENTRY_TOKEN="$token" "$YARDR" --home "$core/y" --actor tester "$@"; }
# The integration's client secret, which no file of the run may hold but the
# one the hook names.
secret=signing_canary_7c1d5e9f3a2b4c6d8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d
# sign <secret>: Sentry's documented signature of the body on stdin, the hex
# HMAC-SHA256 of its exact bytes under the secret (docs.sentry.io, Webhooks,
# Verifying the Signature).
sign() {
SECRET=$1 python3 -I -c 'import hashlib, hmac, os, sys; print(hmac.new(os.environ["SECRET"].encode("utf-8"), sys.stdin.buffer.read(), hashlib.sha256).hexdigest())'
}
# deliver <resource> <request id> <timestamp> <signature, or none> <body file>:
# the request Sentry makes to the hook, its status in $code and its answer in
# $core/out.
deliver() {
if [ "$4" = none ]; then
code=$(curl -s -o "$core/out" -w '%{http_code}' -X POST "http://127.0.0.1:$hp/hooks/sentry" \
-H "Request-ID: $2" -H "Sentry-Hook-Resource: $1" -H "Sentry-Hook-Timestamp: $3" \
-H 'Content-Type: application/json' --data-binary @"$5")
else
code=$(curl -s -o "$core/out" -w '%{http_code}' -X POST "http://127.0.0.1:$hp/hooks/sentry" \
-H "Sentry-Hook-Signature: $4" -H "Request-ID: $2" -H "Sentry-Hook-Resource: $1" -H "Sentry-Hook-Timestamp: $3" \
-H 'Content-Type: application/json' --data-binary @"$5")
fi
}
# listen: the yard's server takes webhooks on a free loopback port. yardr
# refuses port 0, so a free port is found first, which something else could
# take before the server does: then another is tried.
listen() {
tries=0
while [ "$tries" -lt 5 ]; do
hp=$(python3 -I -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')
yrun settings set hook_addr "127.0.0.1:$hp" >/dev/null 2>&1
yrun server stop >/dev/null 2>&1
yrun events -n 1 >/dev/null 2>&1 # a command that asks the server starts it, on the address
i=0
while [ "$i" -lt 40 ]; do
[ "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$hp/hooks/none" 2>/dev/null)" != 000 ] && return 0
sleep 0.1
i=$((i + 1))
done
tries=$((tries + 1))
done
return 1
}
# hookevents: the hook events the yard has, as JSON.
hookevents() { yrun events --kind hook --json -n 1000; }
if ! yrun init --harness claude --operator tester >"$core/init.out" 2>&1; then
echo "test: core FAILED: the scratch yard could not be made: $(tail -c 300 "$core/init.out")"
fail=1
elif ! yrun pack apply "$here" --yes >"$core/apply.out" 2>&1; then
echo "test: core FAILED: the pack did not apply from its directory: $(tail -c 300 "$core/apply.out")"
fail=1
else
# -- the declaration as the yard reads it
yrun source show sentry --json | jq -S .declaration >"$core/declaration.json"
check "declaration as the yard reads it (hook, thread, link, calls)" "$core/declaration.json" testdata/declaration.golden
jq -e '.base == "https://sentry.io/api/0" and .check == "scopes" and .credential.env == "SENTRY_TOKEN"
and (.calls | map(.name) == ["scopes"]) and ([.calls[] | .kind == "read" and .request.method == "GET"] | all)
and .hook.verify.scheme == "hmac-sha256" and .hook.verify.signed == "body"
and .hook.verify.signature == {header: "Sentry-Hook-Signature", encoding: "hex"}
and .hook.verify.delivery == {header: "Request-ID"} and (.hook.verify | has("timestamp") | not)
and .hook.bodies == "unique-per-hook" and .hook.untimed == true
and .thread.states == ["started", "done"] and (.thread | has("open") | not) and (has("hear") | not)
and .link.host == "sentry.io"' "$core/declaration.json" >/dev/null
ok "the shipped base is sentry.io, the one call is a GET, the signature is Sentry's (body, hex, no timestamp), and the thread has no open" $?
# -- a delivery to the yard's own listener, signed as Sentry signs it
umask 077
printf '%s' "$secret" >"$core/secret"
umask 022
yrun hook add sentry --source sentry --secret-file "$core/secret" >"$core/hook.out" 2>&1
ok "the hook is added for the source the pack declares" $? "$(tail -c 300 "$core/hook.out")"
if ! listen; then
echo "test: core FAILED: the yard's server did not take webhooks on a free loopback port"
fail=1
else
jq -c .body testdata/hook/issue-created.json >"$core/created.body"
sig=$(sign "$secret" <"$core/created.body")
rid=5d1e0c3f9b7a4c26a8e3f1b2d4c6a790
deliver issue "$rid" 1762807380 "$sig" "$core/created.body"
[ "$code" = 200 ] && jq -e 'has("seq") and (has("duplicate") | not)' "$core/out" >/dev/null
ok "a delivery signed as Sentry signs it is taken" $? "status $code: $(head -c 200 "$core/out")"
seq=$(jq -r .seq "$core/out")
# A duplicate is answered 200, with the seq of the first, and leaves no
# event.
deliver issue "$rid" 1762807380 "$sig" "$core/created.body"
[ "$code" = 200 ] && jq -e --argjson s "$seq" '.duplicate == true and .seq == $s' "$core/out" >/dev/null
ok "the same delivery again is answered 200 as the first's duplicate (told by the Request-ID)" $? "status $code: $(head -c 200 "$core/out")"
# Sentry makes a new Request-ID and a new timestamp for a retry, and
# repeats the body: the body tells it.
deliver issue 0123456789abcdef0123456789abcdef 1762807999 "$sig" "$core/created.body"
[ "$code" = 200 ] && jq -e --argjson s "$seq" '.duplicate == true and .seq == $s' "$core/out" >/dev/null
ok "a retry with a new Request-ID and timestamp and the same body is answered 200 as a duplicate (told by the body)" $? "status $code: $(head -c 200 "$core/out")"
# Refused: each is answered 401 and left no event (the count below).
{ cat "$core/created.body"; printf x; } >"$core/tampered.body"
deliver issue aaaaaaaaaaaa4c26a8e3f1b2d4c6a790 1762807380 "$sig" "$core/tampered.body"
[ "$code" = 401 ]
ok "a body that was changed after it was signed is refused with 401" $? "status $code"
sigw=$(sign "another-secret-entirely" <"$core/created.body")
deliver issue bbbbbbbbbbbb4c26a8e3f1b2d4c6a790 1762807380 "$sigw" "$core/created.body"
[ "$code" = 401 ]
ok "a body signed with another secret is refused with 401" $? "status $code"
deliver issue cccccccccccc4c26a8e3f1b2d4c6a790 1762807380 none "$core/created.body"
[ "$code" = 401 ]
ok "a body with no signature is refused with 401" $? "status $code"
# Sentry signs the exact bytes: the same JSON laid out another way is
# another body.
jq . "$core/created.body" >"$core/pretty.body"
deliver issue dddddddddddd4c26a8e3f1b2d4c6a790 1762807380 "$sig" "$core/pretty.body"
[ "$code" = 401 ]
ok "the same JSON laid out another way, under the signature of the bytes first sent, is refused with 401" $? "status $code"
jq -c .body testdata/hook/event-alert.json >"$core/alert.body"
sig2=$(sign "$secret" <"$core/alert.body")
deliver event_alert c0a7d2e94b1f4a85960e3d7b5f1a2c48 1762807502 "$sig2" "$core/alert.body"
[ "$code" = 200 ]
ok "an alert rule's event signed as Sentry signs it is taken" $? "status $code: $(head -c 200 "$core/out")"
aseq=$(jq -r .seq "$core/out")
# The yard's own resolve comes back as an issue delivery of action
# resolved, by the integration. Its timestamp is Sentry's unsigned
# header, which nothing here reads.
jq -c .body testdata/hook/issue-resolved.json >"$core/resolved.body"
sig3=$(sign "$secret" <"$core/resolved.body")
deliver issue f3e1b5a7c9d24680a1b2c3d4e5f60718 1 "$sig3" "$core/resolved.body"
[ "$code" = 200 ]
ok "an issue resolved, with a timestamp from 1970, is taken: the timestamp is not signed and not read" $? "status $code"
rseq=$(jq -r .seq "$core/out")
# What the yard recorded, in one look: the three deliveries taken, and
# no duplicate and no refused one.
hookevents >"$core/hookevents.json"
jq -e 'map(.data.type) == ["issue", "event_alert", "issue"]' "$core/hookevents.json" >/dev/null
ok "the yard recorded the three deliveries taken, and no duplicate and no refused one" $? "$(jq -c 'map([.seq, .data.type, .data.action])' "$core/hookevents.json")"
for pair in "$seq:issue-created" "$aseq:event-alert"; do
s=${pair%%:*}
f=${pair#*:}
jq -S --argjson s "$s" '.[] | select(.seq == $s) | .data | del(.delivery, .hook, .deliveries, .new_mentions)' "$core/hookevents.json" >"$core/event.json"
jq -S . "testdata/hook/$f.golden" >"$core/golden.json"
cmp -s "$core/event.json" "$core/golden.json"
ok "the event the yard records for $f is the envelope normalize printed" $? "$(diff "$core/golden.json" "$core/event.json" | head -6)"
done
[ "$(jq -r --argjson s "$seq" '.[] | select(.seq == $s) | .data.delivery' "$core/hookevents.json")" = "$rid" ]
ok "the delivery's id is Sentry's Request-ID" $?
jq -e --argjson s "$rseq" '.[] | select(.seq == $s) | .data | .action == "resolved" and .thread == {} and (has("text") | not)' "$core/hookevents.json" >/dev/null
ok "the yard's own resolve coming back is recorded with no thread, so it is never a new issue" $?
yrun hook list >"$core/hooklist.out" 2>&1
grep -q ' ok ' "$core/hooklist.out" && grep -q 'unauthorized' "$core/hooklist.out"
ok "hook list shows the hook ok, with the refusals as unauthorized" $? "$(tail -c 300 "$core/hooklist.out")"
fi
# -- a bead names the issue: by a typed address, and by the delivery that
# heard it
# One bead takes a relates link for each address, which keeps the one fixed
# form as a tracks link does and allows any number of them; what link add
# prints names it.
nl=0
lbead=$(yrun bead create "the addresses people type" --json 2>/dev/null | jq -r .id)
while IFS=' ' read -r url want <&3; do
case $url in '' | '#'*) continue ;; esac
nl=$((nl + 1))
if yrun link add "$lbead" "$url" --relates >"$core/link.out" 2>&1; then
got=$(sed -n 's/^.* -relates-> \(.*\) (sentry)$/\1/p' "$core/link.out")
else
got=-
fi
if [ "$got" = "$want" ]; then
echo "test: link $url ok"
else
echo "test: link $url: got '$got', want '$want': $(tail -c 200 "$core/link.out")"
fail=1
fi
done 3<testdata/link.txt
seq=${seq:-0}
bead=$(yrun bead create "tracks the heard issue" --json 2>/dev/null | jq -r .id)
yrun link add "$bead" "$seq" >"$core/link.out" 2>&1
[ "$(yrun link list "$bead" --json | jq -r '.[0].url + " " + .[0].source')" = "https://sentry.io/organizations/example-org/issues/1234567890 sentry" ]
ok "a bead is linked to the issue a delivery names, by the delivery's number" $? "$(tail -c 200 "$core/link.out")"
bead2=$(yrun bead create "a second tracker" --json 2>/dev/null | jq -r .id)
yrun link add "$bead2" https://sentry.io/organizations/Example-Org/issues/1234567890/ >"$core/dup.out" 2>&1
[ $? != 0 ] && grep -q "$bead" "$core/dup.out"
ok "the same issue typed in another case has the one tracker, which the refusal names" $? "$(tail -c 200 "$core/dup.out")"
aseq=${aseq:-0}
bead3=$(yrun bead create "made to track an alert's issue" --tracks "$aseq" --json 2>/dev/null | jq -r .id)
[ "$(yrun link list "$bead3" --json | jq -r '.[0].url')" = "https://sentry.io/organizations/test-org/issues/1117540176" ]
ok "a bead made with --tracks of an alert's delivery tracks that alert's issue" $?
# -- the commands SETUP.md teaches that the yard can run at once, run as
# written, in the order it gives them: the hook, the crew member's identity
# and grant, the subscriptions with the keys they filter by, and the hook
# again with the map that resolves an issue when its bead lands. They are
# read out of SETUP.md and not copied here: a copy would pass whatever SETUP
# teaches. A command that ends in a backslash goes on in the next line, and
# <name> is the crew member the yard starts with. An address typed in a
# step is its own.
awk '{ line = $0; sub(/^ +/, "", line)
buf = cont ? buf " " line : line
if (buf ~ /\\$/) { sub(/ *\\$/, "", buf); cont = 1; next }
cont = 0
if (buf ~ /^yardr (hook add sentry |subscribe |crew identity |crew set <name> set_state=)/) print buf
buf = "" }' SETUP.md | sed -e 's/^yardr //' -e 's/<name>/yardmaster/g' -e 's/<integration name>/yardr/g' -e "s|~/.yardr/sentry.secret|$core/secret|g" >"$core/taught"
if [ "$(wc -l <"$core/taught")" -lt 6 ]; then
echo "test: SETUP.md teaches fewer commands than the test looks for: $(tr '\n' '|' <"$core/taught")"
fail=1
fi
while IFS= read -r cmd <&4; do
# The words are plain, so $cmd is split on purpose, and its stdin is
# /dev/null so that it cannot read the rest of the list.
# shellcheck disable=SC2086
if yrun $cmd </dev/null >"$core/out" 2>&1; then
echo "test: SETUP.md: yardr $cmd ok"
else
echo "test: SETUP.md: yardr refused 'yardr $cmd', which SETUP.md teaches: $(tail -c 300 "$core/out")"
fail=1
fi
done 4<"$core/taught"
# -- the stage map: a bead that lands resolves its issue
yrun hook add sentry --source sentry --secret-file "$core/secret" --as yardmaster --map task:new=started --map task:merged=done >"$core/map.out" 2>&1
grep -q 'moves tracked sentry issues as yardmaster: task:merged=done task:new=started' "$core/map.out"
ok "the hook takes a stage map: merged is done (resolved) and new is started (unresolved)" $? "$(tail -c 300 "$core/map.out")"
yrun hook add sentry --source sentry --secret-file "$core/secret" --as yardmaster --map task:review=review >"$core/map2.out" 2>&1
[ $? != 0 ]
ok "a map to review is refused: Sentry has no such status" $? "$(tail -c 300 "$core/map2.out")"
# -- the check call and the thread commands, through the real yardr. The
# operator's copy is the declaration with its base replaced, as SETUP.md's
# last step makes one for another Sentry; here the Sentry is the stand-in.
sd=$core/sd
mkdir -p "$sd" "$core/mine"
python3 -I testdata/standin.py "$sd" 2>"$core/standin.err" <<EOF &
$token
EOF
standin_pid=$!
i=0
while [ ! -s "$sd/port" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i + 1)); done
if [ ! -s "$sd/port" ]; then
echo "test: core FAILED: the stand-in did not listen: $(cat "$core/standin.err")"
fail=1
else
port=$(cat "$sd/port")
cp -R "$here/sentry" "$core/mine/"
sed "s|^base: .*|base: http://127.0.0.1:$port/api/0|" "$here/sentry.yaml" >"$core/mine/sentry.yaml"
# The credential is given as SETUP.md gives it: yardr source key, here
# with the line on its standard input, which the server writes into the
# source's env file.
yrun source add "$core/mine/sentry.yaml" >"$core/add.out" 2>&1 \
&& printf 'SENTRY_TOKEN=%s\n' "$token" | yrun source key sentry >>"$core/add.out" 2>&1
ok "the operator's copy is added and its credential given by source key" $? "$(tail -c 300 "$core/add.out")"
# against <name> <case dir> <command...>: one command of the yard, with
# the stand-in answering as the case does; its words, its status and the
# requests the stand-in saw are the golden testdata/core/<name>.golden.
# The stand-in's port, the scratch yard's path, a call's milliseconds and
# a reply's key are masked.
against() {
name=$1
cdir=$2
shift 2
printf '%s\n' "$cdir" >"$sd/case"
rm -f "$sd/n"
: >"$sd/requests"
"$@" >"$core/cli.out" 2>&1
cli_status=$?
{
echo "--- output"
cat "$core/cli.out"
echo "--- status"
echo "$cli_status"
echo "--- requests"
cat "$sd/requests"
} | sed -e "s|127.0.0.1:$port|<stand-in>|g" -e "s|$core|<yard>|g" -e 's/ in [0-9][0-9]*ms/ in <n>ms/g' -e 's/yardr-reply [0-9a-f-]\{36\}/yardr-reply <key>/g' >"$core/$name.got"
check "core $name" "$core/$name.got" "testdata/core/$name.golden"
}
against check "$here/testdata/core/check" yrun source check sentry
grep -q 'event:write' "$core/cli.out" && ! grep -q 'hidden@example.com' "$core/cli.out"
ok "source check shows the scopes of the token, and nothing else of the answer" $?
against check-refused "$here/testdata/core/check-refused" yrun source check sentry
[ "$cli_status" != 0 ]
ok "source check fails on a token Sentry does not take" $?
# The status of an issue: the crew member takes part in its thread, and
# may set a status in sentry, which only the operator grants.
yrun crew set yardmaster set_state=sentry >/dev/null 2>&1
yrun thread follow yardmaster --source sentry 'example-org#1234567890' >/dev/null 2>&1
against state "$here/testdata/core/state" yrunc thread state "$seq" done --crew yardmaster
[ "$cli_status" = 0 ] && grep -q 'is now resolved (was unresolved)' "$core/cli.out"
ok "thread state done by the delivery's number resolves the issue" $?
yrun events --kind thread_state --json | jq -e '.[-1].data | .state == "done" and .status == "resolved" and .was == "unresolved" and .thread == "example-org#1234567890"' >/dev/null
ok "and the yard logs it as a thread_state event" $?
against reply "$here/testdata/core/reply" yrunc reply "$seq" "Fix landed in 4d2c1ab." --crew yardmaster
[ "$cli_status" = 0 ]
ok "a reply is posted as a comment" $?
# A reply whose answer was lost: the first try fails, and the same reply
# again finds the comment its key left and posts nothing.
mkdir -p "$core/lost" "$core/found"
printf '500\n{"detail":"the stand-in broke"}\n' >"$core/lost/answer_1"
against reply-lost "$core/lost" yrunc reply "$seq" "A reply that landed and was lost." --crew yardmaster
[ "$cli_status" != 0 ]
ok "a reply Sentry answers 500 is no success" $?
key=$(sed -n 's/.*yardr-reply \([0-9a-f-]\{36\}\).*/\1/p' "$sd/requests" | head -n 1)
printf '200\n[{"id":"7060","commentId":"7060","user":null,"sentry_app":null,"type":"note","data":{"text":"A reply that landed and was lost.\\n\\n<!-- yardr-reply %s -->"},"dateCreated":"2026-11-07T10:30:00Z"}]\n' "$key" >"$core/found/answer_1"
against reply-found "$core/found" yrunc reply "$seq" "A reply that landed and was lost." --crew yardmaster
[ "$cli_status" = 0 ] && [ "$(grep -c '^POST ' "$sd/requests")" = 0 ] && [ "$(grep -c '^GET ' "$sd/requests")" = 1 ]
ok "the same reply again finds the comment its key left, and posts nothing" $?
against show "$here/testdata/core/show" yrunc thread show "$seq"
[ "$cli_status" = 0 ]
ok "thread show reads the comments of the issue" $?
# -- the token is where the yard keeps it and nowhere else, and so is
# the signing secret
kill "$standin_pid" 2>/dev/null
standin_pid=
leaks=$(grep -rlF -e "$token" "$core" 2>/dev/null | grep -v -e "^$core/y/sources/sentry.env\$" | head -5)
[ -z "$leaks" ]
ok "the token is in no file of the run but the source's env file (the stand-in's goldens and the thread commands' output hold none)" $? "found in: $leaks"
grep -qF "$token" "$core/y/sources/sentry.env"
ok "(and that env file does hold the token, so the search above looked at something)" $?
leaks=$(grep -rlF -e "$secret" "$core" 2>/dev/null | grep -v -e "^$core/secret\$" | head -5)
[ -z "$leaks" ]
ok "the signing secret is in no file of the run but the one the hook names" $? "found in: $leaks"
! yrun events --json | grep -qF -e "$secret" -e "$token"
ok "neither is in any event the yard logged" $?
fi
fi
[ -z "${standin_pid:-}" ] || { kill "$standin_pid" 2>/dev/null; standin_pid=; }
{
"about": "Sentry issues. Hears an internal integration's webhook for a new issue and for an alert rule, and speaks in the issue: a comment, resolve, unresolve.",
"base": "https://sentry.io/api/0",
"calls": [
{
"about": "What the credential may do, as the scopes its token holds.",
"answer": {
"max": 65536,
"select": [
"auth"
]
},
"kind": "read",
"name": "scopes",
"request": {
"method": "GET",
"path": "/"
}
}
],
"check": "scopes",
"credential": {
"env": "SENTRY_TOKEN",
"header": "Authorization: Bearer {credential}"
},
"hook": {
"bodies": "unique-per-hook",
"keys": [
"level",
"category",
"outsider"
],
"normalize": [
"sentry/normalize"
],
"untimed": true,
"verify": {
"delivery": {
"header": "Request-ID"
},
"scheme": "hmac-sha256",
"signature": {
"encoding": "hex",
"header": "Sentry-Hook-Signature"
},
"signed": "body"
}
},
"link": {
"fixed": "https://sentry.io/organizations/{1}/issues/{2}",
"fold": "lower",
"host": "sentry.io",
"issue": "^/organizations/([A-Za-z0-9_][A-Za-z0-9_-]*)/issues/([1-9][0-9]*)(?:/.*)?$",
"thread": "{1}#{2}"
},
"name": "sentry",
"thread": {
"posted": [
"sentry/thread",
"posted"
],
"read": [
"sentry/thread",
"read"
],
"reply": [
"sentry/thread",
"reply"
],
"state": [
"sentry/thread",
"state"
],
"states": [
"started",
"done"
]
}
}
{"source":"sentry","type":"comment","action":"created","actor":{"id":"1","name":"colleen"},"thread":{},"mentions":[]}
{
"headers": {
"Content-Type": ["application/json"],
"Request-Id": ["0a1b2c3d4e5f46789a0b1c2d3e4f5a6b"],
"Sentry-Hook-Resource": ["comment"],
"Sentry-Hook-Signature": ["0000000000000000000000000000000000000000000000000000000000000000"],
"Sentry-Hook-Timestamp": ["1762807650"],
"User-Agent": ["Sentry Integration Platform/1.0"]
},
"body": {
"action": "created",
"data": {
"comment": "adding a comment",
"project_slug": "sentry",
"comment_id": 1234,
"issue_id": 100,
"timestamp": "2022-03-02T21:51:44.118160Z"
},
"installation": {"uuid": "eac5a0ae-60ec-418f-9318-46dc5e7e52ec"},
"actor": {"type": "user", "id": 1, "name": "colleen"}
}
}
{"source":"sentry","type":"event_alert","action":"triggered","actor":{"id":"sentry","name":"Sentry"},"thread":{"id":"test-org#1117540176","title":"ReferenceError: heck is not defined","url":"https://sentry.io/organizations/test-org/issues/1117540176/"},"mentions":[],"text":"alert \"Very Important Alert!\": error event in ?(<anonymous>)","fields":{"project":["front-end"],"level":["error"],"outsider":["yes"]},"url":"https://sentry.io/organizations/test-org/issues/1117540176/events/e4874d664c3540c1a32eab185f12c5ab/"}
{
"headers": {
"Content-Type": ["application/json"],
"Request-Id": ["c0a7d2e94b1f4a85960e3d7b5f1a2c48"],
"Sentry-Hook-Resource": ["event_alert"],
"Sentry-Hook-Signature": ["0000000000000000000000000000000000000000000000000000000000000000"],
"Sentry-Hook-Timestamp": ["1762807502"],
"User-Agent": ["Sentry Integration Platform/1.0"]
},
"body": {
"action": "triggered",
"actor": {
"id": "sentry",
"name": "Sentry",
"type": "application"
},
"data": {
"event": {
"_ref": 1,
"_ref_version": 2,
"contexts": {
"browser": {"name": "Chrome", "type": "browser", "version": "75.0.3770"},
"os": {"name": "Mac OS X", "type": "os", "version": "10.14.0"}
},
"culprit": "?(<anonymous>)",
"datetime": "2019-08-19T21:06:17.677000Z",
"dist": null,
"event_id": "e4874d664c3540c1a32eab185f12c5ab",
"exception": {
"values": [
{
"mechanism": {"data": {"message": "heck is not defined", "mode": "stack", "name": "ReferenceError"}, "handled": false, "type": "onerror"},
"stacktrace": {
"frames": [
{"abs_path": "<anonymous>", "colno": 5, "filename": "<anonymous>", "in_app": false, "lineno": 3}
]
},
"type": "ReferenceError",
"value": "heck is not defined"
}
]
},
"fingerprint": ["{{ default }}"],
"hashes": ["29f7ffc4903a8a990408b80a3b4c95a2"],
"issue_url": "https://sentry.io/api/0/issues/1117540176/",
"issue_id": "1117540176",
"key_id": "667532",
"level": "error",
"location": "<anonymous>",
"logger": "",
"message": "",
"metadata": {"filename": "<anonymous>", "type": "ReferenceError", "value": "heck is not defined"},
"platform": "javascript",
"project": 1,
"received": 1566248777.677,
"release": null,
"request": {"url": "https://null.jsbin.com/runner"},
"tags": [
["browser", "Chrome 75.0.3770"],
["handled", "no"],
["level", "error"],
["url", "https://null.jsbin.com/runner"]
],
"time_spent": null,
"timestamp": 1566248777.677,
"title": "ReferenceError: heck is not defined",
"type": "error",
"url": "https://sentry.io/api/0/projects/test-org/front-end/events/e4874d664c3540c1a32eab185f12c5ab/",
"user": {"ip_address": "162.218.85.90"},
"version": "7",
"web_url": "https://sentry.io/organizations/test-org/issues/1117540176/events/e4874d664c3540c1a32eab185f12c5ab/"
},
"triggered_rule": "Very Important Alert!",
"issue_alert": {
"title": "Very Important Alert!",
"settings": [{"name": "channel", "value": "#general"}]
}
},
"installation": {
"uuid": "a8e5d37a-696c-4c54-adb5-b3f28d64c7de"
}
}
}
{"source":"sentry","type":"issue","action":"created","actor":{"id":"sentry","name":"Sentry"},"thread":{"id":"test-org#1170","title":"ZeroDivisionError: division by zero second line of the message","url":"https://sentry.io/organizations/test-org/issues/1170/"},"mentions":[],"text":"new error issue BAR-1A in bar: raven.scripts.runner in main","fields":{"project":["bar"],"level":["error"],"outsider":["yes"]},"url":"https://sentry.io/organizations/test-org/issues/1170/","entity":"test-org#1170"}
{
"headers": {
"Content-Type": ["application/json"],
"Request-Id": ["8b2f6a4e1c9d4073b5a1e8f2c3d4b5a6"],
"Sentry-Hook-Resource": ["issue"],
"Sentry-Hook-Signature": ["0000000000000000000000000000000000000000000000000000000000000000"],
"Sentry-Hook-Timestamp": ["1762807441"],
"User-Agent": ["Sentry Integration Platform/1.0"]
},
"body": {
"action": "created",
"installation": {
"uuid": "a8e5d37a-696c-4c54-adb5-b3f28d64c7de"
},
"data": {
"issue": {
"url": "https://sentry.io/api/0/issues/1170/",
"web_url": "https://sentry.io/organizations/test-org/issues/1170/",
"project_url": "https://sentry.io/organizations/test-org/issues/?project=1",
"id": "1170",
"shareId": null,
"shortId": "BAR-1A",
"title": "ZeroDivisionError: division by zero\nsecond line of the message",
"culprit": "raven.scripts.runner in main",
"permalink": null,
"logger": null,
"level": "error",
"status": "unresolved",
"statusDetails": {},
"isPublic": false,
"platform": "python",
"project": {
"id": "1",
"name": "Bar",
"slug": "bar",
"platform": "python"
},
"type": "default",
"metadata": {
"type": "ZeroDivisionError",
"value": "division by zero"
},
"numComments": 0,
"assignedTo": null,
"isBookmarked": false,
"isSubscribed": false,
"hasSeen": false,
"isUnhandled": false,
"count": "1",
"userCount": 0,
"firstSeen": "2018-11-06T21:19:55.121Z",
"lastSeen": "2018-11-06T21:19:55.121Z"
}
},
"actor": {
"type": "application",
"id": "sentry",
"name": "Sentry"
}
}
}
{"source":"sentry","type":"issue","action":"created","actor":{"id":"example-app","name":"Example App"},"thread":{"id":"example-org#1234567890","title":"Error generated with event_id: 495d375a-1df6-45c0-9890-34dae8e1b6a4(Priority: HIGH)","url":"https://sentry.io/organizations/example-org/issues/1234567890/"},"mentions":[],"text":"new fatal issue PYTHON-Y in python: test-transaction-0-41e49cd3-7252-441f-8d27-63a9ad697b0a","fields":{"project":["python"],"level":["fatal"],"category":["error"],"outsider":["yes"]},"url":"https://sentry.io/organizations/example-org/issues/1234567890/","entity":"example-org#1234567890"}
{
"headers": {
"Content-Type": ["application/json"],
"Request-Id": ["5d1e0c3f9b7a4c26a8e3f1b2d4c6a790"],
"Sentry-Hook-Resource": ["issue"],
"Sentry-Hook-Signature": ["0000000000000000000000000000000000000000000000000000000000000000"],
"Sentry-Hook-Timestamp": ["1762807380"],
"User-Agent": ["Sentry Integration Platform/1.0"]
},
"body": {
"action": "created",
"installation": {
"uuid": "24b397fc-a86e-43ef-9297-949e21b82480"
},
"data": {
"issue": {
"url": "https://sentry.io/api/0/organizations/example-org/issues/1234567890/",
"web_url": "https://example-org.sentry.io/issues/1234567890/",
"project_url": "https://example-org.sentry.io/issues/?project=4509877862268928",
"id": "1234567890",
"shareId": null,
"shortId": "PYTHON-Y",
"title": "Error generated with event_id: 495d375a-1df6-45c0-9890-34dae8e1b6a4(Priority: HIGH)",
"culprit": "test-transaction-0-41e49cd3-7252-441f-8d27-63a9ad697b0a",
"permalink": "https://example-org.sentry.io/issues/1234567890/",
"logger": "edge-function",
"level": "fatal",
"status": "unresolved",
"statusDetails": {},
"substatus": "new",
"isPublic": false,
"platform": "javascript",
"project": {
"id": "112313123123134",
"name": "python",
"slug": "python",
"platform": "python"
},
"type": "default",
"metadata": {
"title": "Error generated with event_id: 495d375a-1df6-45c0-9890-34dae8e1b6a4(Priority: HIGH)",
"sdk": {
"name": "edge-function",
"name_normalized": "other"
},
"severity": 1,
"severity_reason": "log_level_fatal",
"initial_priority": 75
},
"numComments": 0,
"assignedTo": null,
"isBookmarked": false,
"isSubscribed": false,
"subscriptionDetails": null,
"hasSeen": false,
"annotations": [],
"issueType": "error",
"issueCategory": "error",
"priority": "high",
"priorityLockedAt": null,
"seerFixabilityScore": null,
"seerAutofixLastTriggered": null,
"isUnhandled": false,
"count": "3",
"userCount": 3,
"firstSeen": "2025-11-10T20:56:00.679000+00:00",
"lastSeen": "2025-11-10T20:56:00.738000+00:00"
}
},
"actor": {
"type": "application",
"id": "example-app",
"name": "Example App"
}
}
}
{"source":"sentry","type":"issue","action":"resolved","actor":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr"},"thread":{},"mentions":[]}
{
"headers": {
"Content-Type": ["application/json"],
"Request-Id": ["f3e1b5a7c9d24680a1b2c3d4e5f60718"],
"Sentry-Hook-Resource": ["issue"],
"Sentry-Hook-Signature": ["0000000000000000000000000000000000000000000000000000000000000000"],
"Sentry-Hook-Timestamp": ["1762807588"],
"User-Agent": ["Sentry Integration Platform/1.0"]
},
"body": {
"action": "resolved",
"installation": {
"uuid": "24b397fc-a86e-43ef-9297-949e21b82480"
},
"data": {
"issue": {
"url": "https://sentry.io/api/0/organizations/example-org/issues/1234567890/",
"web_url": "https://example-org.sentry.io/issues/1234567890/",
"project_url": "https://example-org.sentry.io/issues/?project=4509877862268928",
"id": "1234567890",
"shortId": "PYTHON-Y",
"title": "Error generated with event_id: 495d375a-1df6-45c0-9890-34dae8e1b6a4(Priority: HIGH)",
"culprit": "test-transaction-0-41e49cd3-7252-441f-8d27-63a9ad697b0a",
"level": "fatal",
"status": "resolved",
"statusDetails": {},
"project": {"id": "112313123123134", "name": "python", "slug": "python", "platform": "python"},
"issueType": "error",
"issueCategory": "error"
}
},
"actor": {
"type": "application",
"id": "5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f",
"name": "yardr"
}
}
}
{
"headers": {
"Content-Type": ["application/json"],
"Request-Id": ["2c3d4e5f6a7b48919c2d3e4f5a6b7c8d"]
},
"body": {"action": "created", "data": {"issue": {"id": "QUOTED-BODY-TEXT"}}}
}
{
"headers": {
"Content-Type": ["application/json"],
"Request-Id": ["1b2c3d4e5f6a47809b1c2d3e4f5a6b7c"],
"Sentry-Hook-Resource": ["issue"]
},
"body": "QUOTED-BODY-TEXT"
}
# One address a person may type, a tab, and what yardr link add keeps of it as # the issue one form, or - where it refuses it. The groups fold to lowercase. https://sentry.io/organizations/acme/issues/1170/ https://sentry.io/organizations/acme/issues/1170 https://sentry.io/organizations/acme/issues/1171 https://sentry.io/organizations/acme/issues/1171 https://sentry.io/organizations/Acme-Corp/issues/1172/events/e4874d664c3540c1a32eab185f12c5ab/?project=1 https://sentry.io/organizations/acme-corp/issues/1172 https://sentry.io/organizations/acme/issues/1173/?query=is%3Aunresolved#activity https://sentry.io/organizations/acme/issues/1173 https://acme.sentry.io/issues/1174/ - https://sentry.io/organizations/acme/issues/ - https://sentry.io/organizations/acme/issues/abc/ - https://sentry.io/organizations/acme/issues/0/ - https://sentry.io/organizations/acme/projects/python/ - https://sentry.io/organizations/acme/alerts/rules/ - https://sentry.io/settings/acme/ - https://sentry.io/issues/1175/ - https://sentry.io/api/0/organizations/acme/issues/1176/ -
# A stand-in for Sentry's REST API, for the second pass of the thread cases:
# the same cases the stand-in curl answers, answered over loopback to the real
# curl, so that what the stand-in curl accepts is shown to be what curl does.
#
# It serves one case at a time, as a case's directory holds it: the nth
# request since the test last set the case gets answer_n, whose first line is
# the status and the rest the body, and headers_n for the response headers
# (a raw header block, as curl -D writes it; the status line and a blank line
# in it are skipped). A request with no answer for it gets 599, as the
# stand-in curl's does. It records each request in the same words as the
# stand-in curl: the method and the address as it arrived, whether the
# credential was the one expected, the Content-Type, and the body as sorted
# compact JSON.
#
# It writes the port it bound, on 127.0.0.1, to the file it is given once it
# listens. The token it expects is the first line of its standard input and
# not an argument, which anyone on the machine could read.
#
# python3 -I standin.py <state dir> < <expected token>
#
# The state dir holds, written by the test: case (the case's directory),
# port (written here) and requests (the log, emptied by the test between
# cases). n is the count of requests of the case, kept here and removed by
# the test.
import http.server
import json
import os
import sys
state = sys.argv[1]
expected = sys.stdin.readline().strip()
def read(name, default=""):
try:
with open(os.path.join(state, name), encoding="utf-8") as f:
return f.read()
except OSError:
return default
class Handler(http.server.BaseHTTPRequestHandler):
def handle_any(self):
case = read("case").strip()
n = int(read("n", "0") or 0) + 1
with open(os.path.join(state, "n"), "w") as f:
f.write(str(n))
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length).decode("utf-8") if length else ""
got = self.headers.get("Authorization")
if got == "Bearer " + expected:
auth = "ok"
elif got is None:
auth = "missing"
else:
auth = "WRONG"
lines = ["%s http://127.0.0.1:%s%s" % (self.command, self.server.server_address[1], self.path), " auth: %s" % auth]
if self.headers.get("Content-Type"):
lines.append(" header: Content-Type: %s" % self.headers["Content-Type"])
if body:
try:
shown = json.dumps(json.loads(body), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
except ValueError:
shown = "NOT JSON"
lines.append(" body: %s" % shown)
with open(os.path.join(state, "requests"), "a", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
status, answer, headers = 599, '{"detail":"the stand-in has no answer for this request"}', []
path = os.path.join(case, "answer_%d" % n)
if case and os.path.exists(path):
with open(path, encoding="utf-8") as f:
first, _, rest = f.read().partition("\n")
status, answer = int(first), rest
hpath = os.path.join(case, "headers_%d" % n)
if os.path.exists(hpath):
with open(hpath, encoding="utf-8") as f:
for raw in f.read().splitlines():
name, sep, value = raw.partition(":")
if sep and not raw.startswith("HTTP/"):
headers.append((name.strip(), value.strip()))
data = answer.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
for name, value in headers:
self.send_header(name, value)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = handle_any
def log_message(self, *args):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
with open(os.path.join(state, "port"), "w") as f:
f.write(str(server.server_address[1]))
server.serve_forever()
=== cmd
state
=== in
{"thread":"example-org#1\u001b[31m\nforged log line","state":"done"}
--- requests --- status 1 --- stdout --- stderr thread example-org#1 [31m forged log line: want <organization>#<issue id>
=== cmd
read
=== in
{"thread":"example-org/../../x#1"}
--- requests --- status 1 --- stdout --- stderr thread example-org/../../x#1: want <organization>#<issue id>
=== cmd
reply
=== in
{"thread":"example-org/1234567890","body":"A thread is <organization>#<issue id>.","key":"0a1b2c3d-7777-4222-8333-444455556666"}
--- requests --- status 1 --- stdout --- stderr thread example-org/1234567890: want <organization>#<issue id>
=== cmd
read
=== in
{"thread":"example-org#1234567890"}
=== answer_1
401
{"detail":"\u001b[31mInvalid token @TOKEN@\u001b[0m\nforged log line"}
=== real
--- requests GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100 auth: ok --- status 1 --- stdout --- stderr https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100: HTTP 401: [31mInvalid token <token> [0m forged log line
=== cmd
read
=== in
{"thread":"example-org#1234567890"}
=== curl_exit_1
--- requests GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100 auth: ok --- status 1 --- stdout --- stderr curl: (7) Failed to connect to stand-in could not reach https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100 (curl exit 7)
=== cmd
posted
=== in
{"thread":"example-org#1234567890","key":"00000000-0000-4000-8000-000000000000"}
=== answer_1
200
[{"id":"7061","commentId":"7061","user":{"id":"3","name":"","username":"grace"},"sentry_app":null,"type":"note","data":{"text":"Seen again on the nightly.","comment_id":7061},"dateCreated":"2026-11-07T11:30:00.5Z","source":null}]
=== headers_1
HTTP/2 200
link: <https://sentry.io/x>; rel="next"; results="true"; cursor="0:100:0&x=1 ; rm"
--- requests GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100 auth: ok --- status 1 --- stdout --- stderr https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/: the next page's cursor is not one Sentry makes
=== cmd
posted
=== in
{"thread":"example-org#1234567890","key":"6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f"}
=== answer_1
200
[{"id":"7061","commentId":"7061","user":{"id":"3","name":"","username":"grace"},"sentry_app":null,"type":"note","data":{"text":"Seen again on the nightly.","comment_id":7061},"dateCreated":"2026-11-07T11:30:00.5Z","source":null}]
=== headers_1
HTTP/2 200
content-type: application/json
link: <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:100:0>; rel="next"; results="true"; cursor="0:100:0"
=== answer_2
200
[{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr","slug":"yardr","avatars":[]},"type":"note","data":{"text":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","comment_id":7050},"dateCreated":"2026-11-07T10:02:11Z","source":null},{"id":"7042","commentId":"7042","user":{"id":"1","name":"Ada Lovelace","username":"ada","email":"ada@example.com"},"sentry_app":null,"type":"note","data":{"text":"Looking into it.","comment_id":7042},"dateCreated":"2026-11-07T09:15:30.123456Z","source":null}]
=== headers_2
HTTP/2 200
content-type: application/json
link: <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:100:1>; rel="previous"; results="true"; cursor="0:100:1", <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:200:0>; rel="next"; results="false"; cursor="0:200:0"
=== real
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100&cursor=0:100:0
auth: ok
--- status
0
--- stdout
{"id":"7050","url":"https://sentry.io/organizations/example-org/issues/1234567890/"}
--- stderr
=== cmd
posted
=== in
{"thread":"example-org#1234567890","key":"6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f"}
=== answer_1
200
[{"id":"7061","commentId":"7061","user":{"id":"3","name":"","username":"grace"},"sentry_app":null,"type":"note","data":{"text":"Seen again on the nightly.","comment_id":7061},"dateCreated":"2026-11-07T11:30:00.5Z","source":null},{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr","slug":"yardr","avatars":[]},"type":"note","data":{"text":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","comment_id":7050},"dateCreated":"2026-11-07T10:02:11Z","source":null},{"id":"7042","commentId":"7042","user":{"id":"1","name":"Ada Lovelace","username":"ada","email":"ada@example.com"},"sentry_app":null,"type":"note","data":{"text":"Looking into it.","comment_id":7042},"dateCreated":"2026-11-07T09:15:30.123456Z","source":null}]
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
--- status
0
--- stdout
{"id":"7050","url":"https://sentry.io/organizations/example-org/issues/1234567890/"}
--- stderr
=== cmd
posted
=== in
{"thread":"example-org#1234567890","key":"00000000-0000-4000-8000-000000000000"}
=== answer_1
200
[{"id":"7061","commentId":"7061","user":{"id":"3","name":"","username":"grace"},"sentry_app":null,"type":"note","data":{"text":"Seen again on the nightly.","comment_id":7061},"dateCreated":"2026-11-07T11:30:00.5Z","source":null}]
=== headers_1
HTTP/2 200
content-type: application/json
link: <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:100:0>; rel="next"; results="true"; cursor="0:100:0"
=== answer_2
200
[{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr","slug":"yardr","avatars":[]},"type":"note","data":{"text":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","comment_id":7050},"dateCreated":"2026-11-07T10:02:11Z","source":null},{"id":"7042","commentId":"7042","user":{"id":"1","name":"Ada Lovelace","username":"ada","email":"ada@example.com"},"sentry_app":null,"type":"note","data":{"text":"Looking into it.","comment_id":7042},"dateCreated":"2026-11-07T09:15:30.123456Z","source":null}]
=== headers_2
HTTP/2 200
content-type: application/json
link: <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:100:1>; rel="previous"; results="true"; cursor="0:100:1", <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:200:0>; rel="next"; results="false"; cursor="0:200:0"
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100&cursor=0:100:0
auth: ok
--- status
0
--- stdout
{}
--- stderr
=== cmd
posted
=== in
{"thread":"example-org#1234567890","key":"00000000-0000-4000-8000-000000000000"}
=== answer_1
200
[]
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
--- status
0
--- stdout
{}
--- stderr
=== cmd
posted
=== in
{"thread":"example-org#1234567890","key":"00000000-0000-4000-8000-000000000000"}
=== answer_1
200
{"detail":"QUOTED-BODY-TEXT"}
--- requests GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100 auth: ok --- status 1 --- stdout --- stderr https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/: the answer is no list
=== cmd
read
=== base
http://127.0.0.1:1
=== in
{"thread":"example-org#1234567890"}
=== answer_1
200
[{"id":"7","data":{}}]
--- requests
GET http://127.0.0.1:1/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
--- status
0
--- stdout
{"messages":[{"id":"7","parent":"","author":{"id":"","name":""},"at":"0001-01-01T00:00:00Z","body":"","url":""}]}
--- stderr
=== cmd
read
=== in
{"thread":"example-org#1234567890"}
=== answer_1
200
[]
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
--- status
0
--- stdout
{"messages":[]}
--- stderr
=== cmd
read
=== in
{"thread":"example-org#1234567890"}
=== answer_1
200
[{"id":"7061","commentId":"7061","user":{"id":"3","name":"","username":"grace"},"sentry_app":null,"type":"note","data":{"text":"Seen again on the nightly.","comment_id":7061},"dateCreated":"2026-11-07T11:30:00.5Z","source":null}]
=== headers_1
HTTP/2 200
content-type: application/json
link: <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1", <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:100:0>; rel="next"; results="true"; cursor="0:100:0"
=== answer_2
200
[{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr","slug":"yardr","avatars":[]},"type":"note","data":{"text":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","comment_id":7050},"dateCreated":"2026-11-07T10:02:11Z","source":null},{"id":"7042","commentId":"7042","user":{"id":"1","name":"Ada Lovelace","username":"ada","email":"ada@example.com"},"sentry_app":null,"type":"note","data":{"text":"Looking into it.","comment_id":7042},"dateCreated":"2026-11-07T09:15:30.123456Z","source":null}]
=== headers_2
HTTP/2 200
content-type: application/json
link: <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:100:1>; rel="previous"; results="true"; cursor="0:100:1", <https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?&cursor=0:200:0>; rel="next"; results="false"; cursor="0:200:0"
=== real
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100&cursor=0:100:0
auth: ok
--- status
0
--- stdout
{"messages":[{"id":"7042","parent":"","author":{"id":"1","name":"Ada Lovelace"},"at":"2026-11-07T09:15:30.123456Z","body":"Looking into it.","url":"https://sentry.io/organizations/example-org/issues/1234567890/"},{"id":"7050","parent":"","author":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr"},"at":"2026-11-07T10:02:11Z","body":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","url":"https://sentry.io/organizations/example-org/issues/1234567890/"},{"id":"7061","parent":"","author":{"id":"3","name":"grace"},"at":"2026-11-07T11:30:00.5Z","body":"Seen again on the nightly.","url":"https://sentry.io/organizations/example-org/issues/1234567890/"}]}
--- stderr
=== cmd
read
=== in
{"thread":"example-org#1234567890"}
=== answer_1
200
[{"id":"7061","commentId":"7061","user":{"id":"3","name":"","username":"grace"},"sentry_app":null,"type":"note","data":{"text":"Seen again on the nightly.","comment_id":7061},"dateCreated":"2026-11-07T11:30:00.5Z","source":null},{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr","slug":"yardr","avatars":[]},"type":"note","data":{"text":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","comment_id":7050},"dateCreated":"2026-11-07T10:02:11Z","source":null},{"id":"7042","commentId":"7042","user":{"id":"1","name":"Ada Lovelace","username":"ada","email":"ada@example.com"},"sentry_app":null,"type":"note","data":{"text":"Looking into it.","comment_id":7042},"dateCreated":"2026-11-07T09:15:30.123456Z","source":null}]
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/?per_page=100
auth: ok
--- status
0
--- stdout
{"messages":[{"id":"7042","parent":"","author":{"id":"1","name":"Ada Lovelace"},"at":"2026-11-07T09:15:30.123456Z","body":"Looking into it.","url":"https://sentry.io/organizations/example-org/issues/1234567890/"},{"id":"7050","parent":"","author":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr"},"at":"2026-11-07T10:02:11Z","body":"Fix landed in 4d2c1ab.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","url":"https://sentry.io/organizations/example-org/issues/1234567890/"},{"id":"7061","parent":"","author":{"id":"3","name":"grace"},"at":"2026-11-07T11:30:00.5Z","body":"Seen again on the nightly.","url":"https://sentry.io/organizations/example-org/issues/1234567890/"}]}
--- stderr
=== cmd
reply
=== in
{"thread":"example-org#1234567890","body":"No key, as a hand-made reply may have none."}
=== answer_1
201
{"id":"7051","commentId":"7051","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":null,"type":"note","data":{"text":"No key, as a hand-made reply may have none.","comment_id":7051},"dateCreated":"2026-11-07T10:03:00Z","source":null}
--- requests
POST https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"No key, as a hand-made reply may have none."}
--- status
0
--- stdout
{"id":"7051","url":"https://sentry.io/organizations/example-org/issues/1234567890/"}
--- stderr
=== cmd
reply
=== in
{"thread":"example-org#1234567890","body":"Sentry refuses the same text twice in an hour.","key":"0a1b2c3d-3333-4222-8333-444455556666"}
=== answer_1
400
{"detail":"You have already posted that comment."}
--- requests
POST https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"Sentry refuses the same text twice in an hour.\n\n<!-- yardr-reply 0a1b2c3d-3333-4222-8333-444455556666 -->"}
--- status
1
--- stdout
--- stderr
https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/: HTTP 400: You have already posted that comment.
=== cmd
reply
=== in
{"thread":"example-org#1234567890","body":"A token with event:read alone cannot comment.","key":"0a1b2c3d-4444-4222-8333-444455556666"}
=== answer_1
403
{"detail":"You do not have permission to perform this action."}
--- requests
POST https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"A token with event:read alone cannot comment.\n\n<!-- yardr-reply 0a1b2c3d-4444-4222-8333-444455556666 -->"}
--- status
1
--- stdout
--- stderr
https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/: HTTP 403: You do not have permission to perform this action. (the token needs event:read to read an issue and event:write to comment on it and resolve it)
=== cmd
reply
=== in
{"thread":"example-org#1234567890","body":"The deprecated path answers 410 in a brownout.","key":"0a1b2c3d-5555-4222-8333-444455556666"}
=== answer_1
410
{"message":"This API no longer exists."}
=== real
--- requests
POST https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"The deprecated path answers 410 in a brownout.\n\n<!-- yardr-reply 0a1b2c3d-5555-4222-8333-444455556666 -->"}
--- status
1
--- stdout
--- stderr
https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/: HTTP 410: This API no longer exists.
=== cmd
reply
=== base
http://127.0.0.1:1
=== in
{"thread":"example-org#1234567890","body":"A stand-in base has no web address, so the answer has no URL.","key":"0a1b2c3d-2222-4222-8333-444455556666"}
=== answer_1
201
{"id":"7053","type":"note","data":{"text":"x"},"dateCreated":"2026-11-07T10:05:00Z"}
--- requests
POST http://127.0.0.1:1/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"A stand-in base has no web address, so the answer has no URL.\n\n<!-- yardr-reply 0a1b2c3d-2222-4222-8333-444455556666 -->"}
--- status
0
--- stdout
{"id":"7053"}
--- stderr
=== cmd
reply
=== in
{"thread":"example-org#1234567890","body":"An answer that names no comment is no post.","key":"0a1b2c3d-6666-4222-8333-444455556666"}
=== answer_1
201
{"type":"note"}
--- requests
POST https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"An answer that names no comment is no post.\n\n<!-- yardr-reply 0a1b2c3d-6666-4222-8333-444455556666 -->"}
--- status
5
--- stdout
--- stderr
jq: error (at <stdin>:1): the answer names no comment
=== cmd
reply
=== in
{"thread":"Example-Org#1234567890","body":"The slug is kept lowercase, as Sentry keeps it.","key":"0a1b2c3d-1111-4222-8333-444455556666"}
=== answer_1
201
{"id":"7052","commentId":"7052","user":null,"sentry_app":null,"type":"note","data":{"text":"x","comment_id":7052},"dateCreated":"2026-11-07T10:04:00Z","source":null}
--- requests
POST https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"The slug is kept lowercase, as Sentry keeps it.\n\n<!-- yardr-reply 0a1b2c3d-1111-4222-8333-444455556666 -->"}
--- status
0
--- stdout
{"id":"7052","url":"https://sentry.io/organizations/example-org/issues/1234567890/"}
--- stderr
=== cmd
reply
=== in
{"thread":"example-org#1234567890","parent":"","body":"Fix landed in 4d2c1ab.\n\nA \"quoted\" word, a backslash \\ and ünïcode.","key":"6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f"}
=== answer_1
201
{"id":"7050","commentId":"7050","user":{"id":"9","name":"yardr","username":"sentry-app-yardr"},"sentry_app":{"id":"5f0e9a2b-7c3d-4e1f-8a6b-0d9c8b7a6e5f","name":"yardr","slug":"yardr","avatars":[]},"type":"note","data":{"text":"Fix landed in 4d2c1ab.\n\nA \"quoted\" word, a backslash \\ and ünïcode.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->","comment_id":7050},"dateCreated":"2026-11-07T10:02:11.482113Z","source":null}
=== real
--- requests
POST https://sentry.io/api/0/organizations/example-org/issues/1234567890/comments/
auth: ok
header: Content-Type: application/json
body: {"text":"Fix landed in 4d2c1ab.\n\nA \"quoted\" word, a backslash \\ and ünïcode.\n\n<!-- yardr-reply 6f1f3b1e-1d3c-4a52-9a6b-0b1b2c3d4e5f -->"}
--- status
0
--- stdout
{"id":"7050","url":"https://sentry.io/organizations/example-org/issues/1234567890/"}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done"}
=== answer_1
200
{"id":"1234567890"}
--- requests GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/ auth: ok --- status 1 --- stdout --- stderr the answer names no state for example-org#1234567890
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done\u001b[31m\nforged log line"}
--- requests --- status 1 --- stdout --- stderr "done [31m forged log line" is no state here: want started, done
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"label:wontfix"}
--- requests --- status 1 --- stdout --- stderr "label:wontfix" is no state here: want started, done
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"ignored","substatus":"archived_forever","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"resolved"}
--- status
0
--- stdout
{"status":"resolved","moved":true,"was":"ignored"}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"ongoing","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
=== real
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"resolved"}
--- status
0
--- stdout
{"status":"resolved","moved":true,"was":"unresolved"}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
--- status
0
--- stdout
{"status":"resolved","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done","forward":true}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"ignored","substatus":"archived_until_escalating","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
=== real
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"resolved"}
--- status
0
--- stdout
{"status":"resolved","moved":true,"was":"ignored"}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done","forward":true}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"new","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"resolved"}
--- status
0
--- stdout
{"status":"resolved","moved":true,"was":"unresolved"}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done","forward":true}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
--- status
0
--- stdout
{"status":"resolved","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"started","forward":true}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"ignored","substatus":"archived_forever","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
--- status
0
--- stdout
{"status":"ignored","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"started","forward":true}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
--- status
0
--- stdout
{"status":"resolved","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"started","forward":true}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"regressed","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
--- status
0
--- stdout
{"status":"unresolved","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#99","state":"done"}
=== answer_1
404
{"detail":"The requested resource does not exist"}
--- requests GET https://sentry.io/api/0/organizations/example-org/issues/99/ auth: ok --- status 1 --- stdout --- stderr https://sentry.io/api/0/organizations/example-org/issues/99/: HTTP 404: The requested resource does not exist
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"ongoing","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
403
{"detail":"You do not have permission to perform this action."}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"resolved"}
--- status
1
--- stdout
--- stderr
https://sentry.io/api/0/organizations/example-org/issues/1234567890/: HTTP 403: You do not have permission to perform this action. (the token needs event:read to read an issue and event:write to comment on it and resolve it)
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"ongoing","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"ongoing","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"resolved"}
--- status
1
--- stdout
--- stderr
Sentry answered, but example-org#1234567890 is not resolved
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"review"}
--- requests --- status 1 --- stdout --- stderr a Sentry issue has no review status: the states it has are started and done
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"started"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"ignored","substatus":"archived_forever","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"ongoing","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"unresolved"}
--- status
0
--- stdout
{"status":"unresolved","moved":true,"was":"ignored"}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"started"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"resolved","substatus":"","statusDetails":{},"project":{"slug":"python"}}
=== answer_2
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"ongoing","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
PUT https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
header: Content-Type: application/json
body: {"status":"unresolved"}
--- status
0
--- stdout
{"status":"unresolved","moved":true,"was":"resolved"}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"started"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"unresolved","substatus":"new","statusDetails":{},"project":{"slug":"python"}}
--- requests
GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/
auth: ok
--- status
0
--- stdout
{"status":"unresolved","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"example-org#1234567890","state":"done"}
=== answer_1
200
{"id":"1234567890","shortId":"PYTHON-Y","title":"ZeroDivisionError","status":"reprocessing","substatus":"","statusDetails":{},"project":{"slug":"python"}}
--- requests GET https://sentry.io/api/0/organizations/example-org/issues/1234567890/ auth: ok --- status 1 --- stdout --- stderr the answer names no state for example-org#1234567890