A practical guide to driving dev-pulse from scripts and curl. Focus is on
authentication, discovering the OpenAPI spec, and the two most common
operator tasks: managing repos and managing projects.
Replace https://<host> with your deployment URL throughout.
The full machine-readable schema is served at:
GET https://<host>/openapi.json
Open it in any OpenAPI viewer, or query it inline:
# List all paths
curl -sS https://<host>/openapi.json \
| python3 -c "import json,sys; d=json.load(sys.stdin); \
[print(m.upper(), p) for p in sorted(d['paths']) for m in d['paths'][p]]"
# Inspect a specific endpoint
curl -sS https://<host>/openapi.json \
| python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin) \
['paths']['/projects']['post'], indent=2))"
# Show a schema by name
curl -sS https://<host>/openapi.json \
| python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin) \
['components']['schemas']['CreateProjectRequest'], indent=2))"Always check openapi.json first — request/response shapes evolve and the
spec is the source of truth.
dev-pulse uses cookie sessions plus a CSRF token for mutating requests. There are two login paths: email/password (form login) and GitHub OAuth (UI flow). For scripting, use email/password.
COOKIES=/tmp/dp-cookies.txt
CSRF=$(curl -sS -c "$COOKIES" -X POST https://<host>/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"<secret>"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['csrf_token'])")
echo "CSRF=$CSRF"After login:
- The session cookie (
sas_*) is stored in$COOKIESand must be sent with every subsequent request (-b "$COOKIES"). - The returned
csrf_tokenmust be sent in thex-csrf-tokenheader on every non-GET request. Without it, mutating calls are rejected.
curl -sS -b "$COOKIES" https://<host>/me/orgs
# => [ { "id": "<uuid>", "github_id": 12345, "login": "acme", "name": null } ]Save the org id — most of the operator workflows below need it.
| Verb | Cookie | CSRF header |
|---|---|---|
GET, HEAD |
required | not required |
POST, PUT, PATCH, DELETE |
required | required |
If you see 403 csrf_* errors, your x-csrf-token is missing or stale —
re-run the login step to get a fresh token.
Repos in dev-pulse mirror GitHub repos. They are populated by:
- The reconciler (scheduled background ticks against repos already known to the system).
- Webhook deliveries (a payload for an unknown repo triggers an upsert).
POST /repos/sync— the operator-facing import + sync API used below. This is the path you'll use when onboarding new GitHub repos.
curl -sS -b "$COOKIES" -X POST https://<host>/repos/sync \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d '{"org":"acme","name":"my-new-repo"}'Response (HTTP 202):
{ "queued": true, "repo_id": "<uuid>", "imported": true }imported: true— the repo was unknown and was just upserted from GitHub.imported: false— the repo already existed; a sync was queued anyway.
Failure modes:
404 repo_not_found— GitHub has no such repo (or the app token can't see it). Check the slug and the app installation.400 missing_fields—orgornameis blank.503— the reconciler is not configured on this deployment.
curl -sS -b "$COOKIES" -X POST \
https://<host>/repos/<repo_id>/sync \
-H "x-csrf-token: $CSRF"curl -sS -b "$COOKIES" \
https://<host>/repos/<repo_id>/sync-statusReturns the per-resource cursor age used by the "stale" badge in the UI.
curl -sS -b "$COOKIES" "https://<host>/repos?limit=200"Paginated; matches the operator-side repo table.
for name in repo-a repo-b repo-c; do
curl -sS -b "$COOKIES" -X POST https://<host>/repos/sync \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d "{\"org\":\"acme\",\"name\":\"$name\"}" \
-w " HTTP=%{http_code}\n"
sleep 0.3
doneThe 300 ms sleep avoids hammering the GitHub-side rate budget when discovering many new repos in one pass.
A project is a dev-pulse-native grouping that aggregates one or more repos for reporting (portfolio reports, milestones, board links, …).
org_id is required and must come from /me/orgs.
curl -sS -b "$COOKIES" -X POST https://<host>/projects \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d '{"org_id":"<org-uuid>","name":"Project Phoenix"}'Response (HTTP 200): the full ProjectDto including the new id.
Optional fields: description, start_at, due_at, lead_user_id,
status (active by default; see ProjectStatusDto in the spec).
Errors: 409 project_name_taken if an active project with that name already
exists in the org.
# List
curl -sS -b "$COOKIES" "https://<host>/projects?limit=200"
# Get one
curl -sS -b "$COOKIES" https://<host>/projects/<project_id>
# Partial update (PATCH uses optimistic concurrency — see PatchProjectRequest)
curl -sS -b "$COOKIES" -X PATCH https://<host>/projects/<project_id> \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d '{"version":1,"description":"Updated"}'curl -sS -b "$COOKIES" -X PUT \
"https://<host>/projects/<project_id>/repos/<repo_id>" \
-H "x-csrf-token: $CSRF"Idempotent — calling twice is fine. A repo can be linked to multiple projects.
curl -sS -b "$COOKIES" \
https://<host>/projects/<project_id>/reposcurl -sS -b "$COOKIES" -X DELETE \
"https://<host>/projects/<project_id>/repos/<repo_id>" \
-H "x-csrf-token: $CSRF"Returns 204 whether or not the link existed (idempotent).
curl -sS -b "$COOKIES" -X POST \
https://<host>/projects/<project_id>/archive \
-H "x-csrf-token: $CSRF"A view is a saved, ordered tab on a project that pre-applies filters,
sort, and an optional group-by. Each user has their own private set of views
per project (visibility: "private" in v1).
curl -sS -b "$COOKIES" \
https://<host>/projects/<project_id>/viewsReturns the caller's views in position ASC order. Each ProjectViewDto
carries id, name, filter_clauses, sort, group_by, position,
visibility, and live open_issue_count / total_issue_count.
The group_by value isn't a free-text string — it must come from the
project's dynamic dimension catalogue:
curl -sS -b "$COOKIES" \
https://<host>/projects/<project_id>/group-by-optionsReturns { "dims": [ { "key": "status", … }, { "key": "tag:area", … } ] }.
status is always present; tag:<key> entries appear once per distinct tag
key observed on the project's issues.
curl -sS -b "$COOKIES" -X POST \
https://<host>/projects/<project_id>/views \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d '{
"name": "Open bugs",
"sort": "updated_desc",
"group_by": "status",
"filter_clauses": [
{ "dim": "state", "op": "eq", "value": "open" },
{ "dim": "tag:type", "op": "eq", "value": "bug" }
]
}'Field rules:
name— required, 1–60 chars after trim.sort— required; one ofupdated_desc,updated_asc,title_asc. Empty string is rejected; send"updated_desc"to mean "default".filter_clauses— array of canonical{ "dim": …, "op": …, "value": … }objects. Each clause is validated server-side. Pass[]for "no filter".group_by—nullfor a flat view, or a key from/group-by-options(e.g."status","tag:area").start_date,due_date— optionalYYYY-MM-DDfor timeline-oriented views.
Response (HTTP 201): the full ProjectViewDto including the new id and
the appended position.
Errors: 409 on name clash within the caller's views, 400 on invalid
sort / filter_clauses / group_by.
curl -sS -b "$COOKIES" -X PATCH \
https://<host>/projects/<project_id>/views/<view_id> \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d '{
"name": "Open bugs (high priority)",
"sort": "updated_desc",
"filter_clauses": [
{ "dim": "state", "op": "eq", "value": "open" },
{ "dim": "tag:priority", "op": "eq", "value": "high" }
]
}'Note: the PATCH body has the same shape as POST — there is no partial-update semantics on views. Always send the full intended state.
position is managed by an atomic full-list rewrite. Send the complete
ordered id list:
curl -sS -b "$COOKIES" -X POST \
https://<host>/projects/<project_id>/views/reorder \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d '{ "ordered_ids": ["<view_id_1>","<view_id_2>","<view_id_3>"] }'ordered_ids must equal the caller's existing view-id set on that project —
omitting or adding ids returns 400.
curl -sS -b "$COOKIES" -X DELETE \
https://<host>/projects/<project_id>/views/<view_id> \
-H "x-csrf-token: $CSRF"Returns 204 on success.
COOKIES=/tmp/dp-cookies.txt
HOST=https://<host>
# 1. Login
CSRF=$(curl -sS -c "$COOKIES" -X POST "$HOST/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"<secret>"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['csrf_token'])")
# 2. Find the org
ORG_ID=$(curl -sS -b "$COOKIES" "$HOST/me/orgs" \
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['id'])")
# 3. Import + sync the GitHub repo
REPO_ID=$(curl -sS -b "$COOKIES" -X POST "$HOST/repos/sync" \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d '{"org":"acme","name":"my-repo"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['repo_id'])")
# 4. Create the project
PROJECT_ID=$(curl -sS -b "$COOKIES" -X POST "$HOST/projects" \
-H 'Content-Type: application/json' \
-H "x-csrf-token: $CSRF" \
-d "{\"org_id\":\"$ORG_ID\",\"name\":\"My Project\"}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
# 5. Link them
curl -sS -b "$COOKIES" -X PUT \
"$HOST/projects/$PROJECT_ID/repos/$REPO_ID" \
-H "x-csrf-token: $CSRF"
echo "Linked repo $REPO_ID to project $PROJECT_ID"| Symptom | Cause | Fix |
|---|---|---|
401 unauthorized |
session cookie missing/expired | re-run /auth/login |
403 csrf_* |
x-csrf-token missing or stale |
re-login, capture fresh token |
404 repo_not_found on /repos/sync |
GitHub-side repo absent or app token can't see it | verify slug + app installation |
409 project_name_taken |
active project with same name exists in org | choose a different name or archive the old one |
503 on sync routes |
reconciler not configured on this deployment | enable the GitHub client in server config |
For anything not covered here, the OpenAPI spec (/openapi.json) lists every
endpoint and schema dev-pulse exposes.