@icode-js/icode 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +287 -24
- package/bin/icode.js +10 -0
- package/package.json +3 -3
- package/src/cli.js +175 -8
- package/src/commands/checkout.js +73 -6
- package/src/commands/clean.js +47 -7
- package/src/commands/config-ux.js +292 -0
- package/src/commands/config.js +199 -48
- package/src/commands/continue.js +104 -0
- package/src/commands/doctor.js +15 -29
- package/src/commands/help.js +54 -34
- package/src/commands/history.js +147 -0
- package/src/commands/info.js +39 -4
- package/src/commands/log.js +142 -0
- package/src/commands/migrate.js +19 -12
- package/src/commands/open.js +138 -0
- package/src/commands/pr.js +219 -0
- package/src/commands/push.js +62 -22
- package/src/commands/ready.js +18 -48
- package/src/commands/release.js +112 -0
- package/src/commands/rescue.js +164 -0
- package/src/commands/save.js +52 -11
- package/src/commands/stash.js +210 -0
- package/src/commands/sw.js +89 -0
- package/src/commands/sync.js +19 -2
- package/src/commands/tag.js +40 -1
- package/src/commands/undo.js +9 -3
- package/src/core/ai/diff-range.js +1 -1
- package/src/core/cli/completion.js +237 -1
- package/src/core/cli/output.js +46 -0
- package/src/core/cli/render.js +121 -0
- package/src/core/commands/shell.js +9 -1
- package/src/core/config/repo-config.js +93 -0
- package/src/core/config/store.js +60 -10
- package/src/core/git/autostash.js +46 -0
- package/src/core/git/backup.js +170 -0
- package/src/core/git/conflicts.js +58 -0
- package/src/core/git/context.js +72 -5
- package/src/core/git/file-guard.js +193 -0
- package/src/core/git/operation-state.js +63 -0
- package/src/core/git/operations.js +32 -0
- package/src/core/git/semver.js +194 -0
- package/src/core/git/service.js +454 -24
- package/src/core/platform/http.js +53 -0
- package/src/core/platform/index.js +165 -0
- package/src/core/platform/providers.js +454 -0
- package/src/core/platform/remote-url.js +92 -0
- package/src/core/remote-merge/client.js +33 -3
- package/src/core/tools/browser.js +28 -0
- package/src/core/tools/commit-prompt.js +137 -0
- package/src/core/tools/interactive.js +272 -40
- package/src/core/tools/logger.js +33 -23
- package/src/core/tools/theme.js +228 -0
- package/src/workflows/ai-commit-workflow.js +52 -20
- package/src/workflows/checkout-workflow.js +24 -10
- package/src/workflows/clean-workflow.js +135 -83
- package/src/workflows/continue-workflow.js +233 -0
- package/src/workflows/doctor-workflow.js +10 -7
- package/src/workflows/history-workflow.js +211 -0
- package/src/workflows/migrate-workflow.js +39 -14
- package/src/workflows/pr-workflow.js +252 -0
- package/src/workflows/push/commit-step.js +123 -0
- package/src/workflows/push/local-merge-step.js +213 -0
- package/src/workflows/push/plan.js +63 -0
- package/src/workflows/push/remote-merge-step.js +224 -0
- package/src/workflows/push-workflow.js +185 -492
- package/src/workflows/ready-workflow.js +14 -10
- package/src/workflows/release-workflow.js +189 -0
- package/src/workflows/rescue-workflow.js +137 -0
- package/src/workflows/save-workflow.js +106 -41
- package/src/workflows/switch-workflow.js +171 -0
- package/src/workflows/sync-workflow.js +143 -44
- package/src/workflows/tag-workflow.js +103 -17
- package/src/workflows/undo-workflow.js +113 -34
- package/src/workflows/rollback-workflow.js +0 -99
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readConfig } from '../config/store.js'
|
|
2
|
+
import { IcodeError } from '../errors.js'
|
|
3
|
+
import { PLATFORM_PROVIDERS, SUPPORTED_PLATFORM_NAMES } from './providers.js'
|
|
4
|
+
import { detectProviderFromHost, parseRemoteUrl } from './remote-url.js'
|
|
5
|
+
|
|
6
|
+
export { SUPPORTED_PLATFORM_NAMES } from './providers.js'
|
|
7
|
+
export { detectProviderFromHost, parseRemoteUrl } from './remote-url.js'
|
|
8
|
+
|
|
9
|
+
// 各平台 token 的环境变量名,按顺序取第一个非空值。
|
|
10
|
+
const TOKEN_ENV_NAMES = {
|
|
11
|
+
github: ['ICODE_GITHUB_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'],
|
|
12
|
+
gitlab: ['ICODE_GITLAB_TOKEN', 'GITLAB_TOKEN', 'GITLAB_PRIVATE_TOKEN'],
|
|
13
|
+
gitee: ['ICODE_GITEE_TOKEN', 'GITEE_TOKEN'],
|
|
14
|
+
gitea: ['ICODE_GITEA_TOKEN', 'GITEA_TOKEN']
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 读取平台 token。
|
|
19
|
+
* 优先级:platforms.tokens.<provider> > 旧配置 platforms.remoteMerge.apiKey(provider 一致时)> 环境变量。
|
|
20
|
+
* 输入为 provider 与可选的已读取配置,输出 { token, source }。
|
|
21
|
+
*/
|
|
22
|
+
export function resolvePlatformToken(provider, config = readConfig()) {
|
|
23
|
+
const name = String(provider || '').trim().toLowerCase()
|
|
24
|
+
const fromTokens = String(config.platforms?.tokens?.[name] || '').trim()
|
|
25
|
+
if (fromTokens) {
|
|
26
|
+
return { token: fromTokens, source: `config:platforms.tokens.${name}` }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const legacy = config.platforms?.remoteMerge || {}
|
|
30
|
+
if (String(legacy.provider || '').trim().toLowerCase() === name && String(legacy.apiKey || '').trim()) {
|
|
31
|
+
return { token: String(legacy.apiKey).trim(), source: 'config:platforms.remoteMerge.apiKey' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const envName of TOKEN_ENV_NAMES[name] || []) {
|
|
35
|
+
const value = String(process.env[envName] || '').trim()
|
|
36
|
+
if (value) {
|
|
37
|
+
return { token: value, source: `env:${envName}` }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { token: '', source: '' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 识别平台:显式 provider > 配置 platforms.hosts 映射 > 主机名推断 > 旧配置 remoteMerge.provider。
|
|
46
|
+
* 输入为 remote URL 与 { provider },输出 provider 名称或空串。
|
|
47
|
+
*/
|
|
48
|
+
export function resolveProviderName(remoteUrl, options = {}, config = readConfig()) {
|
|
49
|
+
const explicit = String(options.provider || '').trim().toLowerCase()
|
|
50
|
+
if (explicit) {
|
|
51
|
+
return explicit
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const repo = parseRemoteUrl(remoteUrl)
|
|
55
|
+
const detected = repo ? detectProviderFromHost(repo.host, config.platforms?.hosts || {}) : ''
|
|
56
|
+
if (detected) {
|
|
57
|
+
return detected
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return String(config.platforms?.remoteMerge?.provider || '').trim().toLowerCase()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 根据 remote URL 创建平台客户端。
|
|
65
|
+
* 输入为 remote URL 与 { provider, token },输出 provider 客户端(包含 name/urls/PR/Release 方法)和仓库信息。
|
|
66
|
+
*/
|
|
67
|
+
export function createPlatformClient(remoteUrl, options = {}) {
|
|
68
|
+
const config = options.config || readConfig()
|
|
69
|
+
const repo = parseRemoteUrl(remoteUrl)
|
|
70
|
+
if (!repo) {
|
|
71
|
+
throw new IcodeError(`无法识别 remote 地址: ${remoteUrl || '(空)'}。请检查 git remote -v。`, {
|
|
72
|
+
code: 'PLATFORM_REMOTE_UNRECOGNIZED',
|
|
73
|
+
exitCode: 2
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const provider = resolveProviderName(remoteUrl, options, config)
|
|
78
|
+
const factory = PLATFORM_PROVIDERS[provider]
|
|
79
|
+
if (!factory) {
|
|
80
|
+
throw new IcodeError(
|
|
81
|
+
`无法识别代码托管平台: ${repo.host}。支持 ${SUPPORTED_PLATFORM_NAMES.join(', ')};` +
|
|
82
|
+
`自建平台请执行 icode config platform host set ${repo.host} <provider>`,
|
|
83
|
+
{ code: 'PLATFORM_UNSUPPORTED', exitCode: 2, meta: { host: repo.host, provider } }
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tokenInfo = options.token ? { token: options.token, source: 'option' } : resolvePlatformToken(provider, config)
|
|
88
|
+
const client = factory(repo, tokenInfo.token)
|
|
89
|
+
return {
|
|
90
|
+
...client,
|
|
91
|
+
repo,
|
|
92
|
+
tokenSource: tokenInfo.source,
|
|
93
|
+
hasToken: Boolean(tokenInfo.token)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 基于 GitService 当前 remote 创建平台客户端。
|
|
99
|
+
*/
|
|
100
|
+
export async function createPlatformClientForGit(git, options = {}) {
|
|
101
|
+
const remoteUrl = await git.getRemoteUrl()
|
|
102
|
+
if (!remoteUrl) {
|
|
103
|
+
throw new IcodeError(`当前仓库没有 ${git.remote} remote,无法访问代码托管平台。`, {
|
|
104
|
+
code: 'PLATFORM_REMOTE_MISSING',
|
|
105
|
+
exitCode: 2
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return createPlatformClient(remoteUrl, options)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function delay(ms) {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
setTimeout(resolve, ms)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 通用远程合并:找已有 PR 或新建 -> 等待平台算出可合并状态 -> 合并。
|
|
119
|
+
* 输入为平台客户端与 { sourceBranch, targetBranch, title, body, pollIntervalMs, maxAttempts },
|
|
120
|
+
* 输出与 GitLab 远程合并一致的结果:{ ok, conflict, step, reason, mergeRequestUrl, mergeRequestId }。
|
|
121
|
+
*/
|
|
122
|
+
export async function performPlatformRemoteMerge(client, options = {}) {
|
|
123
|
+
const pollIntervalMs = Number.isInteger(options.pollIntervalMs) ? options.pollIntervalMs : 1500
|
|
124
|
+
const maxAttempts = Number.isInteger(options.maxAttempts) ? options.maxAttempts : 40
|
|
125
|
+
|
|
126
|
+
let pr
|
|
127
|
+
try {
|
|
128
|
+
pr = await client.createPullRequest({
|
|
129
|
+
title: options.title,
|
|
130
|
+
body: options.body,
|
|
131
|
+
head: options.sourceBranch,
|
|
132
|
+
base: options.targetBranch
|
|
133
|
+
})
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { ok: false, conflict: false, step: 'create', reason: error.message }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const base = { mergeRequestUrl: pr.url || client.urls.pr(pr.number), mergeRequestId: pr.number }
|
|
139
|
+
|
|
140
|
+
// 平台异步计算 mergeable,null 表示还没算完。
|
|
141
|
+
let current = pr
|
|
142
|
+
for (let attempt = 0; attempt < maxAttempts && current.mergeable === null && !current.merged; attempt += 1) {
|
|
143
|
+
await delay(pollIntervalMs)
|
|
144
|
+
try {
|
|
145
|
+
current = await client.getPullRequest(pr.number)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return { ok: false, conflict: false, step: 'inspect', reason: error.message, ...base }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (current.merged) {
|
|
152
|
+
return { ok: true, conflict: false, step: 'merge', reason: `${client.prNoun} 已合并`, ...base }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (current.conflict || current.mergeable === false) {
|
|
156
|
+
return { ok: false, conflict: true, step: 'inspect', reason: `${client.prNoun} 存在冲突或不可合并,请在平台上处理`, ...base }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const merged = await client.mergePullRequest(pr.number, { method: options.method })
|
|
160
|
+
if (!merged.ok) {
|
|
161
|
+
return { ok: false, conflict: merged.conflict, step: 'merge', reason: merged.reason, ...base }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { ok: true, conflict: false, step: 'merge', reason: '远程合并已完成', ...base }
|
|
165
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { IcodeError } from '../errors.js'
|
|
2
|
+
import { assertResponseOk, requestJson } from './http.js'
|
|
3
|
+
|
|
4
|
+
// 各平台的 API 适配。每个 provider 都实现同一组方法,并把 PR/MR 归一化成:
|
|
5
|
+
// { number, title, state, url, head, base, author, draft, merged, mergeable, conflict }
|
|
6
|
+
|
|
7
|
+
function requireToken(provider, token) {
|
|
8
|
+
if (!token) {
|
|
9
|
+
throw new IcodeError(
|
|
10
|
+
`缺少 ${provider} 平台 token。请执行: icode config platform token set ${provider} <token>(也可用环境变量)`,
|
|
11
|
+
{ code: 'PLATFORM_TOKEN_MISSING', exitCode: 2, meta: { provider } }
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function encodePath(value) {
|
|
17
|
+
return String(value).split('/').map(encodeURIComponent).join('/')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------- GitHub ----------------
|
|
21
|
+
|
|
22
|
+
function normalizeGitHubPr(item = {}) {
|
|
23
|
+
return {
|
|
24
|
+
number: item.number,
|
|
25
|
+
title: item.title || '',
|
|
26
|
+
state: item.merged_at ? 'merged' : item.state || '',
|
|
27
|
+
url: item.html_url || '',
|
|
28
|
+
head: item.head?.ref || '',
|
|
29
|
+
base: item.base?.ref || '',
|
|
30
|
+
author: item.user?.login || '',
|
|
31
|
+
draft: Boolean(item.draft),
|
|
32
|
+
merged: Boolean(item.merged || item.merged_at),
|
|
33
|
+
mergeable: typeof item.mergeable === 'boolean' ? item.mergeable : null,
|
|
34
|
+
conflict: item.mergeable_state === 'dirty'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createGitHubProvider(repo, token) {
|
|
39
|
+
const apiBase = repo.host === 'github.com' ? 'https://api.github.com' : `${repo.protocol}//${repo.host}/api/v3`
|
|
40
|
+
const repoApi = `${apiBase}/repos/${encodePath(repo.path)}`
|
|
41
|
+
const headers = () => {
|
|
42
|
+
requireToken('github', token)
|
|
43
|
+
return {
|
|
44
|
+
Accept: 'application/vnd.github+json',
|
|
45
|
+
Authorization: `Bearer ${token}`,
|
|
46
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
47
|
+
'User-Agent': 'icode-cli',
|
|
48
|
+
'Content-Type': 'application/json'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: 'github',
|
|
54
|
+
label: 'GitHub',
|
|
55
|
+
prNoun: 'PR',
|
|
56
|
+
prRef: (number) => `refs/pull/${number}/head`,
|
|
57
|
+
urls: {
|
|
58
|
+
repo: () => repo.webUrl,
|
|
59
|
+
branch: (branch) => `${repo.webUrl}/tree/${encodePath(branch)}`,
|
|
60
|
+
commit: (sha) => `${repo.webUrl}/commit/${sha}`,
|
|
61
|
+
file: (ref, file, line) => `${repo.webUrl}/blob/${encodePath(ref)}/${encodePath(file)}${line ? `#L${line}` : ''}`,
|
|
62
|
+
pr: (number) => `${repo.webUrl}/pull/${number}`,
|
|
63
|
+
prs: () => `${repo.webUrl}/pulls`,
|
|
64
|
+
newPr: (base, head) => `${repo.webUrl}/compare/${encodePath(base)}...${encodePath(head)}?expand=1`,
|
|
65
|
+
compare: (base, head) => `${repo.webUrl}/compare/${encodePath(base)}...${encodePath(head)}`,
|
|
66
|
+
releases: () => `${repo.webUrl}/releases`,
|
|
67
|
+
pipelines: () => `${repo.webUrl}/actions`
|
|
68
|
+
},
|
|
69
|
+
async listPullRequests(filter = {}) {
|
|
70
|
+
const url = new URL(`${repoApi}/pulls`)
|
|
71
|
+
url.searchParams.set('state', filter.state === 'merged' ? 'closed' : filter.state || 'open')
|
|
72
|
+
url.searchParams.set('per_page', String(filter.limit || 30))
|
|
73
|
+
if (filter.head) {
|
|
74
|
+
url.searchParams.set('head', `${repo.owner.split('/')[0]}:${filter.head}`)
|
|
75
|
+
}
|
|
76
|
+
if (filter.base) {
|
|
77
|
+
url.searchParams.set('base', filter.base)
|
|
78
|
+
}
|
|
79
|
+
const result = await requestJson(url.toString(), { headers: headers() }, 'GitHub 读取 PR 列表失败')
|
|
80
|
+
assertResponseOk(result, 'GitHub 读取 PR 列表失败')
|
|
81
|
+
const items = (Array.isArray(result.payload) ? result.payload : []).map(normalizeGitHubPr)
|
|
82
|
+
return filter.state === 'merged' ? items.filter((item) => item.merged) : items
|
|
83
|
+
},
|
|
84
|
+
async getPullRequest(number) {
|
|
85
|
+
const result = await requestJson(`${repoApi}/pulls/${number}`, { headers: headers() }, 'GitHub 读取 PR 失败')
|
|
86
|
+
assertResponseOk(result, `GitHub 读取 PR #${number} 失败`)
|
|
87
|
+
return normalizeGitHubPr(result.payload)
|
|
88
|
+
},
|
|
89
|
+
async createPullRequest(input) {
|
|
90
|
+
const result = await requestJson(`${repoApi}/pulls`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: headers(),
|
|
93
|
+
body: JSON.stringify({ title: input.title, body: input.body || '', head: input.head, base: input.base, draft: Boolean(input.draft) })
|
|
94
|
+
}, 'GitHub 创建 PR 失败')
|
|
95
|
+
if (!result.ok && result.status === 422 && /already exists/i.test(result.reason)) {
|
|
96
|
+
const [existing] = await this.listPullRequests({ head: input.head, base: input.base })
|
|
97
|
+
if (existing) {
|
|
98
|
+
return { ...existing, existed: true }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
assertResponseOk(result, 'GitHub 创建 PR 失败')
|
|
102
|
+
return normalizeGitHubPr(result.payload)
|
|
103
|
+
},
|
|
104
|
+
async mergePullRequest(number, options = {}) {
|
|
105
|
+
const result = await requestJson(`${repoApi}/pulls/${number}/merge`, {
|
|
106
|
+
method: 'PUT',
|
|
107
|
+
headers: headers(),
|
|
108
|
+
body: JSON.stringify({ merge_method: options.method || 'merge' })
|
|
109
|
+
}, 'GitHub 合并 PR 失败')
|
|
110
|
+
return {
|
|
111
|
+
ok: result.ok && result.payload?.merged !== false,
|
|
112
|
+
conflict: result.status === 405 || result.status === 409,
|
|
113
|
+
status: result.status,
|
|
114
|
+
reason: result.ok ? result.payload?.message || '已合并' : result.reason
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
async createRelease(input) {
|
|
118
|
+
const result = await requestJson(`${repoApi}/releases`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: headers(),
|
|
121
|
+
body: JSON.stringify({ tag_name: input.tag, name: input.name || input.tag, body: input.body || '', prerelease: Boolean(input.prerelease), draft: Boolean(input.draft) })
|
|
122
|
+
}, 'GitHub 创建 Release 失败')
|
|
123
|
+
assertResponseOk(result, 'GitHub 创建 Release 失败')
|
|
124
|
+
return { url: result.payload?.html_url || `${repo.webUrl}/releases/tag/${encodeURIComponent(input.tag)}` }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------- Gitea / Forgejo ----------------
|
|
130
|
+
|
|
131
|
+
function normalizeGiteaPr(item = {}) {
|
|
132
|
+
return {
|
|
133
|
+
number: item.number,
|
|
134
|
+
title: item.title || '',
|
|
135
|
+
state: item.merged ? 'merged' : item.state || '',
|
|
136
|
+
url: item.html_url || '',
|
|
137
|
+
head: item.head?.ref || '',
|
|
138
|
+
base: item.base?.ref || '',
|
|
139
|
+
author: item.user?.login || '',
|
|
140
|
+
draft: Boolean(item.draft) || /^(wip|draft)[:\s]/i.test(item.title || ''),
|
|
141
|
+
merged: Boolean(item.merged),
|
|
142
|
+
mergeable: typeof item.mergeable === 'boolean' ? item.mergeable : null,
|
|
143
|
+
conflict: item.mergeable === false && !item.merged
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createGiteaProvider(repo, token) {
|
|
148
|
+
const repoApi = `${repo.protocol}//${repo.host}/api/v1/repos/${encodePath(repo.path)}`
|
|
149
|
+
const headers = () => {
|
|
150
|
+
requireToken('gitea', token)
|
|
151
|
+
return { Authorization: `token ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
name: 'gitea',
|
|
156
|
+
label: 'Gitea',
|
|
157
|
+
prNoun: 'PR',
|
|
158
|
+
prRef: (number) => `refs/pull/${number}/head`,
|
|
159
|
+
urls: {
|
|
160
|
+
repo: () => repo.webUrl,
|
|
161
|
+
branch: (branch) => `${repo.webUrl}/src/branch/${encodePath(branch)}`,
|
|
162
|
+
commit: (sha) => `${repo.webUrl}/commit/${sha}`,
|
|
163
|
+
file: (ref, file, line) => `${repo.webUrl}/src/branch/${encodePath(ref)}/${encodePath(file)}${line ? `#L${line}` : ''}`,
|
|
164
|
+
pr: (number) => `${repo.webUrl}/pulls/${number}`,
|
|
165
|
+
prs: () => `${repo.webUrl}/pulls`,
|
|
166
|
+
newPr: (base, head) => `${repo.webUrl}/compare/${encodePath(base)}...${encodePath(head)}`,
|
|
167
|
+
compare: (base, head) => `${repo.webUrl}/compare/${encodePath(base)}...${encodePath(head)}`,
|
|
168
|
+
releases: () => `${repo.webUrl}/releases`,
|
|
169
|
+
pipelines: () => `${repo.webUrl}/actions`
|
|
170
|
+
},
|
|
171
|
+
async listPullRequests(filter = {}) {
|
|
172
|
+
const url = new URL(`${repoApi}/pulls`)
|
|
173
|
+
url.searchParams.set('state', filter.state === 'merged' ? 'closed' : filter.state || 'open')
|
|
174
|
+
url.searchParams.set('limit', String(filter.limit || 50))
|
|
175
|
+
const result = await requestJson(url.toString(), { headers: headers() }, 'Gitea 读取 PR 列表失败')
|
|
176
|
+
assertResponseOk(result, 'Gitea 读取 PR 列表失败')
|
|
177
|
+
return (Array.isArray(result.payload) ? result.payload : [])
|
|
178
|
+
.map(normalizeGiteaPr)
|
|
179
|
+
.filter((item) => (!filter.head || item.head === filter.head) && (!filter.base || item.base === filter.base))
|
|
180
|
+
.filter((item) => filter.state !== 'merged' || item.merged)
|
|
181
|
+
},
|
|
182
|
+
async getPullRequest(number) {
|
|
183
|
+
const result = await requestJson(`${repoApi}/pulls/${number}`, { headers: headers() }, 'Gitea 读取 PR 失败')
|
|
184
|
+
assertResponseOk(result, `Gitea 读取 PR #${number} 失败`)
|
|
185
|
+
return normalizeGiteaPr(result.payload)
|
|
186
|
+
},
|
|
187
|
+
async createPullRequest(input) {
|
|
188
|
+
const title = input.draft ? `WIP: ${input.title}` : input.title
|
|
189
|
+
const result = await requestJson(`${repoApi}/pulls`, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: headers(),
|
|
192
|
+
body: JSON.stringify({ title, body: input.body || '', head: input.head, base: input.base })
|
|
193
|
+
}, 'Gitea 创建 PR 失败')
|
|
194
|
+
if (!result.ok && result.status === 409) {
|
|
195
|
+
const [existing] = await this.listPullRequests({ head: input.head, base: input.base })
|
|
196
|
+
if (existing) {
|
|
197
|
+
return { ...existing, existed: true }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
assertResponseOk(result, 'Gitea 创建 PR 失败')
|
|
201
|
+
return normalizeGiteaPr(result.payload)
|
|
202
|
+
},
|
|
203
|
+
async mergePullRequest(number, options = {}) {
|
|
204
|
+
const result = await requestJson(`${repoApi}/pulls/${number}/merge`, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: headers(),
|
|
207
|
+
body: JSON.stringify({ Do: options.method || 'merge' })
|
|
208
|
+
}, 'Gitea 合并 PR 失败')
|
|
209
|
+
return {
|
|
210
|
+
ok: result.ok,
|
|
211
|
+
conflict: result.status === 405 || result.status === 409,
|
|
212
|
+
status: result.status,
|
|
213
|
+
reason: result.ok ? '已合并' : result.reason
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
async createRelease(input) {
|
|
217
|
+
const result = await requestJson(`${repoApi}/releases`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: headers(),
|
|
220
|
+
body: JSON.stringify({ tag_name: input.tag, name: input.name || input.tag, body: input.body || '', prerelease: Boolean(input.prerelease), draft: Boolean(input.draft) })
|
|
221
|
+
}, 'Gitea 创建 Release 失败')
|
|
222
|
+
assertResponseOk(result, 'Gitea 创建 Release 失败')
|
|
223
|
+
return { url: result.payload?.html_url || `${repo.webUrl}/releases/tag/${encodeURIComponent(input.tag)}` }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------- Gitee ----------------
|
|
229
|
+
|
|
230
|
+
function normalizeGiteePr(item = {}) {
|
|
231
|
+
return {
|
|
232
|
+
number: item.number,
|
|
233
|
+
title: item.title || '',
|
|
234
|
+
state: item.state || '',
|
|
235
|
+
url: item.html_url || '',
|
|
236
|
+
head: item.head?.ref || '',
|
|
237
|
+
base: item.base?.ref || '',
|
|
238
|
+
author: item.user?.login || '',
|
|
239
|
+
draft: Boolean(item.draft),
|
|
240
|
+
merged: item.state === 'merged',
|
|
241
|
+
mergeable: typeof item.mergeable === 'boolean' ? item.mergeable : null,
|
|
242
|
+
conflict: item.mergeable === false && item.state === 'open'
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function createGiteeProvider(repo, token) {
|
|
247
|
+
const repoApi = `https://gitee.com/api/v5/repos/${encodePath(repo.path)}`
|
|
248
|
+
const withToken = (url) => {
|
|
249
|
+
requireToken('gitee', token)
|
|
250
|
+
url.searchParams.set('access_token', token)
|
|
251
|
+
return url.toString()
|
|
252
|
+
}
|
|
253
|
+
const jsonHeaders = { 'Content-Type': 'application/json;charset=UTF-8' }
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
name: 'gitee',
|
|
257
|
+
label: 'Gitee',
|
|
258
|
+
prNoun: 'PR',
|
|
259
|
+
prRef: (number) => `refs/pull/${number}/head`,
|
|
260
|
+
urls: {
|
|
261
|
+
repo: () => repo.webUrl,
|
|
262
|
+
branch: (branch) => `${repo.webUrl}/tree/${encodePath(branch)}`,
|
|
263
|
+
commit: (sha) => `${repo.webUrl}/commit/${sha}`,
|
|
264
|
+
file: (ref, file, line) => `${repo.webUrl}/blob/${encodePath(ref)}/${encodePath(file)}${line ? `#L${line}` : ''}`,
|
|
265
|
+
pr: (number) => `${repo.webUrl}/pulls/${number}`,
|
|
266
|
+
prs: () => `${repo.webUrl}/pulls`,
|
|
267
|
+
newPr: (base, head) => `${repo.webUrl}/compare/${encodePath(base)}...${encodePath(head)}`,
|
|
268
|
+
compare: (base, head) => `${repo.webUrl}/compare/${encodePath(base)}...${encodePath(head)}`,
|
|
269
|
+
releases: () => `${repo.webUrl}/releases`,
|
|
270
|
+
pipelines: () => `${repo.webUrl}/gitee_go/pipelines`
|
|
271
|
+
},
|
|
272
|
+
async listPullRequests(filter = {}) {
|
|
273
|
+
const url = new URL(`${repoApi}/pulls`)
|
|
274
|
+
url.searchParams.set('state', filter.state || 'open')
|
|
275
|
+
url.searchParams.set('per_page', String(filter.limit || 30))
|
|
276
|
+
if (filter.head) {
|
|
277
|
+
url.searchParams.set('head', filter.head)
|
|
278
|
+
}
|
|
279
|
+
if (filter.base) {
|
|
280
|
+
url.searchParams.set('base', filter.base)
|
|
281
|
+
}
|
|
282
|
+
const result = await requestJson(withToken(url), {}, 'Gitee 读取 PR 列表失败')
|
|
283
|
+
assertResponseOk(result, 'Gitee 读取 PR 列表失败')
|
|
284
|
+
return (Array.isArray(result.payload) ? result.payload : []).map(normalizeGiteePr)
|
|
285
|
+
},
|
|
286
|
+
async getPullRequest(number) {
|
|
287
|
+
const result = await requestJson(withToken(new URL(`${repoApi}/pulls/${number}`)), {}, 'Gitee 读取 PR 失败')
|
|
288
|
+
assertResponseOk(result, `Gitee 读取 PR #${number} 失败`)
|
|
289
|
+
return normalizeGiteePr(result.payload)
|
|
290
|
+
},
|
|
291
|
+
async createPullRequest(input) {
|
|
292
|
+
requireToken('gitee', token)
|
|
293
|
+
const result = await requestJson(`${repoApi}/pulls`, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: jsonHeaders,
|
|
296
|
+
body: JSON.stringify({ access_token: token, title: input.title, head: input.head, base: input.base, body: input.body || '', draft: Boolean(input.draft) })
|
|
297
|
+
}, 'Gitee 创建 PR 失败')
|
|
298
|
+
if (!result.ok && /已存在|already exists/i.test(result.reason)) {
|
|
299
|
+
const [existing] = await this.listPullRequests({ head: input.head, base: input.base })
|
|
300
|
+
if (existing) {
|
|
301
|
+
return { ...existing, existed: true }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
assertResponseOk(result, 'Gitee 创建 PR 失败')
|
|
305
|
+
return normalizeGiteePr(result.payload)
|
|
306
|
+
},
|
|
307
|
+
async mergePullRequest(number, options = {}) {
|
|
308
|
+
requireToken('gitee', token)
|
|
309
|
+
const result = await requestJson(`${repoApi}/pulls/${number}/merge`, {
|
|
310
|
+
method: 'PUT',
|
|
311
|
+
headers: jsonHeaders,
|
|
312
|
+
body: JSON.stringify({ access_token: token, merge_method: options.method || 'merge' })
|
|
313
|
+
}, 'Gitee 合并 PR 失败')
|
|
314
|
+
return {
|
|
315
|
+
ok: result.ok,
|
|
316
|
+
conflict: result.status === 405 || result.status === 409,
|
|
317
|
+
status: result.status,
|
|
318
|
+
reason: result.ok ? '已合并' : result.reason
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
async createRelease(input) {
|
|
322
|
+
requireToken('gitee', token)
|
|
323
|
+
const result = await requestJson(`${repoApi}/releases`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: jsonHeaders,
|
|
326
|
+
body: JSON.stringify({
|
|
327
|
+
access_token: token,
|
|
328
|
+
tag_name: input.tag,
|
|
329
|
+
name: input.name || input.tag,
|
|
330
|
+
body: input.body || input.tag,
|
|
331
|
+
prerelease: Boolean(input.prerelease),
|
|
332
|
+
target_commitish: input.target || 'HEAD'
|
|
333
|
+
})
|
|
334
|
+
}, 'Gitee 创建 Release 失败')
|
|
335
|
+
assertResponseOk(result, 'Gitee 创建 Release 失败')
|
|
336
|
+
return { url: result.payload?.html_url || `${repo.webUrl}/releases/tag/${encodeURIComponent(input.tag)}` }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------- GitLab ----------------
|
|
342
|
+
|
|
343
|
+
function normalizeGitLabMr(item = {}) {
|
|
344
|
+
return {
|
|
345
|
+
number: item.iid,
|
|
346
|
+
title: item.title || '',
|
|
347
|
+
state: item.state === 'opened' ? 'open' : item.state || '',
|
|
348
|
+
url: item.web_url || '',
|
|
349
|
+
head: item.source_branch || '',
|
|
350
|
+
base: item.target_branch || '',
|
|
351
|
+
author: item.author?.username || '',
|
|
352
|
+
draft: Boolean(item.draft || item.work_in_progress),
|
|
353
|
+
merged: item.state === 'merged',
|
|
354
|
+
mergeable: item.has_conflicts === true ? false : item.detailed_merge_status === 'mergeable' ? true : null,
|
|
355
|
+
conflict: item.has_conflicts === true
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function createGitLabProvider(repo, token) {
|
|
360
|
+
const projectApi = `${repo.protocol}//${repo.host}/api/v4/projects/${encodeURIComponent(repo.path)}`
|
|
361
|
+
const headers = () => {
|
|
362
|
+
requireToken('gitlab', token)
|
|
363
|
+
return { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' }
|
|
364
|
+
}
|
|
365
|
+
const stateMap = { open: 'opened', closed: 'closed', merged: 'merged', all: 'all' }
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
name: 'gitlab',
|
|
369
|
+
label: 'GitLab',
|
|
370
|
+
prNoun: 'MR',
|
|
371
|
+
prRef: (number) => `refs/merge-requests/${number}/head`,
|
|
372
|
+
urls: {
|
|
373
|
+
repo: () => repo.webUrl,
|
|
374
|
+
branch: (branch) => `${repo.webUrl}/-/tree/${encodePath(branch)}`,
|
|
375
|
+
commit: (sha) => `${repo.webUrl}/-/commit/${sha}`,
|
|
376
|
+
file: (ref, file, line) => `${repo.webUrl}/-/blob/${encodePath(ref)}/${encodePath(file)}${line ? `#L${line}` : ''}`,
|
|
377
|
+
pr: (number) => `${repo.webUrl}/-/merge_requests/${number}`,
|
|
378
|
+
prs: () => `${repo.webUrl}/-/merge_requests`,
|
|
379
|
+
newPr: (base, head) => `${repo.webUrl}/-/merge_requests/new?${new URLSearchParams({
|
|
380
|
+
'merge_request[source_branch]': head,
|
|
381
|
+
'merge_request[target_branch]': base
|
|
382
|
+
}).toString()}`,
|
|
383
|
+
compare: (base, head) => `${repo.webUrl}/-/compare/${encodePath(base)}...${encodePath(head)}`,
|
|
384
|
+
releases: () => `${repo.webUrl}/-/releases`,
|
|
385
|
+
pipelines: () => `${repo.webUrl}/-/pipelines`
|
|
386
|
+
},
|
|
387
|
+
async listPullRequests(filter = {}) {
|
|
388
|
+
const url = new URL(`${projectApi}/merge_requests`)
|
|
389
|
+
url.searchParams.set('state', stateMap[filter.state || 'open'] || 'opened')
|
|
390
|
+
url.searchParams.set('per_page', String(filter.limit || 30))
|
|
391
|
+
if (filter.head) {
|
|
392
|
+
url.searchParams.set('source_branch', filter.head)
|
|
393
|
+
}
|
|
394
|
+
if (filter.base) {
|
|
395
|
+
url.searchParams.set('target_branch', filter.base)
|
|
396
|
+
}
|
|
397
|
+
const result = await requestJson(url.toString(), { headers: headers() }, 'GitLab 读取 MR 列表失败')
|
|
398
|
+
assertResponseOk(result, 'GitLab 读取 MR 列表失败')
|
|
399
|
+
return (Array.isArray(result.payload) ? result.payload : []).map(normalizeGitLabMr)
|
|
400
|
+
},
|
|
401
|
+
async getPullRequest(number) {
|
|
402
|
+
const result = await requestJson(`${projectApi}/merge_requests/${number}`, { headers: headers() }, 'GitLab 读取 MR 失败')
|
|
403
|
+
assertResponseOk(result, `GitLab 读取 MR !${number} 失败`)
|
|
404
|
+
return normalizeGitLabMr(result.payload)
|
|
405
|
+
},
|
|
406
|
+
async createPullRequest(input) {
|
|
407
|
+
const title = input.draft ? `Draft: ${input.title}` : input.title
|
|
408
|
+
const result = await requestJson(`${projectApi}/merge_requests`, {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers: headers(),
|
|
411
|
+
body: JSON.stringify({ source_branch: input.head, target_branch: input.base, title, description: input.body || '' })
|
|
412
|
+
}, 'GitLab 创建 MR 失败')
|
|
413
|
+
if (!result.ok && result.status === 409) {
|
|
414
|
+
const [existing] = await this.listPullRequests({ head: input.head, base: input.base })
|
|
415
|
+
if (existing) {
|
|
416
|
+
return { ...existing, existed: true }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
assertResponseOk(result, 'GitLab 创建 MR 失败')
|
|
420
|
+
return normalizeGitLabMr(result.payload)
|
|
421
|
+
},
|
|
422
|
+
async mergePullRequest(number) {
|
|
423
|
+
const result = await requestJson(`${projectApi}/merge_requests/${number}/merge`, {
|
|
424
|
+
method: 'PUT',
|
|
425
|
+
headers: headers(),
|
|
426
|
+
body: JSON.stringify({})
|
|
427
|
+
}, 'GitLab 合并 MR 失败')
|
|
428
|
+
return {
|
|
429
|
+
ok: result.ok,
|
|
430
|
+
conflict: result.status === 406 || result.status === 409,
|
|
431
|
+
status: result.status,
|
|
432
|
+
reason: result.ok ? '已合并' : result.reason
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
async createRelease(input) {
|
|
436
|
+
const result = await requestJson(`${projectApi}/releases`, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: headers(),
|
|
439
|
+
body: JSON.stringify({ tag_name: input.tag, name: input.name || input.tag, description: input.body || '' })
|
|
440
|
+
}, 'GitLab 创建 Release 失败')
|
|
441
|
+
assertResponseOk(result, 'GitLab 创建 Release 失败')
|
|
442
|
+
return { url: `${repo.webUrl}/-/releases/${encodeURIComponent(input.tag)}` }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export const PLATFORM_PROVIDERS = {
|
|
448
|
+
github: createGitHubProvider,
|
|
449
|
+
gitlab: createGitLabProvider,
|
|
450
|
+
gitee: createGiteeProvider,
|
|
451
|
+
gitea: createGiteaProvider
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export const SUPPORTED_PLATFORM_NAMES = Object.keys(PLATFORM_PROVIDERS)
|