Tangled
The Tangled provider covers Tangled, a forge built on atproto. It reads and writes issues, pulls and comments. It also streams live events from Jetstream and verifies incoming push webhooks. Tangled has no reviews, checks, releases or repository contents API, so those capabilities are false.
| Detail | Value |
|---|---|
| Import | forges/tangled |
| Factories | tangled(), tangledLite() |
| Auth | anonymous, app_password, oauth |
Authentication
Reads are public and need no credentials. Writes need an atproto session, from an app password or from an OAuth client:
import { tangled } from 'forges/tangled'
const reader = tangled({}).create()
const writer = tangled({
auth: {
type: 'app_password',
identifier: 'alice.example.com',
password: process.env.TANGLED_APP_PASSWORD!,
},
}).create()
With app_password, the provider creates a session on the account's PDS (personal data server). It finds the PDS in this order:
- The
pdsoption, if you pass it. - The DID document, if
identifieris a DID. https://bsky.social.
With oauth, pass the did, the pds and a fetch from an atproto OAuth client that signs requests:
tangled({
auth: {
type: 'oauth',
did: session.did,
pds: session.pds,
fetch: session.fetch,
},
})
Instance and services
baseUrl defaults to https://tangled.org. The provider uses it for web links and as the instance.
Tangled records are stored on many servers, so the provider reads from several services. You can change each one:
| Option | Reads | Default |
|---|---|---|
plcUrl | DID documents, to find each author's PDS | https://plc.directory |
backlinksUrl | Records that point at a thread, such as comments | https://constellation.microcosm.blue |
recordsUrl | Records, through a Slingshot-compatible cache | Each author's PDS |
jetstreamUrl | The live event stream | A public Bluesky Jetstream instance |
apiUrl | request() and listSource: 'index' | https://api.tangled.org |
Refs and numbers
Issues and pulls are atproto records, identified by AT-URI. A ThreadRef has the AT-URI as both number and externalId. The repo's owner is the owner's DID, and RepoRef.externalId is the repo DID.
The numbers in the Tangled UI and in webhooks come from the appview, and records don't store them. The provider reports them as displayNumber.
A ref from a webhook has no number until it's matched to a record. To fill number during webhooks.ingest(), pass a resolveDisplayNumber(repo, kind, displayNumber) function that returns the AT-URI.
Thread state
A thread's state is the latest state record written by the thread's author, the repo owner or a collaborator. The provider ignores state records from other accounts. If another account calls close() or reopen(), the call throws InsufficientScopeError and writes no record.
Listing threads
By default, threads.list() reads every record and computes each thread's state, which takes several requests per thread. Set listSource: 'index' to list from the index at apiUrl. The index takes one request per page, but its states can lag behind the records. Every listing from the index has an index_possibly_stale warning.
Notifications
Notifications use the org.tangled.temp.notification.* lexicons. These lexicons are in Tangled's temporary namespace and can change. To enable notifications, pass auth and set notificationsUrl to the base URL of the notification service. The notification capabilities are then 'experimental'.
Stars and follows have no thread. They appear with kind: 'other', and typeRaw contains the notification type.
Merging
The knot that hosts a repository merges its pulls, with no review step. merge() and approveAndMerge() are unavailable.
Live events
sources.subscribe() streams events from Jetstream. Each item has a cursor. Save it, and pass it back to resume the stream:
for await (const { event, cursor } of reader.sources.subscribe({
cursor: saved,
signal,
})) {
console.log(event.kind, event.summary)
saved = cursor
}
To close the connection, abort the signal or break out of the loop. If the connection drops, the iterator throws SubscriptionClosedError with the last cursor. The provider doesn't reconnect, so call subscribe() again with that cursor.
Webhooks
Set webhookSecret to the secret configured on the hook:
const forge = tangled({
webhookSecret: process.env.TANGLED_WEBHOOK_SECRET,
}).create()
const events = await forge.webhooks.ingest({
headers: request.headers,
body: await request.text(),
})
Tangled sends these headers with each delivery:
| Header | Content |
|---|---|
X-Tangled-Signature-256 | sha256= followed by the hex HMAC-SHA256 of the body |
X-Tangled-Event | The event name |
X-Tangled-Delivery | The delivery ID |
A push delivery becomes a push or ref event. Tangled has no API to register hooks, so the webhooks management methods are unavailable.
Recording fixtures
Recording needs no credentials:
pnpm record-fixtures tangled
To record other threads, set FIXTURE_TANGLED_ISSUE and FIXTURE_TANGLED_PULL to AT-URIs. To record through a Slingshot cache, set TANGLED_RECORDS_URL.
Capabilities
Generated by pnpm capability-matrix; do not edit by hand.
| Capability | Support |
|---|---|
experimental | ❌ |
sources.poll | ❌ |
sources.webhook | ✅ |
sources.subscribe | ✅ |
repos.get | ✅ |
users.get | ❌ |
repos.list | experimental |
repos.labels | ❌ |
repos.createLabel | ❌ |
repos.milestones | ❌ |
repos.collaborators | ❌ |
repos.permissionFor | ❌ |
repos.addCollaborator | ❌ |
repos.assignableUsers | ❌ |
repos.reviewerCandidates | ❌ |
threads.get | issue, PR |
threads.list | issue (experimental), PR |
threads.getMany | ✅ |
comments.list | issue, PR |
comments.edit | issue (experimental), PR (experimental) |
comments.delete | issue (experimental), PR (experimental) |
reactions.list | ❌ |
notifications.list | experimental |
notifications.markRead | experimental |
notifications.markDone | ❌ |
notifications.unsubscribe | ❌ |
notifications.markAllRead | experimental |
notifications.markAllDone | ❌ |
notifications.unreadCount | experimental |
writes.comment | issue (experimental), PR |
writes.upsertComment | issue (emulated), PR (emulated) |
writes.close | issue (experimental), PR |
writes.reopen | issue (experimental), PR |
writes.create | issue (experimental) |
writes.update | issue (experimental), PR (experimental) |
writes.setLabels | ❌ |
writes.addLabels | ❌ |
writes.removeLabels | ❌ |
writes.setMilestone | ❌ |
writes.react | ❌ |
writes.setAssignees | ❌ |
writes.requestReview | ❌ |
writes.merge | ❌ |
writes.approveAndMerge | ❌ |
writes.transfer | ❌ |
writes.markDuplicate | ❌ |
subscriptions.get | issue (experimental), PR (experimental) |
subscriptions.set | issue (experimental), PR (experimental) |
installations | ❌ |
checks.thread | ❌ |
checks.list | ❌ |
checks.report | ❌ |
checks.rerun | ❌ |
ci.runs | ❌ |
ci.run | ❌ |
ci.jobs | ❌ |
ci.log | ❌ |
contents.file | ❌ |
contents.tree | ❌ |
contents.branches | ❌ |
contents.tags | ❌ |
contents.resolveRef | ❌ |
contents.commits | ❌ |
contents.commit | ❌ |
contents.compare | ❌ |
contents.threadFiles | ❌ |
contents.threadCommits | ❌ |
reviews.list | ❌ |
reviews.create | ❌ |
reviews.submit | ❌ |
reviews.approve | ❌ |
reviews.resolveThread | ❌ |
releases.list | ❌ |
releases.get | ❌ |
releases.latest | ❌ |
releases.getByTag | ❌ |
releases.downloadAsset | ❌ |
webhooks.list | ❌ |
webhooks.create | ❌ |
webhooks.update | ❌ |
webhooks.delete | ❌ |
webhooks.rotateSecret | ❌ |
webhooks.deliveries | ❌ |
webhooks.redeliver | ❌ |
search.threads | ❌ |
search.repos | ❌ |
search.commits | ❌ |
securityAlerts | ❌ |
eventKinds | native |
auth | anonymous, app_password, oauth |
limits | unknown |