diff --git a/docs/github-package.md b/docs/github-package.md index 8d5cac29..467d1c18 100644 --- a/docs/github-package.md +++ b/docs/github-package.md @@ -1,100 +1,288 @@ -# Github Package +# Creating an Action using the GitHub Context -In order to support using actions to interact with GitHub, I propose adding a `github` package to the toolkit. +## Goal -Its main purpose will be to provide a hydrated GitHub context/Octokit client with some convenience functions. It is largely pulled from the GitHub utilities provided in https://github.com/JasonEtco/actions-toolkit, though it has been condensed. +In this walkthrough we will learn how to build a basic action using GitHub context data to greet users when they open an issue or PR. In the process we will explore how to access this context and how to make authenticated requests to the GitHub API. -### Spec +Note that a complete version of this action can be found at https://github.com/damccorm/issue-greeter. -##### interfaces.ts +## Prerequisites + +This walkthrough assumes that you have gone through the basic [javascript action walkthrough](./javascript-action.md) and have a basic action set up. If not, we recommend you go through that first. + +## Installing dependencies + +All of the dependencies we need should come packaged for us in this library's core and github packages. To install, run the following in your action: + +`npm install @actions/core && npm install @actions/github` + +## Metadata + +Next, we will need a welcome message and a repo token as an input. Recall that inputs are defined in the `action.yml` metadata file - update your `action.yml` file to define `welcome-message` and `repo-token` as inputs. + +```yaml +name: 'Welcome' +description: 'A basic welcome action' +author: 'GitHub' +inputs: + welcome-message: + description: 'Message to display when a user opens an issue or PR' + default: 'Thanks for opening an issue! Make sure you've followed CONTRIBUTING.md' + repo-token: + description: 'Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }}' + required: true +runs: + using: 'node12' + main: 'lib/main.js' +``` + +## Action logic + +Now that we've installed our dependencies and defined our inputs, we're ready to start writing the action logic in `src/main.ts`! For clarity, we'll structure our action up as follows: ```ts -/* - * Interfaces - */ - -export interface PayloadRepository { - [key: string]: any - full_name?: string - name: string - owner: { - [key: string]: any - login: string - name?: string - } - html_url?: string +import * as core from '@actions/core'; +import * as github from '@actions/github'; + +export async function run() { + try { + const welcomeMessage: string = core.getInput('welcome-message'); + // TODO - Get context data + + // TODO - make request to the GitHub API to comment on the issue + } + catch (error) { + core.setFailed(error.message); + throw error; + } } -export interface WebhookPayloadWithRepository { - [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 - } +run(); +``` + +### Getting context data + +For the purpose of this walkthrough, we will need the following pieces of context data: + +- the name of the repo that the action is being run on +- the organization/owner of that repo +- the number of the issue that has been opened + +Fortunately, the GitHub package provides all of this to us with [a single convenience function](https://github.com/actions/toolkit/blob/ac007c06984bc483fae2ba649788dfc858bc6a8b/packages/github/src/context.ts#L34), so we can simply do: + +`const issue: {owner: string; repo: string; number: number} = github.context.issue;` + +The context object also contains a number of easily accessed properties, as well as easy access to the full [GitHub payload](https://developer.github.com/v3/activity/events/types/). We can use this to check and make sure we're actually looking at a recently opened issue (and not something else, like a comment on an existing issue): + +```ts +if (github.context.payload.action !== 'opened') { + console.log('No issue or PR was opened, skipping'); + return; } ``` -##### context.ts - -Contains a GitHub context +Our whole `src/main.ts` file now looks like: ```ts -export class Context { - /** - * Webhook payload object that triggered the workflow - */ - public payload: WebhookPayloadWithRepository +import * as core from '@actions/core'; +import * as github from '@actions/github'; - /** - * Name of the event that triggered the workflow - */ - public event: string - public sha: string - public ref: string - public workflow: string - public action: string - public actor: string - - /** - * Hydrate the context from the environment - */ - constructor () - - public get issue () - - public get repo () +export async function run() { + try { + const welcomeMessage: string = core.getInput('welcome-message', {required: true}); + const repoToken: string = core.getInput('repo-token', {required: true}); + const issue: {owner: string; repo: string; number: number} = github.context.issue; + + if (github.context.payload.action !== 'opened') { + console.log('No issue or pull request was opened, skipping'); + return; + } + + // TODO - make request to the GitHub API to comment on the issue + } + catch (error) { + core.setFailed(error.message); + throw error; + } } +run(); ``` -##### github.ts +### Sending requests to the GitHub API -Contains a hydrated Octokit client +Now that we have our context data, we are able to send a request to the GitHub API using the [Octokit REST client](https://github.com/octokit/rest.js). The REST client exposes a number of easy convenience functions, including one for adding comments to issues/PRs (issues and PRs are treated as one concept by the Octokit client): ```ts -export class GithubClient extends Octokit { - // For making GraphQL requests - public graphql: (query: string, variables?: Variables) => Promise - - // Calls super and initializes graphql - constructor (token: string) +const client: github.GitHub = new github.GitHub(repoToken); +await client.issues.createComment({ + owner: issue.owner, + repo: issue.repo, + issue_number: issue.number, + body: welcomeMessage +}); +``` + +For more docs on the client, you can visit the [Octokit REST documentation](https://octokit.github.io/rest.js/). Now our action code should be complete: + +```ts +import * as core from '@actions/core'; +import * as github from '@actions/github'; + +export async function run() { + try { + const welcomeMessage: string = core.getInput('welcome-message', {required: true}); + const repoToken: string = core.getInput('repo-token', {required: true}); + const issue: {owner: string; repo: string; number: number} = github.context.issue; + + if (github.context.payload.action !== 'opened') { + console.log('No issue or pull request was opened, skipping'); + return; + } + + const client: github.GitHub = new github.GitHub(repoToken); + await client.issues.createComment({ + owner: issue.owner, + repo: issue.repo, + issue_number: issue.number, + body: welcomeMessage + }); + } + catch (error) { + core.setFailed(error.message); + throw error; + } +} + +run(); +``` + +## Writing unit tests for your action + +Next, we're going to write a basic unit test for our action using jest. If you followed the [javascript walkthrough](./javascript-action.md), you should have a file `__tests__/main.test.ts` that runs tests when `npm test` is called. We're going to start by populating that with one test: + +```ts +const nock = require('nock'); +const path = require('path'); + +describe('action test suite', () => { + it('It posts a comment on an opened issue', async () => { + // TODO + }); +}); +``` + +For the purposes of this walkthrough, we'll focus on populating this test and leave the remaining test coverage as an exercise for the reader. + +### Mocking inputs + +First, we want to make sure that we can mock our inputs (welcome-message, and repo-token). Actions handles inputs by populating process.env.INPUT_${input name in all caps}, so we can mock that simply by setting those environment variables: + +```ts +const nock = require('nock'); +const path = require('path'); + +describe('action test suite', () => { + it('It posts a comment on an opened issue', async () => { + const welcomeMessage = 'hello'; + const repoToken = 'token'; + process.env['INPUT_WELCOME-MESSAGE'] = welcomeMessage; + process.env['INPUT_REPO-TOKEN'] = repoToken; + + // TODO + }); +}); +``` + +### Mocking the GitHub context + +Mocking the GitHub context is relatively straightforward. Since most of it is simply populated by environment variables, you can just set the corresponding environment variables defined [here](https://github.com/actions/toolkit/blob/ac007c06984bc483fae2ba649788dfc858bc6a8b/packages/github/src/context.ts#L23) and test that it works in that environment. In this case, we can setup our test with: + +```ts +const nock = require('nock'); +const path = require('path'); + +describe('action test suite', () => { + it('It posts a comment on an opened issue', async () => { + const welcomeMessage = 'hello'; + const repoToken = 'token'; + process.env['INPUT_WELCOME-MESSAGE'] = welcomeMessage; + process.env['INPUT_REPO-TOKEN'] = repoToken; + + process.env['GITHUB_REPOSITORY'] = 'foo/bar'; + process.env['GITHUB_EVENT_PATH'] = path.join(__dirname, 'payload.json'); + + // TODO + }); +}); +``` + +Note that the payload is loaded from GITHUB_EVENT_PATH. Since we set that to `path.join(__dirname, 'payload.json')`, we need to go save our payload there. For the purposes of this test, we can simply save the following to `__tests__/payload.json`: + +```json +{ + "issue": { + "number": 10 + }, + "action": "opened" } ``` + +Now, calling `github.context.issue` should return `{owner: foo, repo: bar, number: 10}`, and `github.context.payload.action` should get set to 'opened' + +> One important detail here is that because the GitHub context loads these environment variables as soon as it is required, you should set them before you require your action. In most cases, this means you need to rerequire your action in every test. If this is a problem, you can get around it by mocking the class directly using jest (or whatever framework you choose). + +### Mocking the Octokit Client + +To mock the client calls, we recommend using [nock](https://github.com/nock/nock) which allows you to mock the http requests made by the client. First, install nock with `npm install nock --save-dev`. + +For this test, we expect the following call: + +```ts +client.issues.createComment({ + owner: 'foo', + repo: 'bar', + issue_number: 10, + body: 'you posted your first issue' +}); +``` + +From [the GitHub endpoint docs](https://developer.github.com/v3/issues/comments/#create-a-comment), we expect this to get make a POST request to `https://api.github.com/repos/foo/bar/issues/10/comments` with body of `{"body":"hello"}` + +We can mock this with: + +```ts +const nock = require('nock'); +const path = require('path'); + +describe('action test suite', () => { + it('It posts a comment on an opened issue', async () => { + const welcomeMessage = 'hello'; + const repoToken = 'token'; + process.env['INPUT_WELCOME-MESSAGE'] = welcomeMessage; + process.env['INPUT_REPO-TOKEN'] = repoToken; + + process.env['GITHUB_REPOSITORY'] = 'foo/bar'; + process.env['GITHUB_EVENT_PATH'] = path.join(__dirname, 'payload.json'); + + nock('https://api.github.com') + .persist() + .post('/repos/foo/bar/issues/10/comments', '{\"body\":\"hello\"}') + .reply(200); + + const main = require('../src/main'); + + await main.run(); + }); +}); +``` + +This will fail if the url or body doesn't exactly match the parameters passed into the nock function. We can now run `npm test` and the test should succeed. + +## Build and publish + +Now that we've written and unit tested our action, we can build our action with `npm run build` and push it to a repo where it can be consumed by workflows. For more info on versioning your action, see [our versioning docs](./action-versioning.md). + +## Next steps + +If you're interested in building out this action further, try extending your action to only run on a user's first issue. See our [first-contribution action](https://github.com/actions/first-interaction) for inspiration. diff --git a/docs/javascript-action.md b/docs/javascript-action.md index a1b6ae4e..6772cae9 100644 --- a/docs/javascript-action.md +++ b/docs/javascript-action.md @@ -159,5 +159,6 @@ steps: name: World! ``` +# Next Steps - +Now that you've created a basic action, see how to [leverage the github context](./github-package) in your actions \ No newline at end of file diff --git a/docs/specs/github-package.md b/docs/specs/github-package.md new file mode 100644 index 00000000..8d5cac29 --- /dev/null +++ b/docs/specs/github-package.md @@ -0,0 +1,100 @@ +# Github Package + +In order to support using actions to interact with GitHub, I propose adding a `github` package to the toolkit. + +Its main purpose will be to provide a hydrated GitHub context/Octokit client with some convenience functions. It is largely pulled from the GitHub utilities provided in https://github.com/JasonEtco/actions-toolkit, though it has been condensed. + +### Spec + +##### interfaces.ts + +```ts +/* + * Interfaces + */ + +export interface PayloadRepository { + [key: string]: any + full_name?: string + name: string + owner: { + [key: string]: any + login: string + name?: string + } + html_url?: string +} + +export interface WebhookPayloadWithRepository { + [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 + } +} +``` + +##### context.ts + +Contains a GitHub context + +```ts +export class Context { + /** + * Webhook payload object that triggered the workflow + */ + public payload: WebhookPayloadWithRepository + + /** + * Name of the event that triggered the workflow + */ + public event: string + public sha: string + public ref: string + public workflow: string + public action: string + public actor: string + + /** + * Hydrate the context from the environment + */ + constructor () + + public get issue () + + public get repo () +} + +``` + +##### github.ts + +Contains a hydrated Octokit client + +```ts +export class GithubClient extends Octokit { + // For making GraphQL requests + public graphql: (query: string, variables?: Variables) => Promise + + // Calls super and initializes graphql + constructor (token: string) +} +``` diff --git a/docs/package-specs.md b/docs/specs/package-specs.md similarity index 100% rename from docs/package-specs.md rename to docs/specs/package-specs.md