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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,19 @@ jobs:
- run: pnpm run lint

- run: pnpm run typecheck

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: pnpm

- run: pnpm ci

- run: pnpm run test
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
"build": "pnpm -r run build",
"lint": "eslint",
"lint:fix": "eslint --fix",
"test": "vitest run",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@typescript/native-preview": "catalog:",
"eslint": "catalog:"
"eslint": "catalog:",
"vitest": "catalog:"
}
}
6 changes: 4 additions & 2 deletions packages/cnb-delete-branch/dist/index.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createRequire } from "node:module";
import process$1 from "node:process";
import { pathToFileURL } from "node:url";
import * as os$1 from "os";
import os, { EOL } from "os";
import * as fs from "fs";
Expand Down Expand Up @@ -16234,8 +16236,8 @@ async function main() {
setFailed(error instanceof Error ? error.message : String(error));
}
}
main().catch((error) => {
if (process$1.argv[1] && import.meta.url === pathToFileURL(process$1.argv[1]).href) main().catch((error) => {
setFailed(`cnb-delete-branch failed: ${error instanceof Error ? error.message : String(error)}`);
});
//#endregion
export {};
export { CNBRequestError, deleteBranch, encodePath, fetchCNB, getPullBranchName, getPullRepoPath, listPulls, main, patchPull };
141 changes: 141 additions & 0 deletions packages/cnb-delete-branch/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { PullRequest } from './types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
CNBRequestError,
deleteBranch,
encodePath,
fetchCNB,
getPullBranchName,
getPullRepoPath,
listPulls,
patchPull,
} from './index'

vi.mock('@actions/core', () => ({
endGroup: vi.fn(),
getInput: vi.fn(),
info: vi.fn(),
setFailed: vi.fn(),
startGroup: vi.fn(),
}))

function mockFetchOnce(status: number, body = '', statusText = ''): void {
vi.mocked(fetch).mockResolvedValueOnce(new Response(body, { status, statusText }))
}

function createPullRequest(number: string, ref = 'refs/heads/feature', repo = 'tdesign/test'): PullRequest {
return {
assignees: [],
author: {} as PullRequest['author'],
base: null,
blocked_on: '',
body: '',
comment_count: 0,
created_at: '',
head: {
ref,
repo: {
id: repo,
name: repo.split('/').at(-1) || repo,
path: repo,
web_url: '',
},
sha: '',
},
is_wip: false,
labels: [],
last_acted_at: '',
mergeable_state: '',
merged_by: {} as PullRequest['merged_by'],
number,
repo: {} as PullRequest['repo'],
review_count: 0,
state: 'open',
title: `PR ${number}`,
updated_at: '',
}
}

describe('删除 CNB 分支', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})

it('编码仓库路径时保留路径分隔符', () => {
expect(encodePath('tdesign/workflows')).toBe('tdesign/workflows')
expect(encodePath('tdesign/branch with space')).toBe('tdesign/branch%20with%20space')
})

it('发送 CNB JSON 请求头并解析 JSON 响应', async () => {
mockFetchOnce(200, JSON.stringify({ ok: true }))

await expect(fetchCNB('token', '/tdesign/test/-/pulls')).resolves.toEqual({
status: 200,
data: { ok: true },
})

expect(fetch).toHaveBeenCalledWith('https://api.cnb.cool/tdesign/test/-/pulls', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
})
})

it('请求失败时抛出 CNBRequestError', async () => {
mockFetchOnce(403, JSON.stringify({ errmsg: 'forbidden' }), 'Forbidden')

await expect(fetchCNB('token', '/tdesign/test/-/pulls')).rejects.toMatchObject({
message: '[CNB] GET /tdesign/test/-/pulls failed: forbidden',
status: 403,
})
})

it('分页拉取 Pull Request 列表', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => createPullRequest(String(index + 1)))
const secondPage = [createPullRequest('101')]
mockFetchOnce(200, JSON.stringify(firstPage))
mockFetchOnce(200, JSON.stringify(secondPage))

const pulls = await listPulls('token', 'tdesign/test', 'open')

expect(pulls).toHaveLength(101)
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://api.cnb.cool/tdesign/test/-/pulls?state=open&page=1&page_size=100',
expect.any(Object),
)
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://api.cnb.cool/tdesign/test/-/pulls?state=open&page=2&page_size=100',
expect.any(Object),
)
})

it('通过 CNB Pull Request 接口更新 PR 状态', async () => {
mockFetchOnce(200, '{}')

await expect(patchPull('token', 'tdesign/test', '12', { state: 'closed' })).resolves.toBe(true)

expect(fetch).toHaveBeenCalledWith('https://api.cnb.cool/tdesign/test/-/pulls/12', expect.objectContaining({
body: JSON.stringify({ state: 'closed' }),
method: 'PATCH',
}))
})

it('分支不存在时视为成功跳过,其他删除失败继续抛出', async () => {
mockFetchOnce(404, JSON.stringify({ errmsg: 'not found' }), 'Not Found')
await expect(deleteBranch('token', 'tdesign/test', 'feature')).resolves.toBe(false)

mockFetchOnce(403, JSON.stringify({ errmsg: 'forbidden' }), 'Forbidden')
await expect(deleteBranch('token', 'tdesign/test', 'feature')).rejects.toBeInstanceOf(CNBRequestError)
})

it('head 数据缺失时安全读取 PR 分支和仓库', () => {
expect(getPullBranchName(createPullRequest('1', 'refs/heads/feature/test'))).toBe('feature/test')
expect(getPullRepoPath(createPullRequest('1'))).toBe('tdesign/test')
expect(getPullBranchName({ head: null } as PullRequest)).toBeUndefined()
expect(getPullRepoPath({ head: null } as PullRequest)).toBeUndefined()
})
})
28 changes: 16 additions & 12 deletions packages/cnb-delete-branch/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { PullRequest } from './types'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import * as core from '@actions/core'

const CNB_API_URL = 'https://api.cnb.cool'
Expand All @@ -9,7 +11,7 @@ interface CNBResponse<T> {
data?: T
}

class CNBRequestError extends Error {
export class CNBRequestError extends Error {
constructor(message: string, public status: number) {
super(message)
this.name = 'CNBRequestError'
Expand All @@ -28,19 +30,19 @@ function parseJson<T>(text: string): T | undefined {
}
}

function encodePath(value: string): string {
export function encodePath(value: string): string {
return value.split('/').map(encodeURIComponent).join('/')
}

function getPullRepoPath(pr: PullRequest): string | undefined {
export function getPullRepoPath(pr: PullRequest): string | undefined {
return pr.head?.repo?.path
}

function getPullBranchName(pr: PullRequest): string | undefined {
export function getPullBranchName(pr: PullRequest): string | undefined {
return pr.head?.ref?.replace(/^refs\/heads\//, '')
}

async function fetchCNB<T>(
export async function fetchCNB<T>(
token: string,
path: string,
options?: RequestInit,
Expand Down Expand Up @@ -74,7 +76,7 @@ async function fetchCNB<T>(
}
}

async function listPulls(token: string, repo: string, state: string): Promise<PullRequest[]> {
export async function listPulls(token: string, repo: string, state: string): Promise<PullRequest[]> {
const pulls: PullRequest[] = []

for (let page = 1; ; page += 1) {
Expand All @@ -94,7 +96,7 @@ async function listPulls(token: string, repo: string, state: string): Promise<Pu
return pulls
}

async function patchPull(
export async function patchPull(
token: string,
repo: string,
number: string,
Expand All @@ -107,7 +109,7 @@ async function patchPull(
return Boolean(result)
}

async function deleteBranch(token: string, repo: string, branch: string): Promise<boolean> {
export async function deleteBranch(token: string, repo: string, branch: string): Promise<boolean> {
try {
await fetchCNB(token, `/${encodePath(repo)}/-/git/branches/${encodePath(branch)}`, {
method: 'DELETE',
Expand All @@ -122,7 +124,7 @@ async function deleteBranch(token: string, repo: string, branch: string): Promis
}
}

async function main(): Promise<void> {
export async function main(): Promise<void> {
const repo = core.getInput('repo', { required: true })
const branch = core.getInput('branch', { required: true })
const token = core.getInput('token', { required: true })
Expand Down Expand Up @@ -175,6 +177,8 @@ async function main(): Promise<void> {
}
}

main().catch((error) => {
core.setFailed(`cnb-delete-branch failed: ${error instanceof Error ? error.message : String(error)}`)
})
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
core.setFailed(`cnb-delete-branch failed: ${error instanceof Error ? error.message : String(error)}`)
})
}
Loading