✨ feat(vscode): 摘修复按 PR 分组框选,可搜作者和标题
This commit is contained in:
1 parent
0031851df3
commit
3e3750c494
9 files changed
+353
-42
No files matched your search
@@ -24,6 +24,14 @@ export interface UnreleasedCommit {
|
||||
subject: string
|
||||
author: string
|
||||
date: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ReleaseCommitGroup {
|
||||
pr?: number
|
||||
title: string
|
||||
authors: string[]
|
||||
commits: UnreleasedCommit[]
|
||||
}
|
||||
|
||||
export class ReleaseError extends Error {
|
||||
@@ -71,6 +79,53 @@ export function pickCurrentRelease(prefix: string, remoteBranches: string[]): st
|
||||
return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1)
|
||||
}
|
||||
|
||||
export function parsePrNumber(text: string): number | undefined {
|
||||
const close = /\b(?:closes|fixes|resolves)\s+#(\d+)/i.exec(text)
|
||||
if (close)
|
||||
return Number(close[1])
|
||||
const paren = /\(#(\d+)\)/.exec(text)
|
||||
if (paren)
|
||||
return Number(paren[1])
|
||||
return undefined
|
||||
}
|
||||
|
||||
function stripPrFromSubject(subject: string): string {
|
||||
return subject.replace(/\s*\(#\d+\)\s*$/, '').trim()
|
||||
}
|
||||
|
||||
/** 同 PR 收一组(新→旧首次出现顺序),认不出 PR 的进「未关联 PR」。 */
|
||||
export function groupCommitsByPr(commits: UnreleasedCommit[]): ReleaseCommitGroup[] {
|
||||
const prGroups = new Map<number, ReleaseCommitGroup>()
|
||||
const prOrder: number[] = []
|
||||
const ungrouped: UnreleasedCommit[] = []
|
||||
for (const c of commits) {
|
||||
const pr = parsePrNumber(`${c.subject}\n${c.body}`)
|
||||
if (pr === undefined) {
|
||||
ungrouped.push(c)
|
||||
continue
|
||||
}
|
||||
let g = prGroups.get(pr)
|
||||
if (!g) {
|
||||
g = { pr, title: stripPrFromSubject(c.subject), authors: [], commits: [] }
|
||||
prGroups.set(pr, g)
|
||||
prOrder.push(pr)
|
||||
}
|
||||
g.commits.push(c)
|
||||
if (!g.authors.includes(c.author))
|
||||
g.authors.push(c.author)
|
||||
}
|
||||
const out = prOrder.map(n => prGroups.get(n)!)
|
||||
if (ungrouped.length > 0) {
|
||||
const authors: string[] = []
|
||||
for (const c of ungrouped) {
|
||||
if (!authors.includes(c.author))
|
||||
authors.push(c.author)
|
||||
}
|
||||
out.push({ title: '未关联 PR', authors, commits: ungrouped })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function remoteBranchNames(cwd: string, prefix: string): Promise<string[]> {
|
||||
const out = await mustGit(cwd, ['for-each-ref', '--format=%(refname:strip=3)', `refs/remotes/origin/${prefix}`], '读取远端发布分支失败')
|
||||
return out ? out.split('\n') : []
|
||||
@@ -110,14 +165,17 @@ export async function listReleaseBranches(o: { cwd: string, prefix: string }): P
|
||||
export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise<UnreleasedCommit[]> {
|
||||
const out = await mustGit(
|
||||
o.cwd,
|
||||
['log', '--right-only', '--cherry-pick', '--no-merges', '--format=%H%x00%s%x00%an%x00%as', unreleasedRange(o.current, o.devBranch)],
|
||||
['log', '--right-only', '--cherry-pick', '--no-merges', '--format=%H%x00%s%x00%an%x00%as%x00%b%x1e', unreleasedRange(o.current, o.devBranch)],
|
||||
'读取未发布提交失败',
|
||||
)
|
||||
if (!out)
|
||||
return []
|
||||
return out.split('\n').map((line) => {
|
||||
const [sha, subject, author, date] = line.split('\0')
|
||||
return { sha, subject, author, date }
|
||||
return out.split('\x1e').flatMap((chunk) => {
|
||||
const trimmed = chunk.replace(/^\n+/, '').trimEnd()
|
||||
if (!trimmed)
|
||||
return []
|
||||
const [sha, subject, author, date, body = ''] = trimmed.split('\0')
|
||||
return [{ sha, subject, author, date, body: body.trim() }]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -538,6 +538,10 @@ export class KanbanWebviewPanel {
|
||||
void release.handleReleaseCherryPick(this)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'release/cherry-pick-confirm') {
|
||||
void release.handleReleaseCherryPickConfirm(this, msg.shas)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'release/deploy') {
|
||||
void release.handleReleaseDeploy(this)
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
cherryPickToRelease,
|
||||
cutRelease,
|
||||
getReleaseStatus,
|
||||
groupCommitsByPr,
|
||||
listReleaseBranches,
|
||||
listUnreleasedCommits,
|
||||
todayReleaseBranch,
|
||||
@@ -85,10 +86,8 @@ export function handleReleaseCut(panel: KanbanWebviewPanel): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
interface CommitPickItem { label: string, description: string, sha: string }
|
||||
|
||||
export function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise<void> {
|
||||
return runReleaseAction(panel, async () => {
|
||||
export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise<void> {
|
||||
try {
|
||||
const root = requireRoot()
|
||||
const s = getSettings(panel.context)
|
||||
const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
|
||||
@@ -102,44 +101,36 @@ export function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise<void
|
||||
toast(panel, 'info', '没有待摘的提交')
|
||||
return
|
||||
}
|
||||
const picked = await pickCommits(current, commits.map(c => ({
|
||||
label: c.subject,
|
||||
description: `${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`,
|
||||
sha: c.sha,
|
||||
})))
|
||||
if (!picked || picked.length === 0)
|
||||
panel.postMessage({ type: 'release/pick-show', branch: current, groups: groupCommitsByPr(commits) })
|
||||
}
|
||||
catch (err) {
|
||||
toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 })
|
||||
}
|
||||
}
|
||||
|
||||
export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas: string[]): Promise<void> {
|
||||
return runReleaseAction(panel, async () => {
|
||||
if (shas.length === 0)
|
||||
return
|
||||
// 列表是 git log 顺序(新→旧),cherry-pick 要按时间顺序(旧→新)
|
||||
const root = requireRoot()
|
||||
const s = getSettings(panel.context)
|
||||
const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
|
||||
if (status.unavailable)
|
||||
throw new Error(status.unavailable)
|
||||
const current = status.current
|
||||
if (!current)
|
||||
throw new Error('尚无发布分支,先切新发布')
|
||||
const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current })
|
||||
const order = new Map(commits.map((c, i) => [c.sha, i]))
|
||||
const shas = [...picked].sort((a, b) => (order.get(b.sha) ?? 0) - (order.get(a.sha) ?? 0)).map(p => p.sha)
|
||||
const spinner = toast(panel, 'info', `正在摘 ${shas.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 })
|
||||
const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0))
|
||||
const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 })
|
||||
try {
|
||||
await cherryPickToRelease({ cwd: root, branch: current, shas })
|
||||
await cherryPickToRelease({ cwd: root, branch: current, shas: ordered })
|
||||
}
|
||||
finally {
|
||||
dismiss(panel, spinner)
|
||||
}
|
||||
toast(panel, 'success', `已摘 ${shas.length} 个提交到 ${current}`)
|
||||
})
|
||||
}
|
||||
|
||||
/** 多选 QuickPick;用户按 Esc 返回 undefined */
|
||||
function pickCommits(current: string, items: CommitPickItem[]): Promise<CommitPickItem[] | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const qp = window.createQuickPick<CommitPickItem>()
|
||||
qp.canSelectMany = true
|
||||
qp.title = `摘修复到 ${current}`
|
||||
qp.placeholder = '勾选要摘到发布分支的提交'
|
||||
qp.items = items
|
||||
qp.onDidAccept(() => {
|
||||
resolve([...qp.selectedItems])
|
||||
qp.hide()
|
||||
})
|
||||
qp.onDidHide(() => {
|
||||
resolve(undefined)
|
||||
qp.dispose()
|
||||
})
|
||||
qp.show()
|
||||
toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${current}`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReleaseCommitGroup } from '../git/release'
|
||||
import type { Issue, IssueColumn } from '../gitea/types'
|
||||
import type { LogEntry } from '../logging/logger'
|
||||
import type { ProfilesData } from '../profiles/store'
|
||||
@@ -122,6 +123,7 @@ export type ExtensionToWebview
|
||||
running: boolean
|
||||
jenkinsConfigured: boolean
|
||||
}
|
||||
| { type: 'release/pick-show', branch: string, groups: ReleaseCommitGroup[] }
|
||||
| { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number }
|
||||
| { type: 'issue/remove', issueNumber: number }
|
||||
| { type: 'profiles/show', data: ProfilesData }
|
||||
@@ -210,6 +212,7 @@ export type WebviewToExtension
|
||||
| { type: 'release/check' }
|
||||
| { type: 'release/cut' }
|
||||
| { type: 'release/cherry-pick' }
|
||||
| { type: 'release/cherry-pick-confirm', shas: string[] }
|
||||
| { type: 'release/deploy' }
|
||||
| { type: 'env-lock/check' }
|
||||
| { type: 'env-lock/toggle' }
|
||||
|
||||
Reference in new issue
Block a user