Compare commits

...

1 Commits

Author SHA1 Message Date
Salman Chishti dae91cface feat: expose getOctokit factory in script context
Add pre-configured getOctokit factory to the script context, enabling
multi-token workflows directly inside github-script.

- getOctokit(token, opts, ...plugins) inherits action config (retry, userAgent, etc.)
- Deep-merges request and retry options, deduplicates plugins
- stripUndefined prevents baseUrl clobber on GHES
- Integration test job with dynamic repo name (fork-friendly)
- 32 tests passing (15 factory + 4 integration + 6 getOctokit + 7 existing)
2026-04-09 15:59:35 +00:00
9 changed files with 602 additions and 9 deletions
+39 -9
View File
@@ -155,21 +155,51 @@ jobs:
result-encoding: string
- run: |
echo "- Validating user-agent default"
expected="actions/github-script octokit-core.js/"
if [[ "${{steps.user-agent-default.outputs.result}}" != "$expected"* ]]; then
echo $'::error::\u274C' "Expected user-agent to start with '$expected', got ${{steps.user-agent-default.outputs.result}}"
ua="${{steps.user-agent-default.outputs.result}}"
if [[ "$ua" != "actions/github-script octokit-core.js/"* ]] && [[ "$ua" != "actions/github-script actions_orchestration_id/"* ]]; then
echo $'::error::\u274C' "Expected user-agent to start with 'actions/github-script', got $ua"
exit 1
fi
echo "- Validating user-agent set to a value"
expected="foobar octokit-core.js/"
if [[ "${{steps.user-agent-set.outputs.result}}" != "$expected"* ]]; then
echo $'::error::\u274C' "Expected user-agent to start with '$expected', got ${{steps.user-agent-set.outputs.result}}"
ua="${{steps.user-agent-set.outputs.result}}"
if [[ "$ua" != "foobar octokit-core.js/"* ]] && [[ "$ua" != "foobar actions_orchestration_id/"* ]]; then
echo $'::error::\u274C' "Expected user-agent to start with 'foobar', got $ua"
exit 1
fi
echo "- Validating user-agent set to an empty string"
expected="actions/github-script octokit-core.js/"
if [[ "${{steps.user-agent-empty.outputs.result}}" != "$expected"* ]]; then
echo $'::error::\u274C' "Expected user-agent to start with '$expected', got ${{steps.user-agent-empty.outputs.result}}"
ua="${{steps.user-agent-empty.outputs.result}}"
if [[ "$ua" != "actions/github-script octokit-core.js/"* ]] && [[ "$ua" != "actions/github-script actions_orchestration_id/"* ]]; then
echo $'::error::\u274C' "Expected user-agent to start with 'actions/github-script', got $ua"
exit 1
fi
echo $'\u2705 Test passed' | tee -a $GITHUB_STEP_SUMMARY
test-get-octokit:
name: 'Integration test: getOctokit'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create secondary client with getOctokit
uses: ./
id: secondary-client
env:
APP_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const appOctokit = getOctokit(process.env.APP_TOKEN)
const {data} = await appOctokit.rest.repos.get({
owner: context.repo.owner,
repo: context.repo.repo
})
return `${appOctokit !== github}:${data.full_name}`
result-encoding: string
- run: |
echo "- Validating secondary client output"
expected="true:${{ github.repository }}"
if [[ "${{steps.secondary-client.outputs.result}}" != "$expected" ]]; then
echo $'::error::\u274C' "Expected '$expected', got ${{steps.secondary-client.outputs.result}}"
exit 1
fi
echo $'\u2705 Test passed' | tee -a $GITHUB_STEP_SUMMARY
+88
View File
@@ -8,6 +8,94 @@ describe('callAsyncFunction', () => {
expect(result).toEqual('bar')
})
test('passes getOctokit through the script context', async () => {
const getOctokit = jest.fn().mockReturnValue('secondary-client')
const result = await callAsyncFunction(
{getOctokit} as any,
"return getOctokit('token')"
)
expect(getOctokit).toHaveBeenCalledWith('token')
expect(result).toEqual('secondary-client')
})
test('getOctokit creates client independent from github', async () => {
const github = {rest: {issues: 'primary'}}
const getOctokit = jest.fn().mockReturnValue({rest: {issues: 'secondary'}})
const result = await callAsyncFunction(
{github, getOctokit} as any,
`
const secondary = getOctokit('other-token')
return {
primary: github.rest.issues,
secondary: secondary.rest.issues,
different: github !== secondary
}
`
)
expect(result).toEqual({
primary: 'primary',
secondary: 'secondary',
different: true
})
expect(getOctokit).toHaveBeenCalledWith('other-token')
})
test('getOctokit passes options through', async () => {
const getOctokit = jest.fn().mockReturnValue('client-with-opts')
const result = await callAsyncFunction(
{getOctokit} as any,
`return getOctokit('my-token', { baseUrl: 'https://ghes.example.com/api/v3' })`
)
expect(getOctokit).toHaveBeenCalledWith('my-token', {
baseUrl: 'https://ghes.example.com/api/v3'
})
expect(result).toEqual('client-with-opts')
})
test('getOctokit supports plugins', async () => {
const getOctokit = jest.fn().mockReturnValue('client-with-plugins')
const result = await callAsyncFunction(
{getOctokit} as any,
`return getOctokit('my-token', { previews: ['v3'] }, 'pluginA', 'pluginB')`
)
expect(getOctokit).toHaveBeenCalledWith(
'my-token',
{previews: ['v3']},
'pluginA',
'pluginB'
)
expect(result).toEqual('client-with-plugins')
})
test('multiple getOctokit calls produce independent clients', async () => {
const getOctokit = jest
.fn()
.mockReturnValueOnce({id: 'client-a'})
.mockReturnValueOnce({id: 'client-b'})
const result = await callAsyncFunction(
{getOctokit} as any,
`
const a = getOctokit('token-a')
const b = getOctokit('token-b')
return { a: a.id, b: b.id, different: a !== b }
`
)
expect(getOctokit).toHaveBeenCalledTimes(2)
expect(getOctokit).toHaveBeenNthCalledWith(1, 'token-a')
expect(getOctokit).toHaveBeenNthCalledWith(2, 'token-b')
expect(result).toEqual({a: 'client-a', b: 'client-b', different: true})
})
test('throws on ReferenceError', async () => {
expect.assertions(1)
@@ -0,0 +1,268 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {createConfiguredGetOctokit} from '../src/create-configured-getoctokit'
describe('createConfiguredGetOctokit', () => {
const mockRetryPlugin = jest.fn()
const mockRequestLogPlugin = jest.fn()
function makeMockGetOctokit() {
return jest.fn().mockReturnValue('mock-client')
}
test('passes token and merged defaults to underlying getOctokit', () => {
const raw = makeMockGetOctokit()
const defaults = {
userAgent: 'actions/github-script actions_orchestration_id/abc',
retry: {enabled: true},
request: {retries: 3}
}
const wrapped = createConfiguredGetOctokit(
raw as any,
defaults,
mockRetryPlugin,
mockRequestLogPlugin
)
wrapped('my-token' as any)
expect(raw).toHaveBeenCalledWith(
'my-token',
expect.objectContaining({
userAgent: 'actions/github-script actions_orchestration_id/abc',
retry: {enabled: true},
request: {retries: 3}
}),
mockRetryPlugin,
mockRequestLogPlugin
)
})
test('user options override top-level defaults', () => {
const raw = makeMockGetOctokit()
const defaults = {
userAgent: 'default-agent',
previews: ['v3']
}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped('tok' as any, {userAgent: 'custom-agent'} as any)
expect(raw).toHaveBeenCalledWith(
'tok',
expect.objectContaining({userAgent: 'custom-agent', previews: ['v3']})
)
})
test('deep-merges request so partial overrides preserve retries', () => {
const raw = makeMockGetOctokit()
const defaults = {
request: {retries: 3, agent: 'proxy-agent', fetch: 'proxy-fetch'}
}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped('tok' as any, {request: {timeout: 5000}} as any)
expect(raw).toHaveBeenCalledWith(
'tok',
expect.objectContaining({
request: {
retries: 3,
agent: 'proxy-agent',
fetch: 'proxy-fetch',
timeout: 5000
}
})
)
})
test('deep-merges retry so partial overrides preserve existing settings', () => {
const raw = makeMockGetOctokit()
const defaults = {
retry: {enabled: true, retries: 3}
}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped('tok' as any, {retry: {retries: 5}} as any)
expect(raw).toHaveBeenCalledWith(
'tok',
expect.objectContaining({
retry: {enabled: true, retries: 5}
})
)
})
test('user can override request.retries explicitly', () => {
const raw = makeMockGetOctokit()
const defaults = {request: {retries: 3}}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped('tok' as any, {request: {retries: 0}} as any)
expect(raw).toHaveBeenCalledWith(
'tok',
expect.objectContaining({request: {retries: 0}})
)
})
test('user plugins are appended after default plugins', () => {
const raw = makeMockGetOctokit()
const customPlugin = jest.fn()
const wrapped = createConfiguredGetOctokit(
raw as any,
{},
mockRetryPlugin,
mockRequestLogPlugin
)
wrapped('tok' as any, {} as any, customPlugin as any)
expect(raw).toHaveBeenCalledWith(
'tok',
expect.any(Object),
mockRetryPlugin,
mockRequestLogPlugin,
customPlugin
)
})
test('duplicate plugins are deduplicated', () => {
const raw = makeMockGetOctokit()
const wrapped = createConfiguredGetOctokit(
raw as any,
{},
mockRetryPlugin,
mockRequestLogPlugin
)
// User passes retry again — should not duplicate
wrapped('tok' as any, {} as any, mockRetryPlugin as any)
expect(raw).toHaveBeenCalledWith(
'tok',
expect.any(Object),
mockRetryPlugin,
mockRequestLogPlugin
)
})
test('applies defaults when no user options provided', () => {
const raw = makeMockGetOctokit()
const defaults = {
userAgent: 'actions/github-script',
retry: {enabled: true},
request: {retries: 3}
}
const wrapped = createConfiguredGetOctokit(
raw as any,
defaults,
mockRetryPlugin
)
wrapped('tok' as any)
expect(raw).toHaveBeenCalledWith(
'tok',
{
userAgent: 'actions/github-script',
retry: {enabled: true},
request: {retries: 3}
},
mockRetryPlugin
)
})
test('baseUrl: undefined from user does not clobber default', () => {
const raw = makeMockGetOctokit()
const defaults = {baseUrl: 'https://ghes.example.com/api/v3'}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped('tok' as any, {baseUrl: undefined} as any)
const calledOpts = raw.mock.calls[0][1]
expect(calledOpts.baseUrl).toBe('https://ghes.example.com/api/v3')
})
test('undefined values in nested request are stripped', () => {
const raw = makeMockGetOctokit()
const defaults = {request: {retries: 3, agent: 'proxy'}}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped('tok' as any, {request: {retries: undefined, timeout: 5000}} as any)
const calledOpts = raw.mock.calls[0][1]
expect(calledOpts.request).toEqual({
retries: 3,
agent: 'proxy',
timeout: 5000
})
})
test('undefined values in nested retry are stripped', () => {
const raw = makeMockGetOctokit()
const defaults = {retry: {enabled: true, retries: 3}}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped('tok' as any, {retry: {enabled: undefined, retries: 5}} as any)
const calledOpts = raw.mock.calls[0][1]
expect(calledOpts.retry).toEqual({enabled: true, retries: 5})
})
test('each call creates an independent client', () => {
const raw = jest
.fn()
.mockReturnValueOnce('client-a')
.mockReturnValueOnce('client-b')
const wrapped = createConfiguredGetOctokit(raw as any, {})
const a = wrapped('token-a' as any)
const b = wrapped('token-b' as any)
expect(a).toBe('client-a')
expect(b).toBe('client-b')
expect(raw).toHaveBeenCalledTimes(2)
})
test('does not mutate defaultOptions between calls', () => {
const raw = makeMockGetOctokit()
const defaults = {
request: {retries: 3},
retry: {enabled: true}
}
const originalDefaults = JSON.parse(JSON.stringify(defaults))
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped(
'tok' as any,
{request: {timeout: 5000}, retry: {retries: 10}} as any
)
wrapped('tok' as any, {request: {timeout: 9000}} as any)
expect(defaults).toEqual(originalDefaults)
})
test('falsy-but-valid values are preserved, only undefined is stripped', () => {
const raw = makeMockGetOctokit()
const defaults = {baseUrl: 'https://ghes.example.com/api/v3'}
const wrapped = createConfiguredGetOctokit(raw as any, defaults)
wrapped(
'tok' as any,
{
log: null,
retries: 0,
debug: false,
userAgent: ''
} as any
)
const calledOpts = raw.mock.calls[0][1]
expect(calledOpts.log).toBeNull()
expect(calledOpts.retries).toBe(0)
expect(calledOpts.debug).toBe(false)
expect(calledOpts.userAgent).toBe('')
expect(calledOpts.baseUrl).toBe('https://ghes.example.com/api/v3')
})
})
+102
View File
@@ -0,0 +1,102 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {callAsyncFunction} from '../src/async-function'
// Create a mock getOctokit that returns Octokit-like objects.
// Real @actions/github integration is tested in the CI workflow
// (integration.yml test-get-octokit job). Here we verify the
// script context wiring — getOctokit is passed through and
// callable from user scripts.
function mockGetOctokit(token: string, options?: any) {
return {
_token: token,
_options: options,
rest: {
issues: {get: async () => ({data: {id: 1}})},
pulls: {get: async () => ({data: {id: 2}})}
},
graphql: async () => ({}),
request: async () => ({})
}
}
describe('getOctokit integration via callAsyncFunction', () => {
test('getOctokit creates a functional client in script scope', async () => {
const result = await callAsyncFunction(
{getOctokit: mockGetOctokit} as any,
`
const client = getOctokit('fake-token-for-test')
return {
hasRest: typeof client.rest === 'object',
hasGraphql: typeof client.graphql === 'function',
hasRequest: typeof client.request === 'function',
hasIssues: typeof client.rest.issues === 'object',
hasPulls: typeof client.rest.pulls === 'object'
}
`
)
expect(result).toEqual({
hasRest: true,
hasGraphql: true,
hasRequest: true,
hasIssues: true,
hasPulls: true
})
})
test('secondary client is independent from primary github client', async () => {
const primary = mockGetOctokit('primary-token')
const result = await callAsyncFunction(
{github: primary, getOctokit: mockGetOctokit} as any,
`
const secondary = getOctokit('secondary-token')
return {
bothHaveRest: typeof github.rest === 'object' && typeof secondary.rest === 'object',
areDistinct: github !== secondary
}
`
)
expect(result).toEqual({
bothHaveRest: true,
areDistinct: true
})
})
test('getOctokit accepts options for GHES base URL', async () => {
const result = await callAsyncFunction(
{getOctokit: mockGetOctokit} as any,
`
const client = getOctokit('fake-token', {
baseUrl: 'https://ghes.example.com/api/v3'
})
return typeof client.rest === 'object'
`
)
expect(result).toBe(true)
})
test('multiple getOctokit calls produce independent clients with different tokens', async () => {
const result = await callAsyncFunction(
{getOctokit: mockGetOctokit} as any,
`
const clientA = getOctokit('token-a')
const clientB = getOctokit('token-b')
return {
aHasRest: typeof clientA.rest === 'object',
bHasRest: typeof clientB.rest === 'object',
areDistinct: clientA !== clientB
}
`
)
expect(result).toEqual({
aHasRest: true,
bHasRest: true,
areDistinct: true
})
})
})
+39
View File
@@ -36188,6 +36188,42 @@ function callAsyncFunction(args, source) {
return fn(...Object.values(args));
}
;// CONCATENATED MODULE: ./src/create-configured-getoctokit.ts
function stripUndefined(obj) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
;
result[key] = value;
}
}
return result;
}
function createConfiguredGetOctokit(rawGetOctokit, defaultOptions, ...defaultPlugins) {
return (token, options, ...additionalPlugins) => {
const userOpts = stripUndefined(options || {});
const defaultRequest = defaultOptions.request;
const userRequestRaw = userOpts.request;
const userRequest = userRequestRaw ? stripUndefined(userRequestRaw) : {};
const defaultRetry = defaultOptions.retry;
const userRetryRaw = userOpts.retry;
const userRetry = userRetryRaw ? stripUndefined(userRetryRaw) : {};
const merged = {
...defaultOptions,
...userOpts,
request: { ...(defaultRequest || {}), ...userRequest },
retry: { ...(defaultRetry || {}), ...userRetry }
};
const allPlugins = [...defaultPlugins];
for (const plugin of additionalPlugins) {
if (!allPlugins.includes(plugin)) {
allPlugins.push(plugin);
}
}
return rawGetOctokit(token, merged, ...allPlugins);
};
}
;// CONCATENATED MODULE: ./src/retry-options.ts
function getRetryOptions(retries, exemptStatusCodes, defaultOptions) {
@@ -36256,6 +36292,7 @@ const wrapRequire = new Proxy(require, {
process.on('unhandledRejection', handleError);
main().catch(handleError);
async function main() {
@@ -36283,12 +36320,14 @@ async function main() {
}
const github = (0,lib_github.getOctokit)(token, opts, plugin_retry_dist_node.retry, dist_node.requestLog);
const script = core.getInput('script', { required: true });
const configuredGetOctokit = createConfiguredGetOctokit(lib_github.getOctokit, opts, plugin_retry_dist_node.retry, dist_node.requestLog);
// Using property/value shorthand on `require` (e.g. `{require}`) causes compilation errors.
const result = await callAsyncFunction({
require: wrapRequire,
__original_require__: require,
github,
octokit: github,
getOctokit: configuredGetOctokit,
context: lib_github.context,
core: core,
exec: exec,
+2
View File
@@ -2,6 +2,7 @@ import * as core from '@actions/core'
import * as exec from '@actions/exec'
import {Context} from '@actions/github/lib/context'
import {GitHub} from '@actions/github/lib/utils'
import {getOctokit} from '@actions/github'
import * as glob from '@actions/glob'
import * as io from '@actions/io'
@@ -12,6 +13,7 @@ export declare type AsyncFunctionArguments = {
core: typeof core
github: InstanceType<typeof GitHub>
octokit: InstanceType<typeof GitHub>
getOctokit: typeof getOctokit
exec: typeof exec
glob: typeof glob
io: typeof io
+53
View File
@@ -0,0 +1,53 @@
import {getOctokit} from '@actions/github'
import {GitHub} from '@actions/github/lib/utils'
import {OctokitOptions, OctokitPlugin} from '@octokit/core/dist-types/types'
type GetOctokit = typeof getOctokit
function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
const result: Partial<T> = {}
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
;(result as Record<string, unknown>)[key] = value
}
}
return result
}
export function createConfiguredGetOctokit(
rawGetOctokit: GetOctokit,
defaultOptions: OctokitOptions,
...defaultPlugins: OctokitPlugin[]
): GetOctokit {
return (
token: string,
options?: OctokitOptions,
...additionalPlugins: OctokitPlugin[]
): InstanceType<typeof GitHub> => {
const userOpts = stripUndefined(options || {})
const defaultRequest = defaultOptions.request
const userRequestRaw = userOpts.request as
| Record<string, unknown>
| undefined
const userRequest = userRequestRaw ? stripUndefined(userRequestRaw) : {}
const defaultRetry = defaultOptions.retry
const userRetryRaw = userOpts.retry as Record<string, unknown> | undefined
const userRetry = userRetryRaw ? stripUndefined(userRetryRaw) : {}
const merged: OctokitOptions = {
...defaultOptions,
...userOpts,
request: {...(defaultRequest || {}), ...userRequest},
retry: {...(defaultRetry || {}), ...userRetry}
}
const allPlugins = [...defaultPlugins]
for (const plugin of additionalPlugins) {
if (!allPlugins.includes(plugin)) {
allPlugins.push(plugin)
}
}
return rawGetOctokit(token, merged, ...allPlugins)
}
}
+9
View File
@@ -8,6 +8,7 @@ import {requestLog} from '@octokit/plugin-request-log'
import {retry} from '@octokit/plugin-retry'
import {RequestRequestOptions} from '@octokit/types'
import {callAsyncFunction} from './async-function'
import {createConfiguredGetOctokit} from './create-configured-getoctokit'
import {RetryOptions, getRetryOptions, parseNumberArray} from './retry-options'
import {wrapRequire} from './wrap-require'
@@ -59,6 +60,13 @@ async function main(): Promise<void> {
const github = getOctokit(token, opts, retry, requestLog)
const script = core.getInput('script', {required: true})
const configuredGetOctokit = createConfiguredGetOctokit(
getOctokit,
opts,
retry,
requestLog
)
// Using property/value shorthand on `require` (e.g. `{require}`) causes compilation errors.
const result = await callAsyncFunction(
{
@@ -66,6 +74,7 @@ async function main(): Promise<void> {
__original_require__: __non_webpack_require__,
github,
octokit: github,
getOctokit: configuredGetOctokit,
context,
core,
exec,
+2
View File
@@ -3,6 +3,7 @@ import * as core from '@actions/core';
import * as exec from '@actions/exec';
import { Context } from '@actions/github/lib/context';
import { GitHub } from '@actions/github/lib/utils';
import { getOctokit } from '@actions/github';
import * as glob from '@actions/glob';
import * as io from '@actions/io';
export declare type AsyncFunctionArguments = {
@@ -10,6 +11,7 @@ export declare type AsyncFunctionArguments = {
core: typeof core;
github: InstanceType<typeof GitHub>;
octokit: InstanceType<typeof GitHub>;
getOctokit: typeof getOctokit;
exec: typeof exec;
glob: typeof glob;
io: typeof io;