A wrapper around the GitHub Projects (v2) GraphQL API, for the UraiJS runtime. It resolves fields, options and iterations by name, so callers work in the vocabulary of the project board rather than in node IDs.
import { createClient } from "urai:lib/<org>/gh-projects";
const gh = createClient(); // token from the GITHUB_TOKEN secret
const project = await gh.project({ org: "acme", number: 3 });
await project.addItem("https://github.com/acme/api/issues/42", {
Status: "In Progress", // single-select, matched by option name
Tags: ["infra", "urgent"], // multi-select, matched by option names
Sprint: "@current", // iteration containing today
"Target date": new Date("2026-09-15"),
Points: 8,
});
for await (const item of project.items({ includeArchived: false })) {
console.log(item.content?.title, "→", item.fields.Status);
}In order of preference:
createClient({ token })meta.secrets.GITHUB_TOKENurai:secrets→await secrets.get("GITHUB_TOKEN")
Point step 2/3 at a different secret with createClient({ secretKey: "GH_PROJECTS_TOKEN" }).
Token permissions. Projects live behind their own permission, separate from repositories:
| Token | Needs |
|---|---|
| Fine-grained PAT | Projects — read, or read and write |
| Classic PAT | project scope (or read:project for read-only) |
| GitHub App | organization_projects / repository_projects |
To read the issue or PR behind an item — title, assignees, labels, state —
the token also needs Issues: read (and Pull requests: read) on the
relevant repositories. Without it GitHub returns content: null on those items
while the project's own fields still resolve; item.content is typed nullable
for exactly this reason.
A token with no Projects access gets null list entries rather than an error,
which reads as "this owner has no projects". listProjects detects that and
throws a message naming the missing permission instead.
await gh.project({ org: "acme", number: 3 });
await gh.project({ user: "octocat", number: 7 });
await gh.project({ owner: "acme", number: 3 }); // org or user, resolved for you
await gh.project("https://github.com/orgs/acme/projects/3");
await gh.project({ id: "PVT_kwDO…" });
await gh.listProjects({ org: "acme" }, { query: "is:open" });
await gh.createProject({ owner: { org: "acme" }, title: "Roadmap" });const fields = await project.fields(); // name -> ProjectField, cached
await project.field("status"); // case-insensitive; throws listing real names
await project.iterations("Sprint");
for await (const item of project.items()) { … } // pages automatically
await project.listItems({ limit: 50 });
await project.getItem(itemId);
await project.itemForContent("https://github.com/acme/api/issues/42");Each item carries two views of its values:
item.fields—{ Status: "In Progress", Tags: ["infra"], Points: 8 }item.fieldValues— the same keyed by name, withoptionId,iterationId, the field'sdataType, and the raw GraphQL node.
await project.setField(itemId, "Status", "Done");
await project.setFields(itemId, { Status: "Done", Points: 3 }); // one mutation
await project.clearFields(itemId, "Points", "Notes"); // or pass null
await project.addItem(contentIdOrUrl, { Status: "Todo" });
await project.addDraftIssue({ title: "Spike", body: "…" });
await project.archiveItem(itemId);
await project.moveItem(itemId, afterItemId);
await project.deleteItem(itemId);
await project.update({ title: "Roadmap", shortDescription: "…", public: false });
await project.close();Values are coerced against the field's declared type:
| Field type | Accepts |
|---|---|
TEXT |
any string (others are stringified) |
NUMBER |
a number, or a numeric string |
DATE |
a Date, or YYYY-MM-DD, or anything new Date() parses |
SINGLE_SELECT |
option name (case-insensitive), option id, or { singleSelectOptionId } |
MULTI_SELECT |
an array of option names or ids, or { multiSelectOptionIds } |
ITERATION |
iteration title, id, a Date inside it, or "@current" / "@next" / "@previous" |
null, undefined, "" and [] clear the field.
Assignees, labels and milestones are not writable through Projects — they
belong to the issue. Attempting one throws a TypeError saying so rather than
failing at the API.
await project.views(); // every tab, in order
await project.view("Roadmap"); // by name, tab number, or node ID
await project.createView({ name: "Roadmap", layout: "ROADMAP_LAYOUT" }); // Gantt
await project.createView({
name: "Schedule",
layout: "TABLE_LAYOUT",
visibleFields: ["Title", "Status", "Start", "Target"],
});
await project.updateView("Schedule", { filter: "is:open" });
await project.deleteView("Schedule");Two limits GitHub imposes on ROADMAP_LAYOUT:
visibleFieldsis rejected — GitHub decides what the timeline shows.createViewthrows aTypeErrorbefore the round-trip rather than letting the API reject it.- The date fields that drive the bars cannot be set through the API.
ProjectV2ViewConfigurationInputonly carriesvisibleFieldIds. Create the project's date fields before the view and GitHub will usually adopt them; otherwise pick them in the view's settings in the UI.
await project.createField({ name: "Points", dataType: "NUMBER" });
await project.createField({
name: "Stage",
dataType: "SINGLE_SELECT",
options: ["Backlog", { name: "In Flight", color: "YELLOW" }, "Shipped"],
});
await project.createField({
name: "Sprint",
dataType: "ITERATION",
iteration: { startDate: "2026-09-07", duration: 14 },
});
await project.deleteField("Points");createField refreshes the field cache. If you change a project's fields
outside this client, call project.refreshFields().
GitHub creates an ITERATION field with no iterations unless the mutation seeds them, and a field with no iterations has nothing to assign. This library seeds one from
startDate/duration; passiteration.iterationsfor more.
All errors extend GitHubProjectsError.
| Class | When |
|---|---|
AuthError |
no token could be resolved |
HttpError |
non-2xx response; carries status, body, headers |
GraphQLError |
200 with an errors array; carries errors, type, data |
NotFoundError |
a project, field, option or iteration did not resolve by name |
Requests retry with jittered exponential backoff on 429, 5xx, secondary rate
limits (403 + Retry-After) and RATE_LIMITED, honouring Retry-After and
X-RateLimit-Reset. Tune with retries, retryDelayMs, maxRetryDelayMs.
gh.rateLimit() reports the headers from the most recent response.
gh.graphql(query, variables) runs any document against the same authenticated,
retrying transport — for the parts of the Projects API this wrapper doesn't
cover (views, workflows, status updates).
const data = await gh.graphql(
`query($id: ID!) { node(id: $id) { ... on ProjectV2 { views(first: 10) { nodes { name } } } } }`,
{ id: project.id },
);examples/triage-issue.ts— add an issue by URL and set its fields.examples/status-report.ts— read a board and summarise it.examples/build-roadmap.ts— create a project, fields and a Gantt view.
docs/llms.txt is a map of this repo for coding agents.
| File | |
|---|---|
index.ts |
public surface; re-exports everything below |
client.ts |
createClient, GitHubProjects, project/owner reference parsing |
project.ts |
the Project class — fields, items, views, mutations |
values.ts |
GraphQL nodes → plain values, and values → field inputs |
fragments.ts |
shared GraphQL fragments |
graphql.ts |
transport: auth, retries, rate limits |
types.ts |
shared types |
errors.ts |
error classes |
examples/ |
runnable scripts |
docs/llms.txt |
repo map for coding agents |