Skip to content

About

UraiJS integration to Github projects

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

gh-projects

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);
}

Authentication

In order of preference:

  1. createClient({ token })
  2. meta.secrets.GITHUB_TOKEN
  3. urai: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.

Opening a project

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" });

Reading

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, with optionId, iterationId, the field's dataType, and the raw GraphQL node.

Writing

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.

Views

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:

  • visibleFields is rejected — GitHub decides what the timeline shows. createView throws a TypeError before the round-trip rather than letting the API reject it.
  • The date fields that drive the bars cannot be set through the API. ProjectV2ViewConfigurationInput only carries visibleFieldIds. 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.

Custom fields

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; pass iteration.iterations for more.

Errors

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.

Escape hatch

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

docs/llms.txt is a map of this repo for coding agents.

Layout

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

About

UraiJS integration to Github projects

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages