@stacksjs/github 0.70.162 → 0.70.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export declare function ghHeaders(): Record<string, string>;
2
+ export declare function githubHeaders(token?: string): Record<string, string>;
3
+ /** Provider-neutral GitHub request helper for both reads and writes. */
4
+ export declare function githubRequest(path: string, init?: RequestInit, options?: GitHubClientOptions, attempt?: number): Promise<Response>;
5
+ export declare function githubJson<T>(path: string, init?: RequestInit, options?: GitHubClientOptions): Promise<T>;
2
6
  /**
3
7
  * `fetch` against the GitHub API that retries on secondary rate limits.
4
8
  *
@@ -13,3 +17,8 @@ export declare function ghFetch(url: string, attempt?: number): Promise<Response
13
17
  */
14
18
  export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
15
19
  export declare const GITHUB_API: 'https://api.github.com';
20
+ export declare interface GitHubClientOptions {
21
+ token?: string
22
+ apiUrl?: string
23
+ fetch?: typeof globalThis.fetch
24
+ }
package/dist/client.js CHANGED
@@ -5,6 +5,11 @@ function getToken() {
5
5
  throw Error("GITHUB_TOKEN environment variable is required");
6
6
  return token;
7
7
  }
8
+ function resolveToken(token) {
9
+ if (token)
10
+ return token;
11
+ return getToken();
12
+ }
8
13
  export function ghHeaders() {
9
14
  return {
10
15
  Authorization: `Bearer ${getToken()}`,
@@ -12,6 +17,50 @@ export function ghHeaders() {
12
17
  "X-GitHub-Api-Version": "2022-11-28"
13
18
  };
14
19
  }
20
+ export function githubHeaders(token) {
21
+ return {
22
+ Authorization: `Bearer ${resolveToken(token)}`,
23
+ Accept: "application/vnd.github+json",
24
+ "X-GitHub-Api-Version": "2022-11-28"
25
+ };
26
+ }
27
+ function retryDelay(res, attempt) {
28
+ if (!(res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || !!res.headers.get("retry-after"))))
29
+ return null;
30
+ const retryAfterHeader = res.headers.get("retry-after"), resetHeader = res.headers.get("x-ratelimit-reset");
31
+ if (retryAfterHeader)
32
+ return Number(retryAfterHeader) * 1000;
33
+ if (resetHeader)
34
+ return Math.max(0, Number(resetHeader) * 1000 - Date.now()) + 500;
35
+ return 1000 * 2 ** attempt;
36
+ }
37
+ export async function githubRequest(path, init = {}, options = {}, attempt = 0) {
38
+ const fetcher = options.fetch ?? globalThis.fetch, url = path.startsWith("http") ? path : `${options.apiUrl ?? GITHUB_API}${path.startsWith("/") ? path : `/${path}`}`, response = await fetcher(url, {
39
+ ...init,
40
+ headers: { ...githubHeaders(options.token), ...init.headers }
41
+ });
42
+ if (response.ok || attempt >= 3)
43
+ return response;
44
+ const waitMs = retryDelay(response, attempt);
45
+ if (waitMs === null)
46
+ return response;
47
+ await new Promise((resolve) => setTimeout(resolve, Math.min(waitMs, 30000)));
48
+ return githubRequest(path, init, options, attempt + 1);
49
+ }
50
+ export async function githubJson(path, init = {}, options = {}) {
51
+ const response = await githubRequest(path, init, options);
52
+ if (!response.ok) {
53
+ const payload = await response.text();
54
+ let message = payload;
55
+ try {
56
+ message = JSON.parse(payload).message || payload;
57
+ } catch {}
58
+ throw Error(`GitHub API ${response.status}: ${message || response.statusText}`);
59
+ }
60
+ if (response.status === 204)
61
+ return;
62
+ return await response.json();
63
+ }
15
64
  export async function ghFetch(url, attempt = 0) {
16
65
  const res = await fetch(url, { headers: ghHeaders() });
17
66
  if (res.ok || attempt >= 3)
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type { GitHubClientOptions } from './client';
1
2
  export type { DetectOptions, FailedTransition, PreviousRunState } from './failure-detector';
2
3
  export type { WorkflowJob, WorkflowRun } from './run-history';
3
4
  export type {
@@ -6,6 +7,13 @@ export type {
6
7
  RunnerAlertState,
7
8
  RunnerSample,
8
9
  } from './runner-pressure-detector';
10
+ export type {
11
+ CreatedPullRequest,
12
+ CreatePullRequestWithFilesOptions,
13
+ RepositoryFileChange,
14
+ RepositoryTree,
15
+ RepositoryTreeEntry,
16
+ } from './pull-requests';
9
17
  export type {
10
18
  DashboardData,
11
19
  DashboardOptions,
@@ -25,7 +33,7 @@ export type {
25
33
  * single dimension without pulling the whole snapshot through.
26
34
  */
27
35
  export { fetchBotPRCounts } from './bots';
28
- export { ghFetch, ghHeaders, GITHUB_API, mapWithConcurrency } from './client';
36
+ export { ghFetch, ghHeaders, githubHeaders, githubJson, githubRequest, GITHUB_API, mapWithConcurrency } from './client';
29
37
  export { clearDashboardCache, getDashboardData } from './dashboard';
30
38
  export { detectNewlyFailedRuns } from './failure-detector';
31
39
  export { fetchAllRepos } from './repos';
@@ -33,3 +41,4 @@ export { fetchRepoActiveRuns } from './runners';
33
41
  export { fetchRunJobs, fetchWorkflowRuns } from './run-history';
34
42
  export { detectRunnerPressure } from './runner-pressure-detector';
35
43
  export { fetchFailedJobs, fetchRepoStatus } from './runs';
44
+ export { createPullRequestWithFiles, fetchRepositoryFile, fetchRepositoryTree } from './pull-requests';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { fetchBotPRCounts } from "./bots";
2
- export { ghFetch, ghHeaders, GITHUB_API, mapWithConcurrency } from "./client";
2
+ export { ghFetch, ghHeaders, githubHeaders, githubJson, githubRequest, GITHUB_API, mapWithConcurrency } from "./client";
3
3
  export { clearDashboardCache, getDashboardData } from "./dashboard";
4
4
  export { detectNewlyFailedRuns } from "./failure-detector";
5
5
  export { fetchAllRepos } from "./repos";
@@ -7,3 +7,4 @@ export { fetchRepoActiveRuns } from "./runners";
7
7
  export { fetchRunJobs, fetchWorkflowRuns } from "./run-history";
8
8
  export { detectRunnerPressure } from "./runner-pressure-detector";
9
9
  export { fetchFailedJobs, fetchRepoStatus } from "./runs";
10
+ export { createPullRequestWithFiles, fetchRepositoryFile, fetchRepositoryTree } from "./pull-requests";
@@ -0,0 +1,43 @@
1
+ import type { GitHubClientOptions } from './client';
2
+ export declare function fetchRepositoryTree(owner: string, repo: string, ref: string, options?: GitHubClientOptions): Promise<RepositoryTree>;
3
+ export declare function fetchRepositoryFile(owner: string, repo: string, path: string, ref: string, options?: GitHubClientOptions & { maxBytes?: number }): Promise<string>;
4
+ /**
5
+ * Create one commit containing all file replacements, expose it through a new
6
+ * branch, and open a pull request. The branch is only created after every blob,
7
+ * tree, and commit has succeeded, so partial generation never exposes half a fix.
8
+ */
9
+ export declare function createPullRequestWithFiles(options: CreatePullRequestWithFilesOptions): Promise<CreatedPullRequest>;
10
+ export declare interface RepositoryFileChange {
11
+ path: string
12
+ content: string
13
+ }
14
+ export declare interface CreatePullRequestWithFilesOptions extends GitHubClientOptions {
15
+ owner: string
16
+ repo: string
17
+ branch: string
18
+ title: string
19
+ body: string
20
+ commitMessage: string
21
+ files: RepositoryFileChange[]
22
+ base?: string
23
+ draft?: boolean
24
+ }
25
+ export declare interface CreatedPullRequest {
26
+ number: number
27
+ url: string
28
+ branch: string
29
+ base: string
30
+ commitSha: string
31
+ }
32
+ export declare interface RepositoryTreeEntry {
33
+ path: string
34
+ mode: string
35
+ type: 'blob' | 'tree' | 'commit'
36
+ sha: string
37
+ size?: number
38
+ }
39
+ export declare interface RepositoryTree {
40
+ sha: string
41
+ truncated: boolean
42
+ entries: RepositoryTreeEntry[]
43
+ }
@@ -0,0 +1,83 @@
1
+ import { githubJson } from "./client";
2
+ function repoPath(owner, repo) {
3
+ if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo))
4
+ throw Error("GitHub owner and repository names contain invalid characters");
5
+ return `/repos/${owner}/${repo}`;
6
+ }
7
+ function validateBranch(branch) {
8
+ if (!branch || branch.length > 240 || branch.startsWith("/") || branch.endsWith("/") || branch.includes("..") || /[~^:?*[\\\s]/.test(branch))
9
+ throw Error(`Invalid Git branch name: ${branch}`);
10
+ }
11
+ function validateFiles(files) {
12
+ if (!files.length)
13
+ throw Error("At least one file change is required");
14
+ if (files.length > 100)
15
+ throw Error("A pull request may change at most 100 files");
16
+ const seen = new Set;
17
+ let bytes = 0;
18
+ for (const file of files) {
19
+ if (!file.path || file.path.startsWith("/") || file.path.includes("\\") || file.path.split("/").includes(".."))
20
+ throw Error(`Unsafe repository path: ${file.path}`);
21
+ if (seen.has(file.path))
22
+ throw Error(`Duplicate repository path: ${file.path}`);
23
+ seen.add(file.path);
24
+ bytes += Buffer.byteLength(file.content);
25
+ }
26
+ if (bytes > 5242880)
27
+ throw Error("Combined file content exceeds the 5 MiB safety limit");
28
+ }
29
+ export async function fetchRepositoryTree(owner, repo, ref, options = {}) {
30
+ const payload = await githubJson(`${repoPath(owner, repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`, {}, options);
31
+ return { sha: payload.sha, truncated: !!payload.truncated, entries: payload.tree ?? [] };
32
+ }
33
+ export async function fetchRepositoryFile(owner, repo, path, ref, options = {}) {
34
+ validateFiles([{ path, content: "" }]);
35
+ const payload = await githubJson(`${repoPath(owner, repo)}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`, {}, options);
36
+ if (payload.type !== "file" || payload.encoding !== "base64" || typeof payload.content !== "string")
37
+ throw Error(`GitHub path is not a base64 encoded file: ${path}`);
38
+ const maxBytes = options.maxBytes ?? 262144;
39
+ if ((payload.size ?? 0) > maxBytes)
40
+ throw Error(`GitHub file exceeds the ${maxBytes} byte safety limit: ${path}`);
41
+ const content = Buffer.from(payload.content.replace(/\n/g, ""), "base64");
42
+ if (content.byteLength > maxBytes)
43
+ throw Error(`Decoded GitHub file exceeds the ${maxBytes} byte safety limit: ${path}`);
44
+ return content.toString("utf8");
45
+ }
46
+ export async function createPullRequestWithFiles(options) {
47
+ validateBranch(options.branch);
48
+ validateFiles(options.files);
49
+ const basePath = repoPath(options.owner, options.repo), client = { token: options.token, apiUrl: options.apiUrl, fetch: options.fetch };
50
+ let base = options.base;
51
+ if (!base)
52
+ base = (await githubJson(basePath, {}, client)).default_branch;
53
+ if (!base)
54
+ throw Error("GitHub repository has no default branch");
55
+ const reference = await githubJson(`${basePath}/git/ref/heads/${encodeURIComponent(base)}`, {}, client), baseCommit = await githubJson(`${basePath}/git/commits/${reference.object.sha}`, {}, client), blobs = await Promise.all(options.files.map(async (file) => {
56
+ const blob = await githubJson(`${basePath}/git/blobs`, {
57
+ method: "POST",
58
+ body: JSON.stringify({ content: file.content, encoding: "utf-8" })
59
+ }, client);
60
+ return { path: file.path, mode: "100644", type: "blob", sha: blob.sha };
61
+ })), tree = await githubJson(`${basePath}/git/trees`, {
62
+ method: "POST",
63
+ body: JSON.stringify({ base_tree: baseCommit.tree.sha, tree: blobs })
64
+ }, client), commit = await githubJson(`${basePath}/git/commits`, {
65
+ method: "POST",
66
+ body: JSON.stringify({ message: options.commitMessage, tree: tree.sha, parents: [reference.object.sha] })
67
+ }, client);
68
+ await githubJson(`${basePath}/git/refs`, {
69
+ method: "POST",
70
+ body: JSON.stringify({ ref: `refs/heads/${options.branch}`, sha: commit.sha })
71
+ }, client);
72
+ const pull = await githubJson(`${basePath}/pulls`, {
73
+ method: "POST",
74
+ body: JSON.stringify({
75
+ title: options.title,
76
+ body: options.body,
77
+ head: options.branch,
78
+ base,
79
+ draft: options.draft ?? !0
80
+ })
81
+ }, client);
82
+ return { number: pull.number, url: pull.html_url, branch: options.branch, base, commitSha: commit.sha };
83
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/github",
3
3
  "type": "module",
4
- "version": "0.70.162",
4
+ "version": "0.70.163",
5
5
  "description": "GitHub API client used by Stacks framework features (dashboard CI surface, notifications, runner alerts).",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [