From 351995acdf40e89f3ac44d8df3623a97b7096e77 Mon Sep 17 00:00:00 2001 From: Nick Alteen Date: Thu, 6 Feb 2025 13:15:14 -0500 Subject: [PATCH 1/2] Add support for stubbing @actions/github --- __fixtures__/payloads/issue_opened.json | 33 +++++++ __tests__/stubs/github/context.test.ts | 73 +++++++++++++++ __tests__/stubs/github/github.test.ts | 15 ++++ __tests__/stubs/github/utils.test.ts | 16 ++++ docs/supported-functionality.md | 10 ++- package-lock.json | 4 +- package.json | 2 +- src/commands/run.ts | 36 ++++++++ .../internal/delete/delete-artifact.ts | 4 +- .../internal/download/download-artifact.ts | 2 +- .../artifact/internal/find/get-artifact.ts | 4 +- .../artifact/internal/find/list-artifacts.ts | 4 +- src/stubs/github/context.ts | 88 +++++++++++++++++++ src/stubs/github/github.ts | 23 +++++ src/stubs/github/interfaces.ts | 43 +++++++++ src/stubs/github/internal/utils.ts | 82 +++++++++++++++++ src/stubs/github/utils.ts | 46 ++++++++++ 17 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 __fixtures__/payloads/issue_opened.json create mode 100644 __tests__/stubs/github/context.test.ts create mode 100644 __tests__/stubs/github/github.test.ts create mode 100644 __tests__/stubs/github/utils.test.ts create mode 100644 src/stubs/github/context.ts create mode 100644 src/stubs/github/github.ts create mode 100644 src/stubs/github/interfaces.ts create mode 100644 src/stubs/github/internal/utils.ts create mode 100644 src/stubs/github/utils.ts diff --git a/__fixtures__/payloads/issue_opened.json b/__fixtures__/payloads/issue_opened.json new file mode 100644 index 0000000..04d41d7 --- /dev/null +++ b/__fixtures__/payloads/issue_opened.json @@ -0,0 +1,33 @@ +{ + "action": "opened", + "issue": { + "assignee": { + "login": "ncalteen" + }, + "assignees": [ + { + "login": "ncalteen" + } + ], + "body": "This is an issue!", + "number": 1, + "state": "open", + "title": "New Issue", + "user": { + "login": "ncalteen" + } + }, + "organization": { + "login": "github" + }, + "repository": { + "full_name": "github/local-action", + "name": "local-action", + "owner": { + "login": "github" + } + }, + "sender": { + "login": "ncalteen" + } +} diff --git a/__tests__/stubs/github/context.test.ts b/__tests__/stubs/github/context.test.ts new file mode 100644 index 0000000..792912c --- /dev/null +++ b/__tests__/stubs/github/context.test.ts @@ -0,0 +1,73 @@ +import { jest } from '@jest/globals' +import path from 'path' +import { Context } from '../../../src/stubs/github/context.js' + +let envBackup: NodeJS.ProcessEnv + +describe('github/context', () => { + beforeEach(() => { + envBackup = process.env + process.env.GITHUB_REPOSITORY = 'github/local-action' + }) + + afterEach(() => { + process.env = envBackup + jest.resetAllMocks() + }) + + describe('Context', () => { + it('Creates a Context object', () => { + const context = new Context() + expect(context).toBeInstanceOf(Context) + }) + + it('Gets the event payload', () => { + process.env.GITHUB_EVENT_PATH = path.join( + process.cwd(), + '__fixtures__', + 'payloads', + 'issue_opened.json' + ) + + const context = new Context() + expect(context.payload.action).toBe('opened') + }) + + it('Does not get the event payload if the path does not exist', () => { + process.env.GITHUB_EVENT_PATH = path.join( + process.cwd(), + '__fixtures__', + 'payloads', + 'does_not_exist.json' + ) + + const context = new Context() + expect(context.payload.action).toBeUndefined() + }) + + it('Gets the issue payload', () => { + process.env.GITHUB_EVENT_PATH = path.join( + process.cwd(), + '__fixtures__', + 'payloads', + 'issue_opened.json' + ) + + const context = new Context() + expect(context.issue.number).toBe(1) + }) + + it('Gets the repo payload', () => { + process.env.GITHUB_EVENT_PATH = path.join( + process.cwd(), + '__fixtures__', + 'payloads', + 'issue_opened.json' + ) + + const context = new Context() + expect(context.repo.owner).toBe('github') + expect(context.repo.repo).toBe('local-action') + }) + }) +}) diff --git a/__tests__/stubs/github/github.test.ts b/__tests__/stubs/github/github.test.ts new file mode 100644 index 0000000..169979d --- /dev/null +++ b/__tests__/stubs/github/github.test.ts @@ -0,0 +1,15 @@ +import { jest } from '@jest/globals' +import * as github from '../../../src/stubs/github/github.js' +import { GitHub } from '../../../src/stubs/github/utils.js' + +describe('github/github', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + describe('getOctokit', () => { + it('Returns the options', () => { + expect(github.getOctokit('test')).toBeInstanceOf(GitHub) + }) + }) +}) diff --git a/__tests__/stubs/github/utils.test.ts b/__tests__/stubs/github/utils.test.ts new file mode 100644 index 0000000..2be6934 --- /dev/null +++ b/__tests__/stubs/github/utils.test.ts @@ -0,0 +1,16 @@ +import { jest } from '@jest/globals' +import * as utils from '../../../src/stubs/github/utils.js' + +describe('github/utils', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + describe('getOctokitOptions', () => { + it('Returns the options', () => { + expect(utils.getOctokitOptions('test')).toEqual({ + auth: 'token test' + }) + }) + }) +}) diff --git a/docs/supported-functionality.md b/docs/supported-functionality.md index 80ee968..066a3e0 100644 --- a/docs/supported-functionality.md +++ b/docs/supported-functionality.md @@ -68,6 +68,15 @@ to the `local-action` command. | `toPlatformPath()` | :white_check_mark: | | | `platform.*` | :white_check_mark: | | +## [`@actions/github`](https://github.com/actions/toolkit/tree/main/packages/github) + +The stubbed version of `@actions/github` functions the same as the real package. +However, the functionality is stubbed in order to ensure that all needed +environment variables are pulled from the `.env` file passed to the +`local-action` command. Otherwise, things like `github.context.eventName` will +be `undefined`. For more information, see +[#149](https://github.com/github/local-action/issues/149). + ## Under Investigation The following packages are under investigation for how to integrate with @@ -85,7 +94,6 @@ this doesn't work correctly, please [open an issue!](https://github.com/github/local-action/issues/new) - [`@actions/exec`](https://github.com/actions/toolkit/tree/main/packages/exec) -- [`@actions/github`](https://github.com/actions/toolkit/tree/main/packages/github) - [`@actions/glob`](https://github.com/actions/toolkit/tree/main/packages/glob) - [`@actions/http-client`](https://github.com/actions/toolkit/tree/main/packages/http-client) - [`@actions/io`](https://github.com/actions/toolkit/tree/main/packages/io) diff --git a/package-lock.json b/package-lock.json index 3c65fb3..fb4a0b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github/local-action", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github/local-action", - "version": "2.5.1", + "version": "2.6.0", "license": "MIT", "dependencies": { "@actions/artifact": "^2.2.0", diff --git a/package.json b/package.json index 3826077..ef2c984 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@github/local-action", "description": "Local Debugging for GitHub Actions", - "version": "2.5.1", + "version": "2.6.0", "type": "module", "author": "Nick Alteen ", "private": false, diff --git a/src/commands/run.ts b/src/commands/run.ts index 5b4cc83..aff0056 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -4,6 +4,8 @@ import quibble from 'quibble' import { ARTIFACT_STUBS } from '../stubs/artifact/artifact.js' import { CORE_STUBS, CoreMeta } from '../stubs/core/core.js' import { EnvMeta } from '../stubs/env.js' +import { Context } from '../stubs/github/context.js' +import { getOctokit } from '../stubs/github/github.ts' import type { Action } from '../types.js' import { printTitle } from '../utils/output.js' import { isESM } from '../utils/package.js' @@ -124,6 +126,23 @@ export async function action(): Promise { // local-action require a different approach depending on if the called action // is written in ESM. if (isESM()) { + await quibble.esm( + path.resolve( + dirs.join(path.sep), + 'node_modules', + '@actions', + 'github', + 'lib', + 'github.js' + ), + { + getOctokit, + // The context object needs to be created **after** the dotenv file is + // loaded. Otherwise, the GITHUB_* environment variables will not be + // available to the action. + context: new Context() + } + ) await quibble.esm( path.resolve( dirs.join(path.sep), @@ -158,6 +177,23 @@ export async function action(): Promise { await run() } else { + quibble( + path.resolve( + dirs.join(path.sep), + 'node_modules', + '@actions', + 'github', + 'lib', + 'github.js' + ), + { + getOctokit, + // The context object needs to be created **after** the dotenv file is + // loaded. Otherwise, the GITHUB_* environment variables will not be + // available to the action. + context: new Context() + } + ) quibble( path.resolve( dirs.join(path.sep), diff --git a/src/stubs/artifact/internal/delete/delete-artifact.ts b/src/stubs/artifact/internal/delete/delete-artifact.ts index cbce1d3..a6d70b3 100644 --- a/src/stubs/artifact/internal/delete/delete-artifact.ts +++ b/src/stubs/artifact/internal/delete/delete-artifact.ts @@ -1,12 +1,12 @@ -import { getOctokit } from '@actions/github' -import { defaults as defaultGitHubOptions } from '@actions/github/lib/utils.js' import type { OctokitOptions } from '@octokit/core' import { requestLog } from '@octokit/plugin-request-log' import { retry } from '@octokit/plugin-retry' import fs from 'fs' import path from 'path' import { EnvMeta } from '../../../../stubs/env.js' +import { getOctokit } from '../../../../stubs/github/github.ts' import * as core from '../../../core/core.js' +import { defaults as defaultGitHubOptions } from '../../../github/utils.js' import { getArtifactPublic } from '../find/get-artifact.js' import { getRetryOptions } from '../find/retry-options.js' import { diff --git a/src/stubs/artifact/internal/download/download-artifact.ts b/src/stubs/artifact/internal/download/download-artifact.ts index d3983be..ac5115b 100644 --- a/src/stubs/artifact/internal/download/download-artifact.ts +++ b/src/stubs/artifact/internal/download/download-artifact.ts @@ -1,4 +1,3 @@ -import { getOctokit } from '@actions/github' import * as httpClient from '@actions/http-client' import fs from 'fs' import path from 'path' @@ -6,6 +5,7 @@ import { finished } from 'stream/promises' import unzip from 'unzip-stream' import { EnvMeta } from '../../../../stubs/env.js' import * as core from '../../../core/core.js' +import { getOctokit } from '../../../github/github.js' import { getGitHubWorkspaceDir } from '../shared/config.js' import { ArtifactNotFoundError } from '../shared/errors.js' import type { diff --git a/src/stubs/artifact/internal/find/get-artifact.ts b/src/stubs/artifact/internal/find/get-artifact.ts index 2a4b360..653221b 100644 --- a/src/stubs/artifact/internal/find/get-artifact.ts +++ b/src/stubs/artifact/internal/find/get-artifact.ts @@ -1,10 +1,10 @@ -import { getOctokit } from '@actions/github' -import { defaults as defaultGitHubOptions } from '@actions/github/lib/utils.js' import type { OctokitOptions } from '@octokit/core' import { requestLog } from '@octokit/plugin-request-log' import { retry } from '@octokit/plugin-retry' import { EnvMeta } from '../../../../stubs/env.js' import * as core from '../../../core/core.js' +import { getOctokit } from '../../../github/github.js' +import { defaults as defaultGitHubOptions } from '../../../github/utils.js' import { ArtifactNotFoundError, InvalidResponseError diff --git a/src/stubs/artifact/internal/find/list-artifacts.ts b/src/stubs/artifact/internal/find/list-artifacts.ts index e0e4f7a..4a59800 100644 --- a/src/stubs/artifact/internal/find/list-artifacts.ts +++ b/src/stubs/artifact/internal/find/list-artifacts.ts @@ -1,10 +1,10 @@ -import { getOctokit } from '@actions/github' -import { defaults as defaultGitHubOptions } from '@actions/github/lib/utils.js' import type { OctokitOptions } from '@octokit/core' import { requestLog } from '@octokit/plugin-request-log' import { retry } from '@octokit/plugin-retry' import { EnvMeta } from '../../../../stubs/env.js' import * as core from '../../../core/core.js' +import { getOctokit } from '../../../github/github.js' +import { defaults as defaultGitHubOptions } from '../../../github/utils.js' import type { Artifact, ListArtifactsResponse } from '../shared/interfaces.js' import { getUserAgentString } from '../shared/user-agent.js' import { getRetryOptions } from './retry-options.js' diff --git a/src/stubs/github/context.ts b/src/stubs/github/context.ts new file mode 100644 index 0000000..c3a4bd3 --- /dev/null +++ b/src/stubs/github/context.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from 'fs' +import { EOL } from 'os' +import { WebhookPayload } from './interfaces.js' + +export class Context { + /** + * Webhook payload object that triggered the workflow + */ + payload: WebhookPayload + + eventName: string + sha: string + ref: string + workflow: string + action: string + actor: string + job: string + runAttempt: number + runNumber: number + runId: number + apiUrl: string + serverUrl: string + graphqlUrl: string + + /** + * Hydrate the context from the environment + */ + constructor() { + this.payload = {} + + if (process.env.GITHUB_EVENT_PATH) { + console.log(process.env.GITHUB_EVENT_PATH) + if (existsSync(process.env.GITHUB_EVENT_PATH)) { + this.payload = JSON.parse( + readFileSync(process.env.GITHUB_EVENT_PATH, { encoding: 'utf8' }) + ) + } else { + const path = process.env.GITHUB_EVENT_PATH + process.stdout.write(`GITHUB_EVENT_PATH ${path} does not exist${EOL}`) + } + } + + this.eventName = process.env.GITHUB_EVENT_NAME as string + this.sha = process.env.GITHUB_SHA as string + this.ref = process.env.GITHUB_REF as string + this.workflow = process.env.GITHUB_WORKFLOW as string + this.action = process.env.GITHUB_ACTION as string + this.actor = process.env.GITHUB_ACTOR as string + this.job = process.env.GITHUB_JOB as string + this.runAttempt = parseInt(process.env.GITHUB_RUN_ATTEMPT as string, 10) + this.runNumber = parseInt(process.env.GITHUB_RUN_NUMBER as string, 10) + this.runId = parseInt(process.env.GITHUB_RUN_ID as string, 10) + this.apiUrl = process.env.GITHUB_API_URL ?? 'https://api.github.com' + this.serverUrl = process.env.GITHUB_SERVER_URL ?? 'https://github.com' + this.graphqlUrl = + process.env.GITHUB_GRAPHQL_URL ?? 'https://api.github.com/graphql' + } + + get issue(): { owner: string; repo: string; number: number } { + const payload = this.payload + + /* istanbul ignore next */ + return { + ...this.repo, + number: (payload.issue || payload.pull_request || payload).number + } + } + + get repo(): { owner: string; repo: string } { + if (process.env.GITHUB_REPOSITORY) { + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/') + return { owner, repo } + } + + /* istanbul ignore next */ + if (this.payload.repository) { + return { + owner: this.payload.repository.owner.login, + repo: this.payload.repository.name + } + } + + /* istanbul ignore next */ + throw new Error( + "context.repo requires a GITHUB_REPOSITORY environment variable like 'owner/repo'" + ) + } +} diff --git a/src/stubs/github/github.ts b/src/stubs/github/github.ts new file mode 100644 index 0000000..cba8fe7 --- /dev/null +++ b/src/stubs/github/github.ts @@ -0,0 +1,23 @@ +/** + * @github/local-action Modified + */ + +import { OctokitOptions, OctokitPlugin } from '@octokit/core/types' +import { GitHub, getOctokitOptions } from './utils.js' + +/** + * Returns a hydrated octokit ready to use for GitHub Actions + * + * @param token Repo PAT or GITHUB_TOKEN + * @param options Options to set + * @returns An octokit instance + */ +export function getOctokit( + token: string, + options?: OctokitOptions, + ...additionalPlugins: OctokitPlugin[] +): InstanceType { + const GitHubWithPlugins = GitHub.plugin(...additionalPlugins) + + return new GitHubWithPlugins(getOctokitOptions(token, options)) +} diff --git a/src/stubs/github/interfaces.ts b/src/stubs/github/interfaces.ts new file mode 100644 index 0000000..8ab08bf --- /dev/null +++ b/src/stubs/github/interfaces.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface PayloadRepository { + [key: string]: any + full_name?: string + name: string + owner: { + [key: string]: any + login: string + name?: string + } + html_url?: string +} + +export interface WebhookPayload { + [key: string]: any + repository?: PayloadRepository + issue?: { + [key: string]: any + number: number + html_url?: string + body?: string + } + pull_request?: { + [key: string]: any + number: number + html_url?: string + body?: string + } + sender?: { + [key: string]: any + type: string + } + action?: string + installation?: { + id: number + [key: string]: any + } + comment?: { + id: number + [key: string]: any + } +} diff --git a/src/stubs/github/internal/utils.ts b/src/stubs/github/internal/utils.ts new file mode 100644 index 0000000..b53fb1b --- /dev/null +++ b/src/stubs/github/internal/utils.ts @@ -0,0 +1,82 @@ +/** + * @github/local-action Modified + */ +/* istanbul ignore file */ + +import * as httpClient from '@actions/http-client' +import { OctokitOptions } from '@octokit/core/types' +import * as http from 'http' +import { ProxyAgent, fetch } from 'undici' + +/** + * Returns the auth string to use for the request. + * + * @param token Token + * @param options Options + * @returns Authentication string or undefined if no auth is provided + */ +export function getAuthString( + token: string, + options: OctokitOptions +): string | undefined { + if (!token && !options.auth) + throw new Error('Parameter token or opts.auth is required') + else if (token && options.auth) + throw new Error('Parameters token and opts.auth may not both be specified') + + return typeof options.auth === 'string' ? options.auth : `token ${token}` +} + +/** + * Returns the proxy agent to use for the request. + * + * @param destinationUrl Destination URL + * @returns Proxy Agent + */ +export function getProxyAgent(destinationUrl: string): http.Agent { + const hc = new httpClient.HttpClient() + + return hc.getAgent(destinationUrl) +} + +/** + * Returns the proxy agent dispatcher to use for the request. + * + * @param destinationUrl Destination URL + * @returns Proxy agent or undefined if no proxy is provided + */ +export function getProxyAgentDispatcher( + destinationUrl: string +): ProxyAgent | undefined { + const hc = new httpClient.HttpClient() + + return hc.getAgentDispatcher(destinationUrl) +} + +/** + * Returns the fetch function to use for the request. + * + * @param destinationUrl Destination URL + * @returns Fetch function + */ +export function getProxyFetch(destinationUrl: string): typeof fetch { + const httpDispatcher = getProxyAgentDispatcher(destinationUrl) + + const proxyFetch: typeof fetch = async (url, opts) => { + return fetch(url, { + ...opts, + dispatcher: httpDispatcher + }) + } + + return proxyFetch +} + +/** + * Returns the base URL to use for the request. + * + * @returns Base URL + */ +export function getApiBaseUrl(): string { + return process.env['GITHUB_API_URL'] || 'https://api.github.com' +} diff --git a/src/stubs/github/utils.ts b/src/stubs/github/utils.ts new file mode 100644 index 0000000..a59c3a9 --- /dev/null +++ b/src/stubs/github/utils.ts @@ -0,0 +1,46 @@ +/** + * @github/local-action Modified + */ + +import { Octokit } from '@octokit/core' +import { OctokitOptions } from '@octokit/core/types' +import { paginateRest } from '@octokit/plugin-paginate-rest' +import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods' +import * as Utils from './internal/utils.js' + +const baseUrl = Utils.getApiBaseUrl() + +export const defaults: OctokitOptions = { + baseUrl, + request: { + agent: Utils.getProxyAgent(baseUrl), + fetch: Utils.getProxyFetch(baseUrl) + } +} + +export const GitHub = Octokit.plugin( + restEndpointMethods, + paginateRest +).defaults(defaults) + +/** + * Convenience function to correctly format Octokit Options to pass into the + * constructor. + * + * @param token Repo PAT or GITHUB_TOKEN + * @param options Options to set + * @returns Octokit Options + */ +export function getOctokitOptions( + token: string, + options?: OctokitOptions +): OctokitOptions { + // Shallow clone - don't mutate the object provided by the caller + const opts = Object.assign({}, options || {}) + + const auth = Utils.getAuthString(token, opts) + + if (auth) opts.auth = auth + + return opts +} From 6dd899e87585c88e5eb3d1206117d9628e3bbcdd Mon Sep 17 00:00:00 2001 From: Nick Alteen Date: Thu, 6 Feb 2025 13:19:41 -0500 Subject: [PATCH 2/2] Ignore lines for coverage --- src/stubs/github/context.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/stubs/github/context.ts b/src/stubs/github/context.ts index c3a4bd3..1142eca 100644 --- a/src/stubs/github/context.ts +++ b/src/stubs/github/context.ts @@ -50,8 +50,11 @@ export class Context { this.runAttempt = parseInt(process.env.GITHUB_RUN_ATTEMPT as string, 10) this.runNumber = parseInt(process.env.GITHUB_RUN_NUMBER as string, 10) this.runId = parseInt(process.env.GITHUB_RUN_ID as string, 10) + /* istanbul ignore next */ this.apiUrl = process.env.GITHUB_API_URL ?? 'https://api.github.com' + /* istanbul ignore next */ this.serverUrl = process.env.GITHUB_SERVER_URL ?? 'https://github.com' + /* istanbul ignore next */ this.graphqlUrl = process.env.GITHUB_GRAPHQL_URL ?? 'https://api.github.com/graphql' }