Adding a source
A source reads the developer's open work from a tracker, and what they finished lately when a rule asks. Adding one is a file in src/sources/, a line in the registry and a passing contract suite. Sources holds the research on which trackers can be read headless and how.
For a private tracker, or one you don't want to maintain in here, a custom source needs no code change: any command that prints your items as JSON.
Rules for a source
- It writes only when awaywork asks, never on its own:
writecomments on or moves one of the user's own items, at a destination the config names, and awaywork has already checked the item is theirs and put its mark on a comment. - A personal token from an env var, named in config (
token_env), never stored in the config and never handed to the agent. - Only the user's items: assigned to them, or awaiting their review. Set
mine: trueon the ones that are theirs, assigned to them or theirs by authorship; awaywork writes to no other. - Plain REST or GraphQL with
fetch; retry on 429 asRetry-Aftersays.
The contract
src/core/types.ts:
ts
interface Source {
id: string
configSchema: ZodObject // strict; nested under sources.<id>
fields: Record<string, ZodType> // what `where` may match; `prefix.*` declares prefix.<any>
link?: string | false // default PR link line, e.g. `Part of {key}`; false: none to configure
secrets(cfg): string[] // env var names it reads
items(cfg, ctx, since?): Promise<Item[]> // the user's open items, plus those finished since `since`
prefetch(item, dir, ctx): Promise<void> // write the context files the worker reads
discover?(cfg, ctx): Promise<Discovery> // read-only: what its credentials can see
write?(cfg, item, write, ctx): Promise<{ before?, after? }> // comment or move; the item's updated times around it
}Item: an opaqueidand a humankey(the tracker's own, likeENG-12),typeissueortask, title, description, url, comments, andfieldsholding only keys declared infields. Keep the raw payload inraw.stateandclosedgo infieldson every item, without being declared:stateisopen,merged(PRs only) orclosed(closed unmerged, done, canceled), andclosedis the ISO time a finished item merged or closed.sinceis aDate, set only when some rule for the source can match a finished item: its lookback, the widestclosed: { newer_than }among those rules or 7 days. Read finished items only then, with the tracker's own date filter (Jiraresolved >= -Nm, LinearcompletedAt, Asanacompleted_since), never by paging through history. Withoutsince, return open items only.- Fields are what users write rules against, in the tracker's own names:
section: QA, not a mapped status. Setrepowhen the tracker can say which repo an item's code lives in (Asana, Jira and Linear all read arepo_mapoption; reuse it); without a linked PR or arepothe item is skipped. Setcreatedandupdated(ISO timestamps) so a rule'sordercan sort the items. - Linked PRs: the forge finds the user's PRs that carry the item's key in their branch or title, or the source's
linkline in their body. Setlinkon the source when the tracker links PRs by a phrase in their description (Linear'sPart of {key}, Asana's{url}). prefetchwritesitem.mdwith the item and all its comments; it's all the worker will see.writegets{ op: 'comment', body }or{ op: 'move', to }. Leave it out when the tracker can't be written to.- A move goes to the status, state, section or label named
toin the tracker's words, ignoring case, and throws naming the ones there are when none matches. - In that read right before the write, read the assignee too and throw
<key> is no longer yourswhen the item isn't the user's any more:mineis from plan time. - Return the item's
updatedas the tracker has it right before the write and right after, read back when the calls don't return it. awaywork recordsafteronly whilebeforeis the one it read, so its own change doesn't look new next run and nobody else's gets lost. - Set
mine: trueon each comment the user wrote, unless the tracker says someone else edited it last:isAwayworkCommentcounts a comment as awaywork's only with its mark andmine. Tell the user's comments by the item's own assignee when items are read as assigned to them, rather than with one more call.
- A move goes to the status, state, section or label named
discoverreturns, read only, the names a config andwhereneed: workspaces, projects, teams, statuses, as plain data (strings, lists of them, lists of records like it).awaywork sources <id>prints it, and calls it for an unconfigured source too, with only the schema's defaults incfg: throw saying what to configure when the source can't do without it.awaywork init --yesreads it only for the built-in sources (the gh login, Asana's workspace and sections, Linear's states); yours gets{}there, so its config is written by hand or by/away.- Throw
unsetSecret(name)fromplan/poll.tsfor a missing token, so the command can say where to put it.
asana.ts is the example to copy.
Register it
ts
// src/sources/index.ts
export const sources: Record<string, Source<any>> = { github, asana, mine }Rules may then use source: mine and its fields; doctor rejects unknown where keys with the list of fields each source declares.
Test it
- Fixtures shaped from the tracker's documented responses in
test/fixtures/<id>/, a recordedfetch, andsourceContract(source, rawConfig, env, move)fromtest/contracts/source.contract.ts, wheremoveis where a source withwritemoves its first item; it checks thatdiscover, when there is one, returns plain data. - A
test/real-<id>.test.tsagainst a live account, skipped unless its env is set; best when it seeds the items it checks and deletes them, liketest/real-github.test.ts. - Document the token's scope, including when the tracker has no read-only token, and the scope
writeneeds.