diff --git a/packages/das/src/api/miners/miners.controller.ts b/packages/das/src/api/miners/miners.controller.ts index eac13b4..9f633a3 100644 --- a/packages/das/src/api/miners/miners.controller.ts +++ b/packages/das/src/api/miners/miners.controller.ts @@ -158,12 +158,31 @@ export class MinersController { }) @ApiParam({ name: "githubId", description: "GitHub user ID (numeric)" }) @ApiBody(SINCE_BY_REPO_API_BODY) + @ApiQuery({ + name: "cursor", + required: false, + description: + "Opaque pagination cursor from a previous response's next_cursor field.", + }) + @ApiQuery({ + name: "limit", + required: false, + description: "Page size (default 50, max 200).", + }) async postPullRequests( @Param("githubId") githubId: string, @Body() body: SinceByRepoBody, + @Query("cursor") cursor?: string, + @Query("limit") limit?: string, ): Promise { const { repoNames, sinceValues } = parseSinceByRepo(body); - return this.miners.getPullRequestsByRepo(githubId, repoNames, sinceValues); + const pagination = parsePaginationQuery(limit, cursor); + return this.miners.getPullRequestsByRepo( + githubId, + repoNames, + sinceValues, + pagination, + ); } @Get(":githubId/issues") @@ -216,11 +235,30 @@ export class MinersController { }) @ApiParam({ name: "githubId", description: "GitHub user ID (numeric)" }) @ApiBody(SINCE_BY_REPO_API_BODY) + @ApiQuery({ + name: "cursor", + required: false, + description: + "Opaque pagination cursor from a previous response's next_cursor field.", + }) + @ApiQuery({ + name: "limit", + required: false, + description: "Page size (default 50, max 200).", + }) async postIssues( @Param("githubId") githubId: string, @Body() body: SinceByRepoBody, + @Query("cursor") cursor?: string, + @Query("limit") limit?: string, ): Promise { const { repoNames, sinceValues } = parseSinceByRepo(body); - return this.miners.getIssuesByRepo(githubId, repoNames, sinceValues); + const pagination = parsePaginationQuery(limit, cursor); + return this.miners.getIssuesByRepo( + githubId, + repoNames, + sinceValues, + pagination, + ); } } diff --git a/packages/das/src/api/miners/miners.service.ts b/packages/das/src/api/miners/miners.service.ts index ae0ee19..cb9b5df 100644 --- a/packages/das/src/api/miners/miners.service.ts +++ b/packages/das/src/api/miners/miners.service.ts @@ -261,12 +261,33 @@ export class MinersService { githubId: string, repoNames: string[], sinceValues: string[], + pagination: PaginationParams | null, ): Promise<{ github_id: string; since: null; generated_at: string; pull_requests: unknown[]; + next_cursor?: string | null; }> { + const cursor = pagination?.cursor ?? null; + const keysetClause = cursor + ? `AND (p.created_at, LOWER(p.repo_full_name), p.pr_number) < ($4::timestamptz, $5, $6::int)` + : ""; + const limitClause = pagination ? `LIMIT $${cursor ? 7 : 4}` : ""; + const params: unknown[] = !pagination + ? [githubId, repoNames, sinceValues] + : cursor + ? [ + githubId, + repoNames, + sinceValues, + cursor.createdAt, + cursor.repoFullName, + cursor.number, + pagination.limit + 1, + ] + : [githubId, repoNames, sinceValues, pagination.limit + 1]; + const rows = await this.dataSource.query( ` WITH windows AS ( @@ -287,16 +308,38 @@ export class MinersService { OR (p.state = 'MERGED' AND p.merged_at >= w.since) OR (p.state = 'CLOSED' AND p.created_at >= w.since) ) - ORDER BY p.created_at DESC + ${keysetClause} + ORDER BY p.created_at DESC, LOWER(p.repo_full_name) DESC, p.pr_number DESC + ${limitClause} `, - [githubId, repoNames, sinceValues], + params, + ); + + if (!pagination) { + return { + github_id: githubId, + since: null, + generated_at: new Date().toISOString(), + pull_requests: rows, + }; + } + + const page = buildPaginatedResponse( + rows as Record[], + pagination.limit, + (row) => ({ + created_at: row.created_at as string, + repo_full_name: row.repo_full_name as string, + pr_number: row.pr_number as number, + }), ); return { github_id: githubId, since: null, generated_at: new Date().toISOString(), - pull_requests: rows, + pull_requests: page.items, + next_cursor: page.nextCursor, }; } @@ -383,12 +426,33 @@ export class MinersService { githubId: string, repoNames: string[], sinceValues: string[], + pagination: PaginationParams | null, ): Promise<{ github_id: string; since: null; generated_at: string; issues: unknown[]; + next_cursor?: string | null; }> { + const cursor = pagination?.cursor ?? null; + const keysetClause = cursor + ? `AND (i.created_at, LOWER(i.repo_full_name), i.issue_number) < ($4::timestamptz, $5, $6::int)` + : ""; + const limitClause = pagination ? `LIMIT $${cursor ? 7 : 4}` : ""; + const params: unknown[] = !pagination + ? [githubId, repoNames, sinceValues] + : cursor + ? [ + githubId, + repoNames, + sinceValues, + cursor.createdAt, + cursor.repoFullName, + cursor.number, + pagination.limit + 1, + ] + : [githubId, repoNames, sinceValues, pagination.limit + 1]; + const rows = await this.dataSource.query( ` WITH windows AS ( @@ -403,16 +467,38 @@ export class MinersService { (i.state = 'OPEN' AND i.created_at >= w.since) OR (i.state = 'CLOSED' AND i.closed_at >= w.since) ) - ORDER BY i.created_at DESC + ${keysetClause} + ORDER BY i.created_at DESC, LOWER(i.repo_full_name) DESC, i.issue_number DESC + ${limitClause} `, - [githubId, repoNames, sinceValues], + params, + ); + + if (!pagination) { + return { + github_id: githubId, + since: null, + generated_at: new Date().toISOString(), + issues: rows, + }; + } + + const page = buildPaginatedResponse( + rows as Record[], + pagination.limit, + (row) => ({ + created_at: row.created_at as string, + repo_full_name: row.repo_full_name as string, + issue_number: row.issue_number as number, + }), ); return { github_id: githubId, since: null, generated_at: new Date().toISOString(), - issues: rows, + issues: page.items, + next_cursor: page.nextCursor, }; }