Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/issueComment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./
with:
prow-commands: /assign /unassign /approve /retitle /area /kind /priority /label /remove /lgtm /close /reopen /lock /milestone /hold /cc /uncc /meow
prow-commands: /assign /unassign /approve /retitle /area /kind /priority /label /remove /lgtm /close /reopen /lock /milestone /hold /cc /uncc /meow /lifecycle /help /triage
github-token: ${{ secrets.GITHUB_TOKEN }}
6 changes: 6 additions & 0 deletions .prowlabels.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ priority:
labels:
- documentation
- question

triage:
values:
- accepted
- needs-information
exclusive: true
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
steps:
- uses: cncf/prow-github-actions@v2
with:
prow-commands: /assign /unassign /approve /retitle /area /kind /priority /label /remove /lgtm /close /reopen /lock /milestone /hold /cc /uncc
prow-commands: /assign /unassign /approve /retitle /area /kind /priority /label /remove /lgtm /close /reopen /lock /milestone /hold /cc /uncc /lifecycle /help
github-token: '${{ secrets.GITHUB_TOKEN }}'
```

Expand Down
46 changes: 46 additions & 0 deletions __tests__/bundle/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,52 @@ describe('dist/index.js', () => {
])
})

it('issue_comment /level uses a mapping-form yaml key as an exclusive label command', async () => {
gh.route('GET', `${repo}/contents/.prowlabels.yaml`, { status: 200, body: labelFileContents })
gh.route('GET', `${repo}/issues/1`, { status: 200, body: { labels: [{ name: 'level/sandbox' }] } })
gh.route('DELETE', `${repo}/issues/1/labels/level%2Fsandbox`, { status: 200, body: [] })
gh.route('POST', `${repo}/issues/1/labels`, { status: 200, body: [] })

const result = await runBundle({
eventName: 'issue_comment',
payload: comment('/level incubation'),
inputs: { ...token, 'prow-commands': '/level' },
apiUrl: gh.url,
})

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
const posts = gh.requestsMatching('POST', /\/issues\/1\/labels$/)
expect(posts).toHaveLength(1)
expect(posts[0].body).toEqual({ labels: ['level/incubation'] })
expect(gh.requests.map(r => `${r.method} ${r.path}`)).toEqual([
`GET ${repo}/contents/.prowlabels.yaml`,
`GET ${repo}/issues/1`,
`DELETE ${repo}/issues/1/labels/level%2Fsandbox`,
`POST ${repo}/issues/1/labels`,
])
})

it('issue_comment /help adds help wanted without reading .prowlabels.yaml', async () => {
gh.route('POST', `${repo}/issues/1/labels`, { status: 200, body: [] })

const result = await runBundle({
eventName: 'issue_comment',
payload: comment('/help'),
inputs: { ...token, 'prow-commands': '/help' },
apiUrl: gh.url,
})

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
const posts = gh.requestsMatching('POST', /\/issues\/1\/labels$/)
expect(posts).toHaveLength(1)
expect(posts[0].body).toEqual({ labels: ['help wanted'] })
expect(gh.requests.map(r => `${r.method} ${r.path}`)).toEqual([
`POST ${repo}/issues/1/labels`,
])
})

it('issue_comment /assign self-assigns an org member', async () => {
gh.route('GET', '/orgs/Codertocat/members/Codertocat', { status: 204 })
gh.route('GET', `${repo}/collaborators/Codertocat`, { status: 404, body: { message: 'Not Found' } })
Expand Down
2 changes: 1 addition & 1 deletion __tests__/cronJobTest/labelPr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'

import { handleCronJobs } from '../../src/cronJobs/handleCronJob'
import labelFileContents from '../fixtures/labels/labelFileContentsResp.json'
import labelFileContents from '../fixtures/labels/globLabelsFileContentsResp.json'

import prListFiles from '../fixtures/pullReq/pullReqListFiles.json'
import listPullReqs from '../fixtures/pullReq/pullReqListPulls.json'
Expand Down
18 changes: 18 additions & 0 deletions __tests__/fixtures/labels/globLabelsFileContentsResp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"type": "file",
"encoding": "base64",
"size": 203,
"name": "labels.yaml",
"path": ".github/labels.yaml",
"content": "YXJlYToKICAtICdidWcnCiAgLSAnaW1wb3J0YW50JwoKa2luZDoKICAtICdmYWlsaW5nLXRlc3QnCiAgLSAnY2xlYW51cCcKCnByaW9yaXR5OgogIC0gJ2xvdycKICAtICdoaWdoJwoKbGFiZWxzOgogIC0gJ2dvb2QtZmlyc3QtaXNzdWUnCiAgLSAnaGVscC13YW50ZWQnCgp0ZXN0czoKICAtICcqKi8qLnRlc3QudHMnCgpzb3VyY2U6CiAgLSAnc3JjLyoqJwo=",
"sha": "3d21ec53a331a6f037a91c368710b99387d012c1",
"url": "https://api.github.com/repos/octokit/octokit.rb/contents/.github.com/labels.yaml",
"git_url": "https://api.github.com/repos/octokit/octokit.rb/git/blobs/3d21ec53a331a6f037a91c368710b99387d012c1",
"html_url": "https://github.com/octokit/octokit.rb/blob/master/.github.com/labels.yaml",
"download_url": "https://raw.githubusercontent.com/octokit/octokit.rb/master/.github.com/labels.yaml",
"_links": {
"git": "https://api.github.com/repos/octokit/octokit.rb/git/blobs/3d21ec53a331a6f037a91c368710b99387d012c1",
"self": "https://api.github.com/repos/octokit/octokit.rb/contents/.github.com/labels.yaml",
"html": "https://github.com/octokit/octokit.rb/blob/master/.github.com/labels.yaml"
}
}
4 changes: 2 additions & 2 deletions __tests__/fixtures/labels/labelFileContentsResp.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"type": "file",
"encoding": "base64",
"size": 203,
"size": 346,
"name": "labels.yaml",
"path": ".prowlabels.yaml",
"content": "YXJlYToKICAtICdidWcnCiAgLSAnaW1wb3J0YW50JwoKa2luZDoKICAtICdmYWlsaW5nLXRlc3QnCiAgLSAnY2xlYW51cCcKCnByaW9yaXR5OgogIC0gJ2xvdycKICAtICdoaWdoJwoKbGFiZWxzOgogIC0gJ2dvb2QtZmlyc3QtaXNzdWUnCiAgLSAnaGVscC13YW50ZWQnCgp0ZXN0czoKICAtICcqKi8qLnRlc3QudHMnCgpzb3VyY2U6CiAgLSAnc3JjLyoqJwo=",
"content": "YXJlYToKICAtICdidWcnCiAgLSAnaW1wb3J0YW50JwoKa2luZDoKICAtICdmYWlsaW5nLXRlc3QnCiAgLSAnY2xlYW51cCcKCnByaW9yaXR5OgogIC0gJ2xvdycKICAtICdoaWdoJwoKbGFiZWxzOgogIC0gJ2dvb2QtZmlyc3QtaXNzdWUnCiAgLSAnaGVscC13YW50ZWQnCgpsZXZlbDoKICB2YWx1ZXM6CiAgICAtIHNhbmRib3gKICAgIC0gaW5jdWJhdGlvbgogICAgLSBncmFkdWF0aW9uCiAgICAtIGFyY2hpdmVkCiAgZXhjbHVzaXZlOiB0cnVlCgp0cmlhZ2U6CiAgLSBhY2NlcHRlZAogIC0gbmVlZHMtaW5mb3JtYXRpb24KCnRlc3RzOgogIC0gJyoqLyoudGVzdC50cycKCnNvdXJjZToKICAtICdzcmMvKionCg==",
"sha": "3d21ec53a331a6f037a91c368710b99387d012c1",
"url": "https://api.github.com/repos/octokit/octokit.rb/contents/.github.com/labels.yaml",
"git_url": "https://api.github.com/repos/octokit/octokit.rb/git/blobs/3d21ec53a331a6f037a91c368710b99387d012c1",
Expand Down
109 changes: 106 additions & 3 deletions __tests__/issueCommentTest/handleIssueComment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as assign from '../../src/issueComment/assign'
import * as cc from '../../src/issueComment/cc'
import { handleIssueComment } from '../../src/issueComment/handleIssueComment'
import * as unassign from '../../src/issueComment/unassign'
import * as fixed from '../../src/labels/fixed'
import * as hold from '../../src/labels/hold'
import * as lgtm from '../../src/labels/lgtm'
import * as prefixed from '../../src/labels/prefixed'
Expand Down Expand Up @@ -146,19 +147,121 @@ it('fails when prow-commands is empty', async () => {
})

it('fails for an unsupported command in prow-commands', async () => {
utils.setupActionsEnv('/not-a-command')
utils.setupActionsEnv('/not_a_command')

const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {})

issueCommentEvent.comment.body = '/not-a-command'
issueCommentEvent.comment.body = '/not_a_command'
const context = new utils.MockContext(issueCommentEvent)

await handleIssueComment(context)
expect(setFailed).toHaveBeenCalledWith(
expect.stringContaining('could not execute /not-a-command'),
expect.stringContaining('could not execute /not_a_command'),
)
})

it('dispatches an unknown lower-case command as a dynamic label command', async () => {
utils.setupActionsEnv('/level')

const add = vi.spyOn(prefixed, 'addPrefixedLabels').mockImplementation(() => Promise.resolve())
const remove = vi.spyOn(prefixed, 'removePrefixedLabels').mockImplementation(() => Promise.resolve())
const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {})

issueCommentEvent.comment.body = '/level incubation'
const context = new utils.MockContext(issueCommentEvent)

await handleIssueComment(context)
expect(add).toHaveBeenCalledTimes(1)
expect(add.mock.calls[0][1]).toEqual({ command: '/level', prefix: 'level', allowlistKey: 'level' })
expect(remove).not.toHaveBeenCalled()
expect(setFailed).not.toHaveBeenCalled()
})

it('dispatches /remove-<key> to the remove path of a dynamic label command', async () => {
utils.setupActionsEnv('/level')

const add = vi.spyOn(prefixed, 'addPrefixedLabels').mockImplementation(() => Promise.resolve())
const remove = vi.spyOn(prefixed, 'removePrefixedLabels').mockImplementation(() => Promise.resolve())

issueCommentEvent.comment.body = '/remove-level incubation'
const context = new utils.MockContext(issueCommentEvent)

await handleIssueComment(context)
expect(remove).toHaveBeenCalledTimes(1)
expect(remove.mock.calls[0][1]).toMatchObject({ command: '/level' })
expect(add).not.toHaveBeenCalled()
})

it('listing only /remove-<key> also enables the dynamic /<key>', async () => {
utils.setupActionsEnv('/remove-level')

const add = vi.spyOn(prefixed, 'addPrefixedLabels').mockImplementation(() => Promise.resolve())
const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {})

issueCommentEvent.comment.body = '/level incubation'
const context = new utils.MockContext(issueCommentEvent)

await handleIssueComment(context)
expect(add).toHaveBeenCalledTimes(1)
expect(add.mock.calls[0][1]).toMatchObject({ command: '/level' })
expect(setFailed).not.toHaveBeenCalled()
})

it.each([
['/help', '/help'],
['/good-first-issue', '/good-first-issue'],
['/remove-good-first-issue', '/good-first-issue'],
] as const)('dispatches a fixed label command listed as %s to %s', async (config, command) => {
utils.setupActionsEnv(config)

const add = vi.spyOn(fixed, 'addFixedLabels').mockImplementation(() => Promise.resolve())
const remove = vi.spyOn(fixed, 'removeFixedLabels').mockImplementation(() => Promise.resolve())
const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {})

issueCommentEvent.comment.body = command
const context = new utils.MockContext(issueCommentEvent)

await handleIssueComment(context)
expect(add).toHaveBeenCalledTimes(1)
expect(add.mock.calls[0][1]).toMatchObject({ command })
expect(remove).not.toHaveBeenCalled()
expect(setFailed).not.toHaveBeenCalled()
})

it('runs remove before add when a comment carries both /help and /remove-help', async () => {
utils.setupActionsEnv('/help')

const order: string[] = []
vi.spyOn(fixed, 'addFixedLabels').mockImplementation(async () => {
order.push('add')
})
vi.spyOn(fixed, 'removeFixedLabels').mockImplementation(async () => {
order.push('remove')
})

issueCommentEvent.comment.body = '/help\n/remove-help'
const context = new utils.MockContext(issueCommentEvent)

await handleIssueComment(context)
expect(order).toEqual(['remove', 'add'])
})

it('does not turn /remove-<hand-written command> into a label command', async () => {
utils.setupActionsEnv('/assign')

vi.spyOn(assign, 'assign').mockImplementation(() => Promise.resolve())
const add = vi.spyOn(prefixed, 'addPrefixedLabels').mockImplementation(() => Promise.resolve())
const remove = vi.spyOn(prefixed, 'removePrefixedLabels').mockImplementation(() => Promise.resolve())

issueCommentEvent.comment.body = '/remove-assign @some-user'
const context = new utils.MockContext(issueCommentEvent)

await handleIssueComment(context)
expect(assign.assign).not.toHaveBeenCalled()
expect(add).not.toHaveBeenCalled()
expect(remove).not.toHaveBeenCalled()
})

it('dispatches /remove-lgtm once to lgtm when /lgtm is configured', async () => {
utils.setupActionsEnv('/lgtm')

Expand Down
136 changes: 136 additions & 0 deletions __tests__/label/dynamic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import * as core from '@actions/core'
import { http } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'

import { handleIssueComment } from '../../src/issueComment/handleIssueComment'
import issuePayload from '../fixtures/issues/issue.json'
import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json'

import labelFileContents from '../fixtures/labels/labelFileContentsResp.json'
import * as utils from '../testUtils'

const server = setupServer()
beforeAll(() =>
server.listen({
onUnhandledRequest: 'error',
}),
)
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

const repo = `${utils.api}/repos/Codertocat/Hello-World`

function issueWithLabels(...names: string[]) {
const payload = structuredClone(issuePayload)
for (const name of names) {
payload.labels.push({ ...payload.labels[0], name })
}
return payload
}

function serveIssueAndRecordMutations(currentLabels: string[]): string[] {
const mutations: string[] = []
server.use(
http.delete(`${repo}/issues/1/labels/:name`, async ({ request }) => {
mutations.push(`DELETE ${new URL(request.url).pathname.split('/labels/')[1]}`)
return new Response(null, { status: 200 })
}),
http.post(`${repo}/issues/1/labels`, async ({ request }) => {
const body = await request.json() as { labels: string[] }
mutations.push(`POST ${body.labels.join(',')}`)
return new Response(null, { status: 200 })
}),
http.get(`${repo}/issues/1`, utils.mockResponse(200, issueWithLabels(...currentLabels))),
http.get(`${repo}/contents/.prowlabels.yaml`, utils.mockResponse(200, labelFileContents)),
)
return mutations
}

async function run(config: string, body: string) {
utils.setupActionsEnv(config)
issueCommentEvent.comment.body = body
const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {})
await handleIssueComment(new utils.MockContext(issueCommentEvent))
return setFailed
}

describe('dynamic label commands from .prowlabels.yaml keys', () => {
it('/level adds level/<value> for a mapping-form section', async () => {
const mutations = serveIssueAndRecordMutations([])

const setFailed = await run('/level', '/level incubation')

expect(mutations).toEqual(['POST level/incubation'])
expect(setFailed).not.toHaveBeenCalled()
})

it('/level replaces the existing level label when the yaml marks it exclusive', async () => {
const mutations = serveIssueAndRecordMutations(['level/sandbox'])

const setFailed = await run('/level', '/level incubation')

expect(mutations).toEqual(['DELETE level%2Fsandbox', 'POST level/incubation'])
expect(setFailed).not.toHaveBeenCalled()
})

it('/remove-level removes level/<value>', async () => {
const mutations = serveIssueAndRecordMutations(['level/incubation'])

const setFailed = await run('/level', '/remove-level incubation')

expect(mutations).toEqual(['DELETE level%2Fincubation'])
expect(setFailed).not.toHaveBeenCalled()
})

it('/level fails for a value that is not in the section', async () => {
const mutations = serveIssueAndRecordMutations([])

const setFailed = await run('/level', '/level foo')

expect(mutations).toEqual([])
expect(setFailed).toHaveBeenCalledWith(
expect.stringContaining('level: command args missing from body'),
)
})

it('/triage stacks labels for a list-form section', async () => {
const mutations = serveIssueAndRecordMutations(['triage/accepted'])

const setFailed = await run('/triage', '/triage needs-information')

expect(mutations).toEqual(['POST triage/needs-information'])
expect(setFailed).not.toHaveBeenCalled()
})

it('fails with the missing key error for a command without a yaml section', async () => {
const mutations = serveIssueAndRecordMutations([])

const setFailed = await run('/nonexistent', '/nonexistent x')

expect(mutations).toEqual([])
expect(setFailed).toHaveBeenCalledWith(
expect.stringContaining(`nonexistent: yaml malformed, expected 'nonexistent' top level key`),
)
})

it('listing only /remove-level also enables /level', async () => {
const mutations = serveIssueAndRecordMutations([])

const setFailed = await run('/remove-level', '/level graduation')

expect(mutations).toEqual(['POST level/graduation'])
expect(setFailed).not.toHaveBeenCalled()
})

it('keeps the unsupported command error for a name that is not a valid key', async () => {
const mutations = serveIssueAndRecordMutations([])

const setFailed = await run('/foo_bar', '/foo_bar x')

expect(mutations).toEqual([])
expect(setFailed).toHaveBeenCalledWith(
expect.stringContaining('could not execute /foo_bar'),
)
})
})
Loading
Loading