mirror of
https://github.com/actions/add-to-project.git
synced 2025-12-11 20:47:05 +00:00
Merge remote-tracking branch 'origin/main' into improve-test-coverage
This commit is contained in:
100
README.md
100
README.md
@@ -1,16 +1,26 @@
|
|||||||
# actions/add-to-project
|
# actions/add-to-project
|
||||||
|
|
||||||
🚨 **This action is a work in progress. Please do not use it except for
|
|
||||||
experimentation until a release has been prepared.** 🚨
|
|
||||||
|
|
||||||
Use this action to automatically add issues to a GitHub Project. Note that this
|
Use this action to automatically add issues to a GitHub Project. Note that this
|
||||||
is for [GitHub Projects
|
is for [GitHub Projects
|
||||||
(beta)](https://docs.github.com/en/issues/trying-out-the-new-projects-experience/about-projects),
|
(beta)](https://docs.github.com/en/issues/trying-out-the-new-projects-experience/about-projects),
|
||||||
not the original GitHub Projects.
|
not the original GitHub Projects.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
[](https://github.com/actions/add-to-project/actions/workflows/test.yml)
|
||||||
|
|
||||||
|
🚨 **This action is a work-in-progress. Please do not use it except for
|
||||||
|
experimentation until a release has been prepared.** 🚨
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
_See [action.yml](action.yml) for [metadata](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions) that defines the inputs, outputs, and runs configuration for this action._
|
||||||
|
|
||||||
|
_For more information about workflows, see [Using workflows](https://docs.github.com/en/actions/using-workflows)._
|
||||||
|
|
||||||
To use the action, create a workflow that runs when issues are opened in your
|
To use the action, create a workflow that runs when issues are opened in your
|
||||||
repository. Run this action in a step, optionally configuring any filters you
|
repository. Run this action in a step, optionally configuring any filters you
|
||||||
may want to add, such as only adding issues with certain labels.
|
may want to add, such as only adding issues with certain labels. If you want to match all the labels, add `label-operator` input to be `AND`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: Add bugs to bugs project
|
name: Add bugs to bugs project
|
||||||
@@ -25,25 +35,83 @@ jobs:
|
|||||||
name: Add issue to project
|
name: Add issue to project
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Pointing to a branch name generally isn't the safest way to refer to an action,
|
|
||||||
# but this is how you can use this action now before we've begun creating releases.
|
|
||||||
# Another option would be to point to a full commit SHA.
|
|
||||||
- uses: actions/add-to-project@main
|
- uses: actions/add-to-project@main
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/<orgName>/projects/<projectNumber>
|
project-url: https://github.com/orgs/<orgName>/projects/<projectNumber>
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
labeled: bug
|
labeled: bug, new
|
||||||
|
label-operator: AND
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Further reading and additional resources
|
||||||
|
|
||||||
|
- [Inputs](#inputs)
|
||||||
|
- [Supported Events](#supported-events)
|
||||||
|
- [How to point the action to a specific branch or commit sha](#how-to-point-the-action-to-a-specific-branch-or-commit-sha)
|
||||||
|
- [Creating a PAT and adding it to your repository](creating-a-pat-and-adding-it-to-your-repository)
|
||||||
|
- [Development](#development)
|
||||||
|
- [Publish to a distribution branch](#publish-to-a-distribution-branch)
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- `project-url` is the URL of the GitHub Project to add issues to.
|
- <a name="project-url">`project-url`</a> **(required)** is the URL of the GitHub Project to add issues to.
|
||||||
- `github-token` is a [personal access
|
_eg: `https://github.com/orgs|users/<ownerName>/projects/<projectNumber>`_
|
||||||
|
- <a name="github-token">`github-token`</a> **(required)** is a [personal access
|
||||||
token](https://github.com/settings/tokens/new) with the `repo`, `write:org` and
|
token](https://github.com/settings/tokens/new) with the `repo`, `write:org` and
|
||||||
`read:org` scopes.
|
`read:org` scopes.
|
||||||
- `labeled` is a comma-separated list of labels. For an issue to be added to the
|
_See [Creating a PAT and adding it to your repository](creating-a-pat-and-adding-it-to-your-repository) for more details_
|
||||||
project, it must have _one_ of the labels in the list. Omitting this key means
|
- <a name="labeled">`labeled`</a> **(optional)** is a comma-separated list of labels used to filter applicable issues. When this key is provided, an issue must have _one_ of the labels in the list to be added to the project. Omitting this key means that any issue will be added.
|
||||||
that all issues will be added.
|
- <a name="labeled">`label-operator`</a> **(optional)** is the behavior of the labels filter, either `AND` or `OR` that controls if the issue should be matched with `all` `labeled` input or any of them, default is `OR`.
|
||||||
|
|
||||||
|
## Supported Events
|
||||||
|
|
||||||
|
Currently this action supports the following [issue events](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issues):
|
||||||
|
|
||||||
|
- `opened`
|
||||||
|
- `transferred`
|
||||||
|
- `labeled`
|
||||||
|
|
||||||
|
This ensures that all issues in the workflow's repo are added to the [specified project](#project-url). If [labeled input(s)](#labeled) are defined, then issues will only be added if they contain at least _one_ of the labels in the list.
|
||||||
|
|
||||||
|
## How to point the action to a specific branch or commit sha
|
||||||
|
|
||||||
|
Pointing to a branch name generally isn't the safest way to refer to an action, but this is how you can use this action now before we've begun creating releases.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
add-to-project:
|
||||||
|
name: Add issue to project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/add-to-project@main
|
||||||
|
with:
|
||||||
|
project-url: https://github.com/orgs/<orgName>/projects/<projectNumber>
|
||||||
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Another option would be to point to a full [commit SHA](https://docs.github.com/en/get-started/quickstart/github-glossary#commit):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
add-to-project:
|
||||||
|
name: Add issue to project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/add-to-project@<commitSHA>
|
||||||
|
with:
|
||||||
|
project-url: https://github.com/orgs/<orgName>/projects/<projectNumber>
|
||||||
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a PAT and adding it to your repository
|
||||||
|
|
||||||
|
- create a new [personal access
|
||||||
|
token](https://github.com/settings/tokens/new) with `repo`, `write:org` and
|
||||||
|
`read:org` scopes
|
||||||
|
_See [Creating a personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) for more information_
|
||||||
|
|
||||||
|
- add the newly created PAT as a repository secret, this secret will be referenced by the [github-token input](#github-token)
|
||||||
|
_See [Encrypted secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository) for more information_
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -76,3 +144,7 @@ the "dist/" directory.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Now, a release can be created from the branch containing the built action.
|
Now, a release can be created from the branch containing the built action.
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
The scripts and documentation in this project are released under the [MIT License](LICENSE)
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ describe('addToProject', () => {
|
|||||||
expect(outputs.itemId).toEqual('project-next-item-id')
|
expect(outputs.itemId).toEqual('project-next-item-id')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('adds matching issues with a label filter', async () => {
|
test('adds matching issues with a label filter without label-operator', async () => {
|
||||||
mockGetInput({
|
mockGetInput({
|
||||||
'project-url': 'https://github.com/orgs/github/projects/1',
|
'project-url': 'https://github.com/orgs/github/projects/1',
|
||||||
'github-token': 'gh_token',
|
'github-token': 'gh_token',
|
||||||
labeled: 'bug'
|
labeled: 'bug, new'
|
||||||
})
|
})
|
||||||
|
|
||||||
github.context.payload = {
|
github.context.payload = {
|
||||||
@@ -94,6 +94,92 @@ describe('addToProject', () => {
|
|||||||
expect(outputs.itemId).toEqual('project-next-item-id')
|
expect(outputs.itemId).toEqual('project-next-item-id')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('does not add un-matching issues with a label filter without label-operator', async () => {
|
||||||
|
mockGetInput({
|
||||||
|
'project-url': 'https://github.com/orgs/github/projects/1',
|
||||||
|
'github-token': 'gh_token',
|
||||||
|
labeled: 'bug'
|
||||||
|
})
|
||||||
|
|
||||||
|
github.context.payload = {
|
||||||
|
issue: {
|
||||||
|
number: 1,
|
||||||
|
labels: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoSpy = jest.spyOn(core, 'info')
|
||||||
|
const gqlMock = mockGraphQL()
|
||||||
|
await addToProject()
|
||||||
|
expect(infoSpy).toHaveBeenCalledWith(`Skipping issue 1 because it does not have one of the labels: bug`)
|
||||||
|
expect(gqlMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds matching issues with labels filter with AND label-operator', async () => {
|
||||||
|
mockGetInput({
|
||||||
|
'project-url': 'https://github.com/orgs/github/projects/1',
|
||||||
|
'github-token': 'gh_token',
|
||||||
|
labeled: 'bug, new',
|
||||||
|
'label-operator': 'AND'
|
||||||
|
})
|
||||||
|
|
||||||
|
github.context.payload = {
|
||||||
|
issue: {
|
||||||
|
number: 1,
|
||||||
|
labels: [{name: 'bug'}, {name: 'new'}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGraphQL(
|
||||||
|
{
|
||||||
|
test: /getProject/,
|
||||||
|
return: {
|
||||||
|
organization: {
|
||||||
|
projectNext: {
|
||||||
|
id: 'project-next-id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /addProjectNextItem/,
|
||||||
|
return: {
|
||||||
|
addProjectNextItem: {
|
||||||
|
projectNextItem: {
|
||||||
|
id: 'project-next-item-id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await addToProject()
|
||||||
|
|
||||||
|
expect(outputs.itemId).toEqual('project-next-item-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not add un-matching issues with labels filter with AND label-operator', async () => {
|
||||||
|
mockGetInput({
|
||||||
|
'project-url': 'https://github.com/orgs/github/projects/1',
|
||||||
|
'github-token': 'gh_token',
|
||||||
|
labeled: 'bug, new',
|
||||||
|
'label-operator': 'AND'
|
||||||
|
})
|
||||||
|
|
||||||
|
github.context.payload = {
|
||||||
|
issue: {
|
||||||
|
number: 1,
|
||||||
|
labels: [{name: 'bug'}, {name: 'other'}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoSpy = jest.spyOn(core, 'info')
|
||||||
|
const gqlMock = mockGraphQL()
|
||||||
|
await addToProject()
|
||||||
|
expect(infoSpy).toHaveBeenCalledWith(`Skipping issue 1 because it doesn't match all the labels: bug, new`)
|
||||||
|
expect(gqlMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
test('adds matching issues with multiple label filters', async () => {
|
test('adds matching issues with multiple label filters', async () => {
|
||||||
mockGetInput({
|
mockGetInput({
|
||||||
'project-url': 'https://github.com/orgs/github/projects/1',
|
'project-url': 'https://github.com/orgs/github/projects/1',
|
||||||
@@ -163,27 +249,6 @@ describe('addToProject', () => {
|
|||||||
expect(gqlMock).not.toHaveBeenCalled()
|
expect(gqlMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does not add un-matching issues with a label filter', async () => {
|
|
||||||
mockGetInput({
|
|
||||||
'project-url': 'https://github.com/orgs/github/projects/1',
|
|
||||||
'github-token': 'gh_token',
|
|
||||||
labeled: 'bug'
|
|
||||||
})
|
|
||||||
|
|
||||||
github.context.payload = {
|
|
||||||
issue: {
|
|
||||||
number: 1,
|
|
||||||
labels: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const infoSpy = jest.spyOn(core, 'info')
|
|
||||||
const gqlMock = mockGraphQL()
|
|
||||||
await addToProject()
|
|
||||||
expect(infoSpy).toHaveBeenCalledWith(`Skipping issue 1 because it does not have one of the labels: bug`)
|
|
||||||
expect(gqlMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test(`throws an error when url isn't a valid project url`, async () => {
|
test(`throws an error when url isn't a valid project url`, async () => {
|
||||||
mockGetInput({
|
mockGetInput({
|
||||||
'project-url': 'https://github.com/orgs/github/repositories',
|
'project-url': 'https://github.com/orgs/github/repositories',
|
||||||
@@ -205,28 +270,28 @@ describe('addToProject', () => {
|
|||||||
expect(infoSpy).not.toHaveBeenCalled()
|
expect(infoSpy).not.toHaveBeenCalled()
|
||||||
expect(gqlMock).not.toHaveBeenCalled()
|
expect(gqlMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test(`throws an error when url isn't under the github.com domain`, async () => {
|
test(`throws an error when url isn't under the github.com domain`, async () => {
|
||||||
mockGetInput({
|
mockGetInput({
|
||||||
'project-url': 'https://notgithub.com/orgs/github/projects/1',
|
'project-url': 'https://notgithub.com/orgs/github/projects/1',
|
||||||
'github-token': 'gh_token'
|
'github-token': 'gh_token'
|
||||||
})
|
})
|
||||||
|
|
||||||
github.context.payload = {
|
github.context.payload = {
|
||||||
issue: {
|
issue: {
|
||||||
number: 1,
|
number: 1,
|
||||||
labels: []
|
labels: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const infoSpy = jest.spyOn(core, 'info')
|
const infoSpy = jest.spyOn(core, 'info')
|
||||||
const gqlMock = mockGraphQL()
|
const gqlMock = mockGraphQL()
|
||||||
await expect(addToProject()).rejects.toThrow(
|
await expect(addToProject()).rejects.toThrow(
|
||||||
'https://notgithub.com/orgs/github/projects/1. Project URL should match the format https://github.com/<orgs-or-users>/<ownerName>/projects/<projectNumber>'
|
'https://notgithub.com/orgs/github/projects/1. Project URL should match the format https://github.com/<orgs-or-users>/<ownerName>/projects/<projectNumber>'
|
||||||
)
|
)
|
||||||
expect(infoSpy).not.toHaveBeenCalled()
|
expect(infoSpy).not.toHaveBeenCalled()
|
||||||
expect(gqlMock).not.toHaveBeenCalled()
|
expect(gqlMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function mockGetInput(mocks: Record<string, string>): jest.SpyInstance {
|
function mockGetInput(mocks: Record<string, string>): jest.SpyInstance {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ inputs:
|
|||||||
labeled:
|
labeled:
|
||||||
required: false
|
required: false
|
||||||
description: A comma-separated list of labels to use as a filter for issue to be added
|
description: A comma-separated list of labels to use as a filter for issue to be added
|
||||||
|
label-operator:
|
||||||
|
required: false
|
||||||
|
description: The behavior of the labels filter, AND to match all labels, OR to match any label (default is OR)
|
||||||
runs:
|
runs:
|
||||||
using: 'node16'
|
using: 'node16'
|
||||||
main: 'dist/index.js'
|
main: 'dist/index.js'
|
||||||
|
|||||||
14
dist/index.js
generated
vendored
14
dist/index.js
generated
vendored
@@ -51,14 +51,20 @@ function addToProject() {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0)) !== null && _a !== void 0 ? _a : [];
|
.filter(l => l.length > 0)) !== null && _a !== void 0 ? _a : [];
|
||||||
|
const labelOperator = core.getInput('label-operator').trim().toLocaleLowerCase();
|
||||||
const octokit = github.getOctokit(ghToken);
|
const octokit = github.getOctokit(ghToken);
|
||||||
const urlMatch = projectUrl.match(urlParse);
|
const urlMatch = projectUrl.match(urlParse);
|
||||||
const issue = (_b = github.context.payload.issue) !== null && _b !== void 0 ? _b : github.context.payload.pull_request;
|
const issue = (_b = github.context.payload.issue) !== null && _b !== void 0 ? _b : github.context.payload.pull_request;
|
||||||
const issueLabels = ((_c = issue === null || issue === void 0 ? void 0 : issue.labels) !== null && _c !== void 0 ? _c : []).map((l) => l.name);
|
const issueLabels = ((_c = issue === null || issue === void 0 ? void 0 : issue.labels) !== null && _c !== void 0 ? _c : []).map((l) => l.name);
|
||||||
// Ensure the issue matches our `labeled` filter, if provided.
|
// Ensure the issue matches our `labeled` filter based on the label-operator.
|
||||||
if (labeled.length > 0) {
|
if (labelOperator === 'and') {
|
||||||
const hasLabel = issueLabels.some(l => labeled.includes(l));
|
if (!labeled.every(l => issueLabels.includes(l))) {
|
||||||
if (!hasLabel) {
|
core.info(`Skipping issue ${issue === null || issue === void 0 ? void 0 : issue.number} because it doesn't match all the labels: ${labeled.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (labeled.length > 0 && !issueLabels.some(l => labeled.includes(l))) {
|
||||||
core.info(`Skipping issue ${issue === null || issue === void 0 ? void 0 : issue.number} because it does not have one of the labels: ${labeled.join(', ')}`);
|
core.info(`Skipping issue ${issue === null || issue === void 0 ? void 0 : issue.number} because it does not have one of the labels: ${labeled.join(', ')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -37,18 +37,22 @@ export async function addToProject(): Promise<void> {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.filter(l => l.length > 0) ?? []
|
.filter(l => l.length > 0) ?? []
|
||||||
|
const labelOperator = core.getInput('label-operator').trim().toLocaleLowerCase()
|
||||||
|
|
||||||
const octokit = github.getOctokit(ghToken)
|
const octokit = github.getOctokit(ghToken)
|
||||||
const urlMatch = projectUrl.match(urlParse)
|
const urlMatch = projectUrl.match(urlParse)
|
||||||
const issue = github.context.payload.issue ?? github.context.payload.pull_request
|
const issue = github.context.payload.issue ?? github.context.payload.pull_request
|
||||||
const issueLabels: string[] = (issue?.labels ?? []).map((l: {name: string}) => l.name)
|
const issueLabels: string[] = (issue?.labels ?? []).map((l: {name: string}) => l.name)
|
||||||
|
|
||||||
// Ensure the issue matches our `labeled` filter, if provided.
|
// Ensure the issue matches our `labeled` filter based on the label-operator.
|
||||||
if (labeled.length > 0) {
|
if (labelOperator === 'and') {
|
||||||
const hasLabel = issueLabels.some(l => labeled.includes(l))
|
if (!labeled.every(l => issueLabels.includes(l))) {
|
||||||
|
core.info(`Skipping issue ${issue?.number} because it doesn't match all the labels: ${labeled.join(', ')}`)
|
||||||
if (!hasLabel) {
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (labeled.length > 0 && !issueLabels.some(l => labeled.includes(l))) {
|
||||||
core.info(`Skipping issue ${issue?.number} because it does not have one of the labels: ${labeled.join(', ')}`)
|
core.info(`Skipping issue ${issue?.number} because it does not have one of the labels: ${labeled.join(', ')}`)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user