Skip to content

Commit 5f4940c

Browse files
committed
chore: implement new APIs
1 parent 4efcbec commit 5f4940c

17 files changed

Lines changed: 315 additions & 93 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,5 @@ vite.config.js.timestamp-*
139139
vite.config.ts.timestamp-*
140140

141141
temp.ts
142+
143+
.claude

README.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,62 @@ $ npm i @openally/github.sdk
3838
$ yarn add @openally/github.sdk
3939
```
4040

41-
## 📚 Usage
41+
## 👀 Usage
4242

4343
```ts
44-
// TBC
44+
import {
45+
repos,
46+
users
47+
} from "@openally/github.sdk";
48+
49+
// Iterate over all pull requests (pagination handled automatically)
50+
for await (const pr of repos.OpenAlly["github.sdk"].pulls().iterate()) {
51+
console.log(pr.title);
52+
}
53+
54+
// Fetch all tags at once
55+
const tags = await repos.OpenAlly["github.sdk"].tags().all();
56+
57+
// List all repos for a user
58+
const userRepos = await users.torvalds.repos().all();
59+
60+
// Iterate over workflow runs for a specific workflow
61+
for await (const run of repos.nodejs.node.workflowRuns("ci.yml").iterate()) {
62+
console.log(run.id, run.status);
63+
}
4564
```
4665

47-
## API
48-
49-
TBC
66+
Each method returns an `ApiEndpoint<T>` instance with:
67+
- `.setBearerToken(token)` — attach a GitHub personal access token
68+
- `.setAgent(userAgent)` — override the default `User-Agent` header
69+
- `.iterate()``AsyncIterableIterator<T>` that handles pagination transparently
70+
- `.all()``Promise<T[]>` collecting all pages
71+
72+
## 📚 API
73+
74+
### `repos[owner][repo]`
75+
76+
| Method | GitHub Documentation |
77+
|---|---|
78+
| `.tags()` | [List repository tags](https://docs.github.com/en/rest/repos/repos#list-repository-tags) |
79+
| `.pulls()` | [List pull requests](https://docs.github.com/en/rest/pulls/pulls#list-pull-requests) |
80+
| `.issues()` | [List repository issues](https://docs.github.com/en/rest/issues/issues#list-repository-issues) |
81+
| `.commits()` | [List commits](https://docs.github.com/en/rest/commits/commits#list-commits) |
82+
| `.workflows()` | [List repository workflows](https://docs.github.com/en/rest/actions/workflows#list-repository-workflows) |
83+
| `.workflowRuns(workflowId)` | [List workflow runs for a workflow](https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-workflow) |
84+
| `.runJobs(runId)` | [List jobs for a workflow run](https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run) |
85+
| `.runArtifacts(runId)` | [List workflow run artifacts](https://docs.github.com/en/rest/actions/artifacts#list-workflow-run-artifacts) |
86+
87+
### `users[username]`
88+
89+
| Method | GitHub Documentation |
90+
|---|---|
91+
| `.orgs()` | [List organizations for a user](https://docs.github.com/en/rest/orgs/orgs#list-organizations-for-a-user) |
92+
| `.repos()` | [List repositories for a user](https://docs.github.com/en/rest/repos/repos#list-repositories-for-a-user) |
93+
| `.gists()` | [List gists for a user](https://docs.github.com/en/rest/gists/gists#list-gists-for-a-user) |
94+
| `.followers()` | [List followers of a user](https://docs.github.com/en/rest/users/followers#list-followers-of-a-user) |
95+
| `.following()` | [List the people a user follows](https://docs.github.com/en/rest/users/followers#list-the-people-a-user-follows) |
96+
| `.starred()` | [List repositories starred by a user](https://docs.github.com/en/rest/activity/starring#list-repositories-starred-by-a-user) |
5097

5198
## Contributors ✨
5299

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,11 @@
3939
"devDependencies": {
4040
"@openally/config.eslint": "^2.2.0",
4141
"@openally/config.typescript": "^1.2.1",
42-
"@types/http-link-header": "1.0.7",
4342
"@types/node": "25.3.1",
4443
"c8": "^11.0.0",
4544
"typescript": "^5.9.3"
4645
},
4746
"dependencies": {
48-
"@octokit/types": "16.0.0",
49-
"http-link-header": "1.1.3"
47+
"@octokit/types": "16.0.0"
5048
}
5149
}

src/api/.gitkeep

Whitespace-only changes.

src/api/repos.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Import Internal Dependencies
2+
import { ApiEndpoint } from "../class/ApiEndpoint.ts";
3+
import { createApiProxy } from "../class/createApiProxy.ts";
4+
import type {
5+
Tag,
6+
PullRequest,
7+
Issue,
8+
Commit,
9+
Workflow,
10+
WorkflowRun,
11+
Job,
12+
Artifact,
13+
WorkflowsResponse,
14+
WorkflowRunsResponse,
15+
JobsResponse,
16+
ArtifactsResponse,
17+
RequestConfig
18+
} from "../types.ts";
19+
20+
type RepoEndpointMethods = {
21+
tags: () => ApiEndpoint<Tag>;
22+
pulls: () => ApiEndpoint<PullRequest>;
23+
issues: () => ApiEndpoint<Issue>;
24+
commits: () => ApiEndpoint<Commit>;
25+
workflows: () => ApiEndpoint<Workflow>;
26+
workflowRuns: (workflowId: string | number) => ApiEndpoint<WorkflowRun>;
27+
runJobs: (runId: number) => ApiEndpoint<Job>;
28+
runArtifacts: (runId: number) => ApiEndpoint<Artifact>;
29+
};
30+
31+
export type ReposProxy = {
32+
[owner: string]: {
33+
[repo: string]: RepoEndpointMethods;
34+
};
35+
};
36+
37+
function createRepoProxy(
38+
owner: string,
39+
repo: string,
40+
config: RequestConfig = {}
41+
): RepoEndpointMethods {
42+
return {
43+
tags: () => new ApiEndpoint<Tag>(`/repos/${owner}/${repo}/tags`, config),
44+
pulls: () => new ApiEndpoint<PullRequest>(`/repos/${owner}/${repo}/pulls`, config),
45+
issues: () => new ApiEndpoint<Issue>(`/repos/${owner}/${repo}/issues`, config),
46+
commits: () => new ApiEndpoint<Commit>(`/repos/${owner}/${repo}/commits`, config),
47+
workflows: () => new ApiEndpoint<Workflow>(
48+
`/repos/${owner}/${repo}/actions/workflows`,
49+
{ ...config, extractor: (raw: WorkflowsResponse) => raw.workflows }
50+
),
51+
workflowRuns: (workflowId: string | number) => new ApiEndpoint<WorkflowRun>(
52+
`/repos/${owner}/${repo}/actions/workflows/${workflowId}/runs`,
53+
{ ...config, extractor: (raw: WorkflowRunsResponse) => raw.workflow_runs }
54+
),
55+
runJobs: (runId: number) => new ApiEndpoint<Job>(
56+
`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`,
57+
{ ...config, extractor: (raw: JobsResponse) => raw.jobs }
58+
),
59+
runArtifacts: (runId: number) => new ApiEndpoint<Artifact>(
60+
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`,
61+
{ ...config, extractor: (raw: ArtifactsResponse) => raw.artifacts }
62+
)
63+
};
64+
}
65+
66+
export function createReposProxy(config: RequestConfig = {}): ReposProxy {
67+
return createApiProxy(
68+
(owner) => createApiProxy(
69+
(repo) => createRepoProxy(owner, repo, config)
70+
)
71+
) as ReposProxy;
72+
}
73+
74+
export const repos = createReposProxy();

src/api/users.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Import Internal Dependencies
2+
import { ApiEndpoint } from "../class/ApiEndpoint.ts";
3+
import { createApiProxy } from "../class/createApiProxy.ts";
4+
import type {
5+
UserOrg,
6+
UserRepo,
7+
UserGist,
8+
UserFollower,
9+
UserFollowing,
10+
UserStarred,
11+
RequestConfig
12+
} from "../types.ts";
13+
14+
// CONSTANTS
15+
const kUserEndpointResponseMap = {
16+
orgs: {} as UserOrg,
17+
repos: {} as UserRepo,
18+
gists: {} as UserGist,
19+
followers: {} as UserFollower,
20+
following: {} as UserFollowing,
21+
starred: {} as UserStarred
22+
};
23+
24+
type UserEndpoint = keyof typeof kUserEndpointResponseMap;
25+
type UserEndpointMethods = {
26+
[K in UserEndpoint]: () => ApiEndpoint<typeof kUserEndpointResponseMap[K]>;
27+
};
28+
export type UsersProxy = {
29+
[username: string]: UserEndpointMethods;
30+
};
31+
32+
function createUserProxy(
33+
username: string,
34+
config: RequestConfig = {}
35+
): UserEndpointMethods {
36+
return Object.fromEntries(
37+
(Object.keys(kUserEndpointResponseMap) as UserEndpoint[]).map(
38+
(endpoint) => [endpoint, () => new ApiEndpoint(`/users/${username}/${endpoint}`, config)]
39+
)
40+
) as UserEndpointMethods;
41+
}
42+
43+
export function createUsersProxy(config: RequestConfig = {}): UsersProxy {
44+
return createApiProxy((username) => createUserProxy(username, config)) as UsersProxy;
45+
}
46+
47+
export const users = createUsersProxy();

src/class/ApiEndpoint.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,50 @@
11
// Import Internal Dependencies
2-
import * as utils from "../utils/index.ts";
2+
import { HttpLinkParser } from "./HttpLinkParser.ts";
33

44
// CONSTANTS
55
const kGithubURL = new URL("https://api.github.com/");
66

7-
/**
8-
* @see https://github.com/dashlog/fetch-github-repositories/blob/master/src/api.ts#L66
9-
* @see https://github.com/fraxken/dep-updater/blob/master/src/githubActions.ts#L97C53-L97C66
10-
*/
7+
export class ApiEndpointOptions<T> {
8+
/**
9+
* By default, the raw response from the GitHub API is returned as-is.
10+
* You can provide a custom extractor function to transform the raw response
11+
* into an array of type T.
12+
*/
13+
extractor?: (raw: any) => T[];
14+
/**
15+
* A personal access token is required to access private resources,
16+
* and to increase the rate limit for unauthenticated requests.
17+
*/
18+
token?: string;
19+
/**
20+
* @default "@openally/github.sdk/1.0.0"
21+
* @see https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api?apiVersion=2022-11-28#user-agent
22+
*/
23+
userAgent?: string;
24+
}
1125

1226
export class ApiEndpoint<T> {
1327
#userAgent: string;
1428
#bearerToken?: string;
1529

1630
#nextURL: string | null = null;
1731
#apiEndpoint: string | URL;
32+
#extractor: (raw: any) => T[];
1833

1934
constructor(
20-
apiEndpoint: string | URL
35+
apiEndpoint: string | URL,
36+
options: ApiEndpointOptions<T> = {}
2137
) {
22-
this.#userAgent = "@openally/github.sdk/1.0.0";
38+
const {
39+
userAgent = "@openally/github.sdk/1.0.0",
40+
token,
41+
extractor = ((raw) => raw as T[])
42+
} = options;
43+
44+
this.#userAgent = userAgent;
45+
this.#bearerToken = token;
2346
this.#apiEndpoint = apiEndpoint;
47+
this.#extractor = extractor;
2448
}
2549

2650
setBearerToken(
@@ -57,12 +81,14 @@ export class ApiEndpoint<T> {
5781
url,
5882
{ headers }
5983
);
60-
const data = await response.json() as T[];
84+
const rawData = await response.json();
6185

6286
const linkHeader = response.headers.get("link");
63-
this.#nextURL = utils.getNextPageURL(linkHeader);
87+
this.#nextURL = linkHeader
88+
? HttpLinkParser.parse(linkHeader).get("next") ?? null
89+
: null;
6490

65-
return data;
91+
return this.#extractor(rawData);
6692
}
6793

6894
async* iterate(): AsyncIterableIterator<T> {
@@ -74,6 +100,6 @@ export class ApiEndpoint<T> {
74100
}
75101

76102
all(): Promise<T[]> {
77-
return utils.fromAsync(this.iterate());
103+
return Array.fromAsync(this.iterate());
78104
}
79105
}

src/class/GithubClient.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Import Internal Dependencies
2+
import {
3+
createUsersProxy,
4+
type UsersProxy
5+
} from "../api/users.ts";
6+
import {
7+
createReposProxy,
8+
type ReposProxy
9+
} from "../api/repos.ts";
10+
11+
export interface GithubClientOptions {
12+
token?: string;
13+
userAgent?: string;
14+
}
15+
16+
export class GithubClient {
17+
readonly users: UsersProxy;
18+
readonly repos: ReposProxy;
19+
20+
constructor(
21+
options: GithubClientOptions = {}
22+
) {
23+
const config = {
24+
token: options.token,
25+
userAgent: options.userAgent
26+
};
27+
28+
this.users = createUsersProxy(config);
29+
this.repos = createReposProxy(config);
30+
}
31+
}

src/class/HttpLinkParser.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export class HttpLinkParser {
2+
static parse(
3+
headerValue: string
4+
): Map<string, string> {
5+
const result = new Map<string, string>();
6+
7+
for (const part of headerValue.split(", ")) {
8+
const urlMatch = part.match(/^<([^>]+)>/);
9+
const relMatch = part.match(/rel="([^"]+)"/);
10+
if (urlMatch && relMatch) {
11+
result.set(relMatch[1], urlMatch[1]);
12+
}
13+
}
14+
15+
return result;
16+
}
17+
}

src/class/createApiProxy.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function createApiProxy<T>(
2+
factory: (key: string) => T
3+
): Record<string, T> {
4+
return new Proxy(Object.create(null), {
5+
get(_, key: string) {
6+
return factory(key);
7+
}
8+
}) as Record<string, T>;
9+
}

0 commit comments

Comments
 (0)