stack/apps/dashboard/src/lib/github-api.ts
BilalG1 b8fc04bdbd
feat: link Stack Auth projects to GitHub and push config from the dashboard (#1450)
End-to-end flow for managing Stack Auth config via GitHub: link a repo
during onboarding, edit settings in the dashboard, and have the change
committed to your repo + synced back via a GitHub Actions workflow.


![demo](https://gist.githubusercontent.com/BilalG1/29d1188fc581e87d1311baec6e2ae770/raw/demo-2x.gif)

## What this adds

- **CLI** — `stack config push --source github --source-repo
--source-path --source-workflow-path`. Records the source on the config
row so the dashboard knows where the file lives. Reads `GITHUB_SHA` /
`GITHUB_REF_NAME` for commit + branch.
- **Onboarding "Link existing project"** — searchable repo/branch
comboboxes, auto-detects candidate `stack.config.{ts,js}` paths, writes
`STACK_AUTH_PROJECT_ID` + `STACK_AUTH_SECRET_SERVER_KEY` secrets, and
commits a generated workflow YAML that re-runs `stack config push` on
every change to the config file.
- **Dashboard "Push to GitHub" dialog** — replaces the prior TODO
buttons. Pre-flights `repo`+`workflow` scopes on the user's GitHub
connection; if missing, the button flips to "Reconnect with GitHub". On
push, commits the dashboard's edit straight to the linked repo/branch
via the Contents API (with `cache: "no-store"` to dodge GitHub's 60s GET
cache so consecutive pushes don't 409). Suspense boundary scoped to the
dialog body so opening it doesn't blank the dashboard.
- **Project settings** — surface the linked workflow file as a clickable
GitHub link when the source carries `workflow_path`.

## Test plan

- `pnpm lint` (29/29) ✓
- `pnpm typecheck` (29/29) ✓
- `pnpm --filter @stackframe/stack-cli test` (111/111) ✓
- Dashboard vitest on the three relevant files
(`link-existing-onboarding-workflow`, `github-api`,
`github-config-push`) — 37/37 ✓
- Live end-to-end: `BilalG1/lex-lookup` linked to a local dev project;
passkey toggled, push committed `0bb958bd`
([commit](0bb958bda3)).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Persist workflow file paths for GitHub-backed config sync
* Dashboard “Push” flow to commit config updates with trimmed/default
commit messages
* CLI options to declare GitHub source (repo/path/workflow) and persist
selectable package runner for manual pushes
  * Show workflow-file link in project configuration when present

* **Improvements**
* Robust config-path normalization, existence checks, debounced
repo/branch search, and better GitHub rate-limit handling
* New GitHub API utilities for safe file read/commit and import-package
detection

* **Tests**
* Expanded tests covering GitHub API, config rendering/merge, and push
behaviors

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1450?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-21 13:47:46 -07:00

205 lines
7.0 KiB
TypeScript

/**
* Client-side helpers for talking to the GitHub REST API on behalf of a Stack
* user's connected GitHub account.
*
* Kept separate from any React/hook code so the helpers are easy to unit-test
* and to share between the new-project onboarding flow and the config-update
* dialog.
*/
import type { OAuthConnection } from "@stackframe/stack";
export const GITHUB_SCOPE_REQUIREMENTS = ["repo", "workflow"];
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function getObjectString(value: Record<string, unknown>, key: string): string | null {
const field = value[key];
return typeof field === "string" ? field : null;
}
export function parseRepositoryFullName(fullName: string): { owner: string, repo: string } {
const slashIndex = fullName.indexOf("/");
if (slashIndex <= 0 || slashIndex >= fullName.length - 1 || fullName.indexOf("/", slashIndex + 1) !== -1) {
throw new Error(`Repository must be in the format 'owner/repo' (got '${fullName}').`);
}
return {
owner: fullName.slice(0, slashIndex),
repo: fullName.slice(slashIndex + 1),
};
}
export function encodeGitHubPath(path: string): string {
return path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
export function githubRepositoryContentsUrl(owner: string, repo: string, path: string): string {
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeGitHubPath(path)}`;
}
export type GithubFetch = (path: string, requestInit?: RequestInit) => Promise<unknown>;
/**
* Returns a `githubFetch` helper bound to the given OAuth connection. The
* helper accepts an `api.github.com`-relative path (e.g. "/user") and returns
* the parsed JSON body. Non-2xx responses are turned into thrown Errors whose
* message is the GitHub-supplied `message` field when present.
*/
export function createGithubFetch(account: OAuthConnection): GithubFetch {
return async (path, requestInit) => {
const tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS });
if (tokenResult.status === "error") {
throw new Error("Could not get a GitHub access token. Reconnect your GitHub account and try again.");
}
const response = await fetch(new URL(path, "https://api.github.com").toString(), {
...requestInit,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${tokenResult.data.accessToken}`,
...(requestInit?.headers ?? {}),
},
});
if (response.status === 204) {
// 204 is always a success status (any 2xx satisfies `response.ok`),
// so no error check is needed here.
return null;
}
const responseText = await response.text();
const parsedBody = responseText.length > 0 ? JSON.parse(responseText) : null;
if (!response.ok) {
const parsedMessage = isObject(parsedBody) ? getObjectString(parsedBody, "message") : null;
throw new Error(parsedMessage ?? `GitHub API request failed with status ${response.status}.`);
}
return parsedBody;
};
}
export type GithubFileContent = {
/** UTF-8 decoded file content. */
text: string,
/** Blob SHA — required when updating the file via the Contents API. */
sha: string,
};
/**
* Fetches a file via `GET /repos/{owner}/{repo}/contents/{path}` and returns
* its decoded UTF-8 content plus blob SHA. Returns `null` if the file does not
* exist on the given branch.
*
* Errors that are not 404s (network failures, permission errors, etc.) are
* re-thrown.
*/
export async function getFileContent(
githubFetch: GithubFetch,
options: { owner: string, repo: string, branch: string, path: string },
): Promise<GithubFileContent | null> {
const { owner, repo, branch, path } = options;
const refQuery = new URLSearchParams({ ref: branch }).toString();
try {
// `cache: "no-store"` because GitHub's Contents API responds with
// `Cache-Control: private, max-age=60` for authenticated reads, and the
// browser's HTTP cache is not invalidated by our subsequent PUT to the
// same URL. Without this, a second push within ~60s reads a stale blob
// SHA and the PUT fails with 409 "{path} does not match {sha}".
const response = await githubFetch(`${githubRepositoryContentsUrl(owner, repo, path)}?${refQuery}`, { cache: "no-store" });
if (!isObject(response) || Array.isArray(response)) {
// GitHub returns an array when the path is a directory; treat that as
// "file not found" so the caller surfaces a clear error.
return null;
}
const type = getObjectString(response, "type");
if (type !== "file") {
return null;
}
const encoding = getObjectString(response, "encoding");
const rawContent = getObjectString(response, "content");
const sha = getObjectString(response, "sha");
if (rawContent == null || sha == null) {
throw new Error("GitHub file response is missing content or sha.");
}
if (encoding !== "base64") {
throw new Error(`Unexpected GitHub file encoding '${encoding ?? "<missing>"}'.`);
}
return {
text: decodeBase64Utf8(rawContent),
sha,
};
} catch (error) {
if (error instanceof Error && /Not Found/i.test(error.message)) {
return null;
}
throw error;
}
}
/**
* Creates or updates a file via `PUT /repos/{owner}/{repo}/contents/{path}`.
* `sha` is required when updating an existing file (the blob SHA from
* `getFileContent`) and must be omitted when creating a new file.
*/
export async function commitFile(
githubFetch: GithubFetch,
options: {
owner: string,
repo: string,
branch: string,
path: string,
content: string,
message: string,
sha?: string,
},
): Promise<void> {
const { owner, repo, branch, path, content, message, sha } = options;
const body: Record<string, unknown> = {
message,
content: encodeBase64Utf8(content),
branch,
};
if (sha !== undefined) {
body.sha = sha;
}
await githubFetch(githubRepositoryContentsUrl(owner, repo, path), {
method: "PUT",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
});
}
function decodeBase64Utf8(base64: string): string {
const stripped = base64.replace(/\s+/g, "");
if (typeof globalThis.atob === "function") {
const binary = globalThis.atob(stripped);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder("utf-8").decode(bytes);
}
// Node fallback for unit tests.
return Buffer.from(stripped, "base64").toString("utf-8");
}
function encodeBase64Utf8(text: string): string {
const bytes = new TextEncoder().encode(text);
if (typeof globalThis.btoa === "function") {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return globalThis.btoa(binary);
}
return Buffer.from(bytes).toString("base64");
}