Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ const github = new GithubClient({
token: process.env.GITHUB_TOKEN
});

for await (const pr of github.repos.nodejs.node.pulls().iterate()) {
for await (const pr of github.repos.nodejs.node.pulls()) {
console.log(pr.title);
}

const tags = await github.repos.OpenAlly["github.sdk"].tags().all();
const tags = await github.repos.OpenAlly["github.sdk"].tags();

const userRepos = await github.users.torvalds.repos().all();
const userRepos = await github.users.torvalds.repos();
```

## 📚 API
Expand Down
33 changes: 31 additions & 2 deletions docs/api/ApiEndpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ const endpoint = repos.nodejs.node.pulls()
.setAgent("my-app/1.0.0");

// Stream items one by one across all pages
for await (const pr of endpoint.iterate()) {
for await (const pr of endpoint) {
console.log(pr.title);
}

// Or collect everything at once
const allPRs = await endpoint.all();
const allPRs = await endpoint;
```

## Methods
Expand All @@ -35,3 +35,32 @@ Asynchronously iterates over all items across all pages. Pagination is handled t
### `.all(): Promise<T[]>`

Collects all pages and resolves with a flat array of every item.

### `[Symbol.asyncIterator](): AsyncIterableIterator<T>`

Makes `ApiEndpoint` compatible with `for await...of` loops. Internally delegates to `.iterate()`, so pagination is handled transparently.

```ts
for await (const pr of endpoint) {
console.log(pr.title);
}
```

### `.then(onfulfilled?, onrejected?): Promise<TResult1 | TResult2>`

Makes `ApiEndpoint` a **Thenable**, so it can be `await`-ed directly without calling `.all()` explicitly. Internally delegates to `.all()`.

```ts
// These two are equivalent:
const prs = await endpoint;
const prs = await endpoint.all();
```

This also means `ApiEndpoint` instances can be passed to `Promise.resolve()`, used in `Promise.all()`, and chained with `.then()` / `.catch()` / `.finally()` like a regular promise.

```ts
endpoint
.then((prs) => prs.filter((pr) => pr.draft === false))
.then(console.log)
.catch(console.error);
```
6 changes: 3 additions & 3 deletions docs/api/GithubClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ const github = new GithubClient({
});

// Iterate over all open pull requests
for await (const pr of github.repos.OpenAlly["github.sdk"].pulls().iterate()) {
for await (const pr of github.repos.OpenAlly["github.sdk"].pulls()) {
console.log(pr.title);
}

// Collect all tags at once
const tags = await github.repos.OpenAlly["github.sdk"].tags().all();
const tags = await github.repos.OpenAlly["github.sdk"].tags();

// List all repositories for a user
const userRepos = await github.users.torvalds.repos().all();
const userRepos = await github.users.torvalds.repos();
```

## Constructor
Expand Down
8 changes: 4 additions & 4 deletions docs/api/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ The `repos` proxy provides access to GitHub repository endpoints.
import { repos } from "@openally/github.sdk";

// Collect all tags
const tags = await repos.OpenAlly["github.sdk"].tags().all();
const tags = await repos.OpenAlly["github.sdk"].tags();

// Stream pull requests page by page
for await (const pr of repos.nodejs.node.pulls().iterate()) {
for await (const pr of repos.nodejs.node.pulls()) {
console.log(pr.number, pr.title);
}

// Stream workflow runs for a specific workflow file
for await (const run of repos.nodejs.node.workflowRuns("ci.yml").iterate()) {
for await (const run of repos.nodejs.node.workflowRuns("ci.yml")) {
console.log(run.id, run.status);
}

// Collect all jobs for a specific run
const jobs = await repos.nodejs.node.runJobs(12345678).all();
const jobs = await repos.nodejs.node.runJobs(12345678);
```

## Access pattern
Expand Down
6 changes: 3 additions & 3 deletions docs/api/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ The `users` proxy provides access to GitHub user endpoints.
import { users } from "@openally/github.sdk";

// Collect all repositories for a user
const repos = await users.torvalds.repos().all();
const repos = await users.torvalds.repos();

// Stream followers one by one
for await (const follower of users.torvalds.followers().iterate()) {
for await (const follower of users.torvalds.followers()) {
console.log(follower.login);
}

// Collect all starred repositories
const starred = await users.torvalds.starred().all();
const starred = await users.torvalds.starred();
```

## Access pattern
Expand Down
24 changes: 19 additions & 5 deletions src/api/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,38 @@ function createRepoProxy(
commits: () => new ApiEndpoint<Commit>(`/repos/${owner}/${repo}/commits`, config),
workflows: () => new ApiEndpoint<Workflow>(
`/repos/${owner}/${repo}/actions/workflows`,
{ ...config, extractor: (raw: WorkflowsResponse) => raw.workflows }
{
...config,
extractor: (raw: WorkflowsResponse) => raw.workflows
}
),
workflowRuns: (workflowId: string | number) => new ApiEndpoint<WorkflowRun>(
`/repos/${owner}/${repo}/actions/workflows/${workflowId}/runs`,
{ ...config, extractor: (raw: WorkflowRunsResponse) => raw.workflow_runs }
{
...config,
extractor: (raw: WorkflowRunsResponse) => raw.workflow_runs
}
),
runJobs: (runId: number) => new ApiEndpoint<Job>(
`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`,
{ ...config, extractor: (raw: JobsResponse) => raw.jobs }
{
...config,
extractor: (raw: JobsResponse) => raw.jobs
}
),
runArtifacts: (runId: number) => new ApiEndpoint<Artifact>(
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`,
{ ...config, extractor: (raw: ArtifactsResponse) => raw.artifacts }
{
...config,
extractor: (raw: ArtifactsResponse) => raw.artifacts
}
)
};
}

export function createReposProxy(config: RequestConfig = {}): ReposProxy {
export function createReposProxy(
config: RequestConfig = {}
): ReposProxy {
return createApiProxy(
(owner) => createApiProxy(
(repo) => createRepoProxy(owner, repo, config)
Expand Down
14 changes: 9 additions & 5 deletions src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,20 @@ export type UsersProxy = {
function createUserProxy(
username: string,
config: RequestConfig = {}
): UserEndpointMethods {
) {
return Object.fromEntries(
(Object.keys(kUserEndpointResponseMap) as UserEndpoint[]).map(
Object.keys(kUserEndpointResponseMap).map(
(endpoint) => [endpoint, () => new ApiEndpoint(`/users/${username}/${endpoint}`, config)]
)
) as UserEndpointMethods;
);
}

export function createUsersProxy(config: RequestConfig = {}): UsersProxy {
return createApiProxy((username) => createUserProxy(username, config)) as UsersProxy;
export function createUsersProxy(
config: RequestConfig = {}
): UsersProxy {
return createApiProxy(
(username) => createUserProxy(username, config)
) as UsersProxy;
}

export const users = createUsersProxy();
11 changes: 11 additions & 0 deletions src/class/ApiEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,18 @@ export class ApiEndpoint<T> {
} while (this.#nextURL !== null);
}

[Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this.iterate();
}

all(): Promise<T[]> {
return Array.fromAsync(this.iterate());
}

then<TResult1 = T[], TResult2 = never>(
onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2> {
return this.all().then(onfulfilled, onrejected);
}
}
65 changes: 63 additions & 2 deletions test/ApiEndpoint.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,21 @@ describe("ApiEndpoint", () => {
});
});

describe("all() with thenable", () => {
it("should fetch a single page and return all items", async() => {
mockAgent
.get(kGithubOrigin)
.intercept({ path: "/users/foo/repos", method: "GET" })
.reply(200, JSON.stringify([{ id: 1 }, { id: 2 }]), {
headers: { "content-type": "application/json" }
});

const result = await new ApiEndpoint("/users/foo/repos");

assert.deepEqual(result, [{ id: 1 }, { id: 2 }]);
});
});

describe("iterate()", () => {
it("should yield items one at a time", async() => {
mockAgent
Expand Down Expand Up @@ -184,6 +199,50 @@ describe("ApiEndpoint", () => {
});
});

describe("Symbol.asyncIterator", () => {
it("should yield items one at a time", async() => {
mockAgent
.get(kGithubOrigin)
.intercept({ path: "/users/foo/repos", method: "GET" })
.reply(200, JSON.stringify([{ id: 1 }, { id: 2 }]), {
headers: { "content-type": "application/json" }
});

const items: unknown[] = [];
for await (const item of new ApiEndpoint("/users/foo/repos")) {
items.push(item);
}

assert.deepEqual(items, [{ id: 1 }, { id: 2 }]);
});

it("should yield items across paginated pages", async() => {
const pool = mockAgent.get(kGithubOrigin);

pool
.intercept({ path: "/users/foo/repos", method: "GET" })
.reply(200, JSON.stringify([{ id: 1 }]), {
headers: {
"content-type": "application/json",
link: '<https://api.github.com/users/foo/repos?page=2>; rel="next"'
}
});

pool
.intercept({ path: "/users/foo/repos?page=2", method: "GET" })
.reply(200, JSON.stringify([{ id: 2 }, { id: 3 }]), {
headers: { "content-type": "application/json" }
});

const items: unknown[] = [];
for await (const item of new ApiEndpoint("/users/foo/repos")) {
items.push(item);
}

assert.deepEqual(items, [{ id: 1 }, { id: 2 }, { id: 3 }]);
});
});

describe("headers", () => {
it("should send the default User-Agent header", async() => {
mockAgent
Expand Down Expand Up @@ -257,10 +316,12 @@ describe("ApiEndpoint", () => {

describe("extractor", () => {
it("should apply the extractor to transform the raw response", async() => {
const workflows = [{ id: 10 }, { id: 20 }];

mockAgent
.get(kGithubOrigin)
.intercept({ path: "/repos/foo/bar/actions/workflows", method: "GET" })
.reply(200, JSON.stringify({ total_count: 2, workflows: [{ id: 10 }, { id: 20 }] }), {
.reply(200, JSON.stringify({ total_count: 2, workflows }), {
headers: { "content-type": "application/json" }
});

Expand All @@ -269,7 +330,7 @@ describe("ApiEndpoint", () => {
{ extractor: (raw) => raw.workflows }
).all();

assert.deepEqual(result, [{ id: 10 }, { id: 20 }]);
assert.deepEqual(result, workflows);
});

it("should apply the extractor on every page when paginating", async() => {
Expand Down
37 changes: 28 additions & 9 deletions test/GithubClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,15 @@ describe("GithubClient", () => {
it("should return ApiEndpoints for repo sub-resources", () => {
const client = new GithubClient();

assert.ok(client.repos.owner.myrepo.tags() instanceof ApiEndpoint);
assert.ok(client.repos.owner.myrepo.pulls() instanceof ApiEndpoint);
assert.ok(client.repos.owner.myrepo.workflows() instanceof ApiEndpoint);
assert.ok(
client.repos.owner.myrepo.tags() instanceof ApiEndpoint
);
assert.ok(
client.repos.owner.myrepo.pulls() instanceof ApiEndpoint
);
assert.ok(
client.repos.owner.myrepo.workflows() instanceof ApiEndpoint
);
});

it("should use the configured token when fetching repo data", async() => {
Expand All @@ -126,7 +132,9 @@ describe("GithubClient", () => {
headers: { "content-type": "application/json" }
});

const result = await client.repos.octocat["hello-world"].tags().all();
const result = await client.repos.octocat["hello-world"]
.tags()
.all();

assert.deepEqual(result, [{ name: "v1.0.0" }]);
});
Expand All @@ -145,22 +153,33 @@ describe("GithubClient", () => {
headers: { "content-type": "application/json" }
});

await assert.doesNotReject(client.repos.octocat["hello-world"].tags().all());
await assert.doesNotReject(
client.repos.octocat["hello-world"].tags().all()
);
});

it("should fetch workflows with the envelope extractor", async() => {
const client = new GithubClient();
const workflows = [{ id: 7, name: "CI" }];

mockAgent
.get(kGithubOrigin)
.intercept({ path: "/repos/octocat/hello-world/actions/workflows", method: "GET" })
.reply(200, JSON.stringify({ total_count: 1, workflows: [{ id: 7, name: "CI" }] }), {
.intercept({
path: "/repos/octocat/hello-world/actions/workflows",
method: "GET"
})
.reply(200, JSON.stringify({ total_count: 1, workflows }), {
headers: { "content-type": "application/json" }
});

const result = await client.repos.octocat["hello-world"].workflows().all();
const result = await client.repos.octocat["hello-world"]
.workflows()
.all();

assert.deepEqual(result, [{ id: 7, name: "CI" }]);
assert.deepEqual(
result,
workflows
);
});
});
});
Loading