Arbituro docs
API examples
Complete programs to copy and adapt: a small client in JavaScript and Python, and scripts for CI, releases, reports and alerts.
- Each program reads its key from the
ARBITURO_API_KEYenvironment variable. - JavaScript needs Node 18 or later, which has
fetchbuilt in. Python needs the requests package. - A program acts as the person who made its key, and what it writes goes in under their name. Make the key from an account that can change work in the projects the program touches. Anyone who can change your CI’s workflows can use a key kept in its secrets, so give it the shortest expiry that works.
- Use your own projects’ keys in place of WEB and OPS.
A client to start from
Both versions send your key and a User-Agent, turn a refusal into an error that carries the status
and Arbituro’s sentence, and wait out the rate limit.
// A small client for the Arbituro API. Needs Node 18 or later.
// Copy it into your project, then:
// import { arbituro } from './arbituro.mjs';
// const job = await arbituro('GET', '/projects/WEB/jobs/WEB-12');
const API = 'https://app.arbituro.com/api/v1';
export class ArbituroError extends Error {
constructor(status, message) {
super(`${status}: ${message}`);
this.status = status;
}
}
/**
* Calls the API as the person who made the key, and returns the answer's JSON.
* A refusal throws an ArbituroError carrying the status and Arbituro's sentence.
* Over the rate limit, it waits and tries again, twice at most.
*/
export async function arbituro(method, path, body, options = {}) {
const key = options.key ?? process.env.ARBITURO_API_KEY;
const client = options.client ?? 'my-script/1.0';
const headers = { Authorization: `Bearer ${key}`, 'User-Agent': client };
if (body !== undefined) headers['Content-Type'] = 'application/json';
for (let attempt = 1; ; attempt++) {
const response = await fetch(API + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status === 429 && attempt <= 2) {
// The count starts again every minute, so a minute's wait clears it.
await new Promise((resolve) => setTimeout(resolve, 60_000));
continue;
}
const text = await response.text();
let answer = null;
try {
answer = text ? JSON.parse(text) : null;
} catch {
// Not JSON, such as a proxy's error page.
}
if (!response.ok) {
const why = answer?.message ?? answer?.title ?? response.statusText;
throw new ArbituroError(response.status, why);
}
return answer;
}
}
import { arbituro, ArbituroError } from './arbituro.mjs';
const job = await arbituro('GET', '/projects/WEB/jobs/WEB-12');
console.log(job.title, job.doneWhen.length, 'criteria');
try {
const note = { body: 'Released in 4.2.' };
await arbituro('POST', '/projects/WEB/jobs/WEB-12/notes', note, {
client: 'release-notes/1.0',
});
} catch (error) {
if (!(error instanceof ArbituroError) || error.status !== 403) throw error;
console.error('This key can read WEB but not write to it.');
}
"""A small client for the Arbituro API. Needs the requests package.
Copy it into your project, then:
from arbituro import arbituro
job = arbituro("GET", "/projects/WEB/jobs/WEB-12")
"""
import os
import time
import requests
API = "https://app.arbituro.com/api/v1"
class ArbituroError(Exception):
def __init__(self, status, message):
super().__init__(f"{status}: {message}")
self.status = status
def arbituro(method, path, body=None, key=None, client="my-script/1.0"):
"""Calls the API as the person who made the key, and returns the answer's JSON.
A refusal raises an ArbituroError carrying the status and Arbituro's sentence.
Over the rate limit, it waits and tries again, twice at most.
"""
headers = {
"Authorization": f"Bearer {key or os.environ['ARBITURO_API_KEY']}",
"User-Agent": client,
}
for attempt in range(3):
response = requests.request(
method, API + path, headers=headers, json=body, timeout=30
)
if response.status_code == 429 and attempt < 2:
# The count starts again every minute, so a minute's wait clears it.
time.sleep(60)
continue
try:
answer = response.json()
except ValueError:
# Not JSON, such as a proxy's error page.
answer = None
if not response.ok:
answer = answer or {}
why = answer.get("message") or answer.get("title") or response.reason
raise ArbituroError(response.status_code, why)
return answer
from arbituro import ArbituroError, arbituro
job = arbituro("GET", "/projects/WEB/jobs/WEB-12")
print(job["title"], len(job["doneWhen"]), "criteria")
try:
note = {"body": "Released in 4.2."}
arbituro("POST", "/projects/WEB/jobs/WEB-12/notes", note,
client="release-notes/1.0")
except ArbituroError as error:
if error.status != 403:
raise
print("This key can read WEB but not write to it.")
Link pull requests from GitHub Actions
When a pull request is opened or reopened, this workflow links it to the job its branch is named for, such as web-12-fix-download for WEB-12. The job then shows the pull request with its checks.
- Add an API key as the repository secret
ARBITURO_API_KEY. - The repository has to be linked to the job’s project. See Link repositories to a project.
- A pull request from a fork gets no repository secrets, so the workflow skips it.
# Links each pull request to the Arbituro job its branch is named for, such as
# web-12-fix-download for WEB-12. Save it as .github/workflows/arbituro.yml and add
# an API key as the repository secret ARBITURO_API_KEY. The repository has to be
# linked to the job's project in Arbituro.
name: Link to Arbituro
on:
pull_request:
types: [opened, reopened]
jobs:
link:
# A pull request from a fork gets no repository secrets, so it is skipped.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
# It calls only Arbituro, so its GitHub token gets no permissions.
permissions: {}
steps:
- name: Link the pull request to its job
env:
ARBITURO_API_KEY: ${{ secrets.ARBITURO_API_KEY }}
BRANCH: ${{ github.head_ref }}
PULL_REQUEST: ${{ github.event.pull_request.html_url }}
run: |
job=$(printf '%s' "$BRANCH" | grep -oE '^[A-Za-z][A-Za-z0-9]*-[0-9]+' \
| tr '[:lower:]' '[:upper:]' || true)
if [ -z "$job" ]; then
echo "$BRANCH doesn't start with a job key. Nothing to link."
exit 0
fi
api=https://app.arbituro.com/api/v1
status=$(curl -sS -o answer.json -w '%{http_code}' -X POST \
"$api/projects/${job%-*}/jobs/$job/pull-requests" \
-H "Authorization: Bearer $ARBITURO_API_KEY" \
-H "Content-Type: application/json" \
-H "User-Agent: github-actions/link-pull-request" \
-d "{\"url\": \"$PULL_REQUEST\"}")
cat answer.json; echo
# 201 is linked. A 404 or 409 says why not, such as a job that isn't
# there or a link that already is.
case $status in
201) ;;
404|409) echo "::warning::$PULL_REQUEST was not linked to $job." ;;
*) echo "::error::Arbituro answered $status."; exit 1 ;;
esac
Once the pull request is linked, a criterion that a CI check decides is settled by that check. See Let CI decide criteria.
Record test results as evidence
Run this after your tests pass, with the job’s key, the start of the criterion’s text and the address of the CI run. The person who decides the criterion is asked, with the run in front of them, and a note on the job says so.
// Records a CI run as evidence for an acceptance criterion a person decides, then
// leaves a note on the job. Needs Node 18 or later. Run it after your tests pass,
// with the job's key, the start of the criterion's text and the run's address:
// ARBITURO_API_KEY=sk_... node record-evidence.mjs WEB-12 \
// "A download completes" https://ci.example.com/runs/812
const API = 'https://app.arbituro.com/api/v1';
const [job, criterion, run] = process.argv.slice(2);
if (!/^[A-Z][A-Z0-9]*-[0-9]+$/.test(job ?? '') || !criterion || !run) {
console.error('Usage: node record-evidence.mjs <job> <criterion> <run address>');
process.exit(2);
}
const project = job.slice(0, job.lastIndexOf('-'));
async function post(path, body) {
const response = await fetch(`${API}/projects/${project}/jobs/${job}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARBITURO_API_KEY}`,
'Content-Type': 'application/json',
'User-Agent': 'ci/record-evidence',
},
body: JSON.stringify(body),
});
const answer = await response.json().catch(() => ({}));
if (!response.ok) {
console.error(`${response.status}: ${answer.message ?? response.statusText}`);
process.exit(1);
}
return answer;
}
// The criterion stays undecided. Arbituro asks the person who decides it, with
// this evidence in front of them.
const claim = await post('/claims', {
clause: criterion,
evidence: `The end-to-end tests passed in CI: ${run}`,
});
console.log(claim.message);
const waiting = `Evidence for "${claim.clause}" is waiting on ${claim.decider}.`;
const note = await post('/notes', { body: `CI passed: ${run}\n\n${waiting}` });
console.log(note.message);
A criterion that a CI check decides can’t be claimed. Link the pull request instead, and the check settles it.
Hold a release until the criteria are met
Run this before a deploy. It exits with 0 when every acceptance criterion on the job is met or waived, and with 1 otherwise, listing what is still open and what it waits on.
// Holds a release until every acceptance criterion on a job is met or waived.
// Needs Node 18 or later. It exits with 0 when the job is ready, and with 1 and
// the open criteria when it isn't:
// ARBITURO_API_KEY=sk_... node criteria-gate.mjs WEB-12
const API = 'https://app.arbituro.com/api/v1';
const job = process.argv[2];
if (!/^[A-Z][A-Z0-9]*-[0-9]+$/.test(job ?? '')) {
console.error('Usage: node criteria-gate.mjs <job key, such as WEB-12>');
process.exit(2);
}
const project = job.slice(0, job.lastIndexOf('-'));
const response = await fetch(`${API}/projects/${project}/jobs/${job}`, {
headers: {
Authorization: `Bearer ${process.env.ARBITURO_API_KEY}`,
'User-Agent': 'ci/criteria-gate',
},
});
const answer = await response.json().catch(() => ({}));
if (!response.ok) {
console.error(`${response.status}: ${answer.message ?? response.statusText}`);
process.exit(1);
}
// An empty list means nobody has agreed yet what finished means for this job.
const criteria = answer.doneWhen;
if (criteria.length === 0) {
console.error(`${job} has no acceptance criteria yet.`);
process.exit(1);
}
function decider(criterion) {
switch (criterion.decidedBy) {
case 'Person':
return criterion.decider;
case 'Owner':
return 'whoever judges the job';
case 'Check':
return `the ${criterion.check} check`;
case 'Send':
return 'an approved message';
default:
return 'an automatic check';
}
}
const done = (criterion) => ['Passing', 'Waived'].includes(criterion.state);
const open = criteria.filter((criterion) => !done(criterion));
const met = criteria.length - open.length;
console.log(`${job}: ${met} of ${criteria.length} criteria met or waived.`);
for (const criterion of open) {
console.log(` Waiting on ${decider(criterion)}: ${criterion.text}`);
}
process.exit(open.length === 0 ? 0 : 1);
WEB-12: 1 of 3 criteria met or waived.
Waiting on Jo Park: A download completes on iOS Safari.
Waiting on an approved message: Support knows the fix is live. Report work that has stalled
This lists a project’s unfinished jobs that nothing has happened on for two weeks, with who each one waits on. It reads every page of the list. Run it on a schedule and send the output wherever your team reads it.
"""Lists a project's unfinished jobs that nothing has happened on for two weeks,
and who each one waits on.
Needs the requests package (pip install requests). Run it with the project's key:
ARBITURO_API_KEY=sk_... python stale-jobs.py WEB
"""
import os
import sys
import requests
API = "https://app.arbituro.com/api/v1"
if len(sys.argv) != 2:
sys.exit("Usage: python stale-jobs.py <project key, such as WEB>")
url = f"{API}/projects/{sys.argv[1]}/jobs"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['ARBITURO_API_KEY']}"
session.headers["User-Agent"] = "stale-jobs/1.0"
params = {"untouchedDays": 14, "limit": 100}
while True:
response = session.get(url, params=params, timeout=30)
if not response.ok:
sys.exit(f"{response.status_code}: {response.text or response.reason}")
page = response.json()
for job in page["jobs"]:
owner = job["owner"] or "nobody"
days = job["untouchedDays"]
line = f"{job['key']} {job['title']}"
print(f"{line} (owner: {owner}, untouched {days} days)")
if job["waiting"]:
print(f" {job['waiting']}")
# next names the key to read on from, and is null on the last page.
if page["next"] is None:
break
params["after"] = page["next"]
WEB-14 Add a retry to the download (owner: nobody, untouched 16 days)
WEB-23 Log failed downloads (owner: Sam Reyes, untouched 21 days)
waiting on Sam Reyes · answer File a job from an alert
This files a job with a title and a description, and prints its key. Call it from your monitoring when something breaks. The job arrives in the backlog with no owner, for a person to pick up. It needs curl and jq.
#!/bin/sh
# Files a job for an alert, and prints the new job's key. Needs curl and jq.
# ARBITURO_API_KEY=sk_... ./file-job.sh OPS "Disk nearly full on db-2" \
# "The disk monitor saw 91% at 03:12 UTC."
set -eu
if [ $# -ne 3 ]; then
echo "Usage: ./file-job.sh <project key> <title> <description>" >&2
exit 2
fi
body=$(jq -n --arg title "$2" --arg description "$3" \
'{title: $title, description: $description}')
answer=$(curl -sS --fail-with-body -X POST \
"https://app.arbituro.com/api/v1/projects/$1/jobs" \
-H "Authorization: Bearer $ARBITURO_API_KEY" \
-H "Content-Type: application/json" \
-H "User-Agent: alerts/file-job" \
-d "$body") || { echo "$answer" >&2; exit 1; }
printf '%s\n' "$answer" | jq -r .key
Something missing or wrong? Email support@arbituro.com.