Skip to content
Open
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
43 changes: 43 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,49 @@ jobs:
fi
echo $'\u2705 Test passed' | tee -a $GITHUB_STEP_SUMMARY

test-graphql-paginate:
name: 'Integration test: GraphQL pagination'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/install-dependencies
- id: graphql-paginate
name: Paginate a GraphQL query with github.graphql.paginate
uses: ./
env:
APP_TOKEN: ${{ github.token }}
with:
script: |
const query = `query paginate($cursor: String, $owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issues(first: 2, after: $cursor) {
nodes { number }
pageInfo { hasNextPage endCursor }
}
}
}`
const variables = {owner: context.repo.owner, repo: context.repo.repo}

// Walk two pages of two issues each, then stop.
let pages = 0
const numbers = new Set()
for await (const page of github.graphql.paginate.iterator(query, variables)) {
for (const issue of page.repository.issues.nodes) numbers.add(issue.number)
if (++pages === 2) break
}

const secondary = getOctokit(process.env.APP_TOKEN)
return `${typeof github.graphql.paginate}:${typeof secondary.graphql.paginate}:${pages}:${numbers.size}`
result-encoding: string
- run: |
echo "- Validating GraphQL pagination output"
expected="function:function:2:4"
if [[ "${{steps.graphql-paginate.outputs.result}}" != "$expected" ]]; then
echo $'::error::\u274C' "Expected '$expected', got ${{steps.graphql-paginate.outputs.result}}"
exit 1
fi
echo $'\u2705 Test passed' | tee -a $GITHUB_STEP_SUMMARY

test-debug:
strategy:
matrix:
Expand Down
20 changes: 20 additions & 0 deletions .licenses/npm/@octokit/plugin-paginate-graphql.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,40 @@ jobs:
console.log(result)
```

### Paginate GraphQL queries

The `github` client includes the [`@octokit/plugin-paginate-graphql`](https://github.com/octokit/plugin-paginate-graphql.js)
plugin, so `github.graphql.paginate` (and `github.graphql.paginate.iterator`) fetches every page of a
cursor-paginated GraphQL query, similar to `github.paginate` for the REST API. The query must declare a
`$cursor: String` variable and select `pageInfo { hasNextPage endCursor }` on the paginated connection:

```yaml
on: workflow_dispatch

jobs:
list-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const query = `query paginate($cursor: String, $owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issues(first: 100, after: $cursor) {
nodes { number title }
pageInfo { hasNextPage endCursor }
}
}
}`
const result = await github.graphql.paginate(query, {
owner: context.repo.owner,
repo: context.repo.repo
})
console.log(`Found ${result.repository.issues.nodes.length} issues`)
```

Secondary clients created with `getOctokit` also expose `graphql.paginate`.

### Run a separate file

If you don't want to inline your entire script that you want to run, you can
Expand Down
72 changes: 72 additions & 0 deletions __test__/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import * as core from '@actions/core'

// `@actions/github` and the Octokit plugins are ESM-only, so the CommonJS
// Jest runtime cannot load them; stand-ins are used instead. The real
// `github.graphql.paginate` behaviour is covered by the CI workflow
// (integration.yml test-graphql-paginate job).
jest.mock(
'@actions/github',
() => ({context: {}, getOctokit: jest.fn(() => ({}))}),
{virtual: true}
)
jest.mock('@actions/github/lib/utils', () => ({defaults: {}}), {virtual: true})
jest.mock('@octokit/plugin-retry', () => ({retry: jest.fn()}))
jest.mock('@octokit/plugin-request-log', () => ({requestLog: jest.fn()}))
jest.mock('@octokit/plugin-paginate-graphql', () => ({
paginateGraphQL: jest.fn()
}))

import {getOctokit} from '@actions/github'
import {paginateGraphQL} from '@octokit/plugin-paginate-graphql'
import {requestLog} from '@octokit/plugin-request-log'
import {retry} from '@octokit/plugin-retry'

describe('main wires Octokit plugins', () => {
const inputs: Record<string, string> = {
'github-token': 'primary-token',
debug: 'false',
retries: '0',
'result-encoding': 'string',
script: "getOctokit('secondary-token'); return 'done'"
}

beforeAll(async () => {
for (const [name, value] of Object.entries(inputs)) {
process.env[`INPUT_${name.toUpperCase()}`] = value
}
// wrap-require.ts wraps the bundler-provided require; use Jest's here.
;(globalThis as any).__non_webpack_require__ = require
jest.spyOn(core, 'setOutput').mockImplementation(() => undefined)
jest.spyOn(core, 'setFailed').mockImplementation(() => undefined)

// Importing main.ts runs main(): it builds the primary client and
// evaluates the script above, which creates a secondary client.
await import('../src/main')
await new Promise(resolve => setImmediate(resolve))
})

test('primary github client gets retry, requestLog and paginateGraphQL', () => {
expect(getOctokit).toHaveBeenNthCalledWith(
1,
'primary-token',
expect.any(Object),
retry,
requestLog,
paginateGraphQL
)
})

test('secondary clients from getOctokit inherit paginateGraphQL', () => {
expect(getOctokit).toHaveBeenNthCalledWith(
2,
'secondary-token',
expect.any(Object),
retry,
requestLog,
paginateGraphQL
)
expect(core.setFailed).not.toHaveBeenCalled()
})
})
189 changes: 185 additions & 4 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64828,6 +64828,186 @@ function getOctokit(token, options, ...additionalPlugins) {
var glob = __nccwpck_require__(8090);
// EXTERNAL MODULE: ./node_modules/@actions/io/lib/io.js
var io = __nccwpck_require__(7436);
;// CONCATENATED MODULE: ./node_modules/@octokit/plugin-paginate-graphql/dist-bundle/index.js
// pkg/dist-src/errors.js
var generateMessage = (path, cursorValue) => `The cursor at "${path.join(
","
)}" did not change its value "${cursorValue}" after a page transition. Please make sure your that your query is set up correctly.`;
var MissingCursorChange = class extends Error {
constructor(pageInfo, cursorValue) {
super(generateMessage(pageInfo.pathInQuery, cursorValue));
this.pageInfo = pageInfo;
this.cursorValue = cursorValue;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
name = "MissingCursorChangeError";
};
var MissingPageInfo = class extends Error {
constructor(response) {
super(
`No pageInfo property found in response. Please make sure to specify the pageInfo in your query. Response-Data: ${JSON.stringify(
response,
null,
2
)}`
);
this.response = response;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
name = "MissingPageInfo";
};

// pkg/dist-src/object-helpers.js
var isObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
function findPaginatedResourcePath(responseData) {
const paginatedResourcePath = deepFindPathToProperty(
responseData,
"pageInfo"
);
if (paginatedResourcePath.length === 0) {
throw new MissingPageInfo(responseData);
}
return paginatedResourcePath;
}
var deepFindPathToProperty = (object, searchProp, path = []) => {
for (const key of Object.keys(object)) {
const currentPath = [...path, key];
const currentValue = object[key];
if (isObject(currentValue)) {
if (currentValue.hasOwnProperty(searchProp)) {
return currentPath;
}
const result = deepFindPathToProperty(
currentValue,
searchProp,
currentPath
);
if (result.length > 0) {
return result;
}
}
}
return [];
};
var get = (object, path) => {
return path.reduce((current, nextProperty) => current[nextProperty], object);
};
var set = (object, path, mutator) => {
const lastProperty = path[path.length - 1];
const parentPath = [...path].slice(0, -1);
const parent = get(object, parentPath);
if (typeof mutator === "function") {
parent[lastProperty] = mutator(parent[lastProperty]);
} else {
parent[lastProperty] = mutator;
}
};

// pkg/dist-src/extract-page-info.js
var extractPageInfos = (responseData) => {
const pageInfoPath = findPaginatedResourcePath(responseData);
return {
pathInQuery: pageInfoPath,
pageInfo: get(responseData, [...pageInfoPath, "pageInfo"])
};
};

// pkg/dist-src/page-info.js
var isForwardSearch = (givenPageInfo) => {
return givenPageInfo.hasOwnProperty("hasNextPage");
};
var getCursorFrom = (pageInfo) => isForwardSearch(pageInfo) ? pageInfo.endCursor : pageInfo.startCursor;
var hasAnotherPage = (pageInfo) => isForwardSearch(pageInfo) ? pageInfo.hasNextPage : pageInfo.hasPreviousPage;

// pkg/dist-src/iterator.js
var createIterator = (octokit) => {
return (query, initialParameters = {}) => {
let nextPageExists = true;
let parameters = { ...initialParameters };
return {
[Symbol.asyncIterator]: () => ({
async next() {
if (!nextPageExists) return { done: true, value: {} };
const response = await octokit.graphql(
query,
parameters
);
const pageInfoContext = extractPageInfos(response);
const nextCursorValue = getCursorFrom(pageInfoContext.pageInfo);
nextPageExists = hasAnotherPage(pageInfoContext.pageInfo);
if (nextPageExists && nextCursorValue === parameters.cursor) {
throw new MissingCursorChange(pageInfoContext, nextCursorValue);
}
parameters = {
...parameters,
cursor: nextCursorValue
};
return { done: false, value: response };
}
})
};
};
};

// pkg/dist-src/merge-responses.js
var mergeResponses = (response1, response2) => {
if (Object.keys(response1).length === 0) {
return Object.assign(response1, response2);
}
const path = findPaginatedResourcePath(response1);
const nodesPath = [...path, "nodes"];
const newNodes = get(response2, nodesPath);
if (newNodes) {
set(response1, nodesPath, (values) => {
return [...values, ...newNodes];
});
}
const edgesPath = [...path, "edges"];
const newEdges = get(response2, edgesPath);
if (newEdges) {
set(response1, edgesPath, (values) => {
return [...values, ...newEdges];
});
}
const pageInfoPath = [...path, "pageInfo"];
set(response1, pageInfoPath, get(response2, pageInfoPath));
return response1;
};

// pkg/dist-src/paginate.js
var createPaginate = (octokit) => {
const iterator = createIterator(octokit);
return async (query, initialParameters = {}) => {
let mergedResponse = {};
for await (const response of iterator(
query,
initialParameters
)) {
mergedResponse = mergeResponses(mergedResponse, response);
}
return mergedResponse;
};
};

// pkg/dist-src/version.js
var plugin_paginate_graphql_dist_bundle_VERSION = "0.0.0-development";

// pkg/dist-src/index.js
function paginateGraphQL(octokit) {
return {
graphql: Object.assign(octokit.graphql, {
paginate: Object.assign(createPaginate(octokit), {
iterator: createIterator(octokit)
})
})
};
}


;// CONCATENATED MODULE: ./node_modules/@octokit/plugin-request-log/dist-src/version.js
const plugin_request_log_dist_src_version_VERSION = "6.0.0";

Expand Down Expand Up @@ -65065,6 +65245,7 @@ const wrapRequire = new Proxy(require, {




process.on('unhandledRejection', handleError);
main().catch(handleError);
async function main() {
Expand All @@ -65090,12 +65271,12 @@ async function main() {
if (baseUrl) {
opts.baseUrl = baseUrl;
}
const github = getOctokit(token, opts, retry, requestLog);
const github = getOctokit(token, opts, retry, requestLog, paginateGraphQL);
const script = core.getInput('script', { required: true });
// Wrap getOctokit so secondary clients inherit retry, logging,
// orchestration ID, and the action's retries input.
// Wrap getOctokit so secondary clients inherit retry, logging, GraphQL
// pagination, orchestration ID, and the action's retries input.
// Deep-copy opts to prevent shared references with the primary client.
const configuredGetOctokit = createConfiguredGetOctokit(getOctokit, { ...opts, retry: { ...opts.retry }, request: { ...opts.request } }, retry, requestLog);
const configuredGetOctokit = createConfiguredGetOctokit(getOctokit, { ...opts, retry: { ...opts.retry }, request: { ...opts.request } }, retry, requestLog, paginateGraphQL);
// Using property/value shorthand on `require` (e.g. `{require}`) causes compilation errors.
const result = await callAsyncFunction({
require: wrapRequire,
Expand Down
Loading