✨ 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
|
subject: string
|
||||||
author: string
|
author: string
|
||||||
date: string
|
date: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseCommitGroup {
|
||||||
|
pr?: number
|
||||||
|
title: string
|
||||||
|
authors: string[]
|
||||||
|
commits: UnreleasedCommit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReleaseError extends Error {
|
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)
|
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[]> {
|
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}`], '读取远端发布分支失败')
|
const out = await mustGit(cwd, ['for-each-ref', '--format=%(refname:strip=3)', `refs/remotes/origin/${prefix}`], '读取远端发布分支失败')
|
||||||
return out ? out.split('\n') : []
|
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[]> {
|
export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise<UnreleasedCommit[]> {
|
||||||
const out = await mustGit(
|
const out = await mustGit(
|
||||||
o.cwd,
|
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)
|
if (!out)
|
||||||
return []
|
return []
|
||||||
return out.split('\n').map((line) => {
|
return out.split('\x1e').flatMap((chunk) => {
|
||||||
const [sha, subject, author, date] = line.split('\0')
|
const trimmed = chunk.replace(/^\n+/, '').trimEnd()
|
||||||
return { sha, subject, author, date }
|
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)
|
void release.handleReleaseCherryPick(this)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'release/cherry-pick-confirm') {
|
||||||
|
void release.handleReleaseCherryPickConfirm(this, msg.shas)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (msg.type === 'release/deploy') {
|
if (msg.type === 'release/deploy') {
|
||||||
void release.handleReleaseDeploy(this)
|
void release.handleReleaseDeploy(this)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
cherryPickToRelease,
|
cherryPickToRelease,
|
||||||
cutRelease,
|
cutRelease,
|
||||||
getReleaseStatus,
|
getReleaseStatus,
|
||||||
|
groupCommitsByPr,
|
||||||
listReleaseBranches,
|
listReleaseBranches,
|
||||||
listUnreleasedCommits,
|
listUnreleasedCommits,
|
||||||
todayReleaseBranch,
|
todayReleaseBranch,
|
||||||
@@ -85,10 +86,8 @@ export function handleReleaseCut(panel: KanbanWebviewPanel): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommitPickItem { label: string, description: string, sha: string }
|
export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise<void> {
|
||||||
|
try {
|
||||||
export function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promise<void> {
|
|
||||||
return runReleaseAction(panel, async () => {
|
|
||||||
const root = requireRoot()
|
const root = requireRoot()
|
||||||
const s = getSettings(panel.context)
|
const s = getSettings(panel.context)
|
||||||
const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
|
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', '没有待摘的提交')
|
toast(panel, 'info', '没有待摘的提交')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const picked = await pickCommits(current, commits.map(c => ({
|
panel.postMessage({ type: 'release/pick-show', branch: current, groups: groupCommitsByPr(commits) })
|
||||||
label: c.subject,
|
}
|
||||||
description: `${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`,
|
catch (err) {
|
||||||
sha: c.sha,
|
toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 })
|
||||||
})))
|
}
|
||||||
if (!picked || picked.length === 0)
|
}
|
||||||
|
|
||||||
|
export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas: string[]): Promise<void> {
|
||||||
|
return runReleaseAction(panel, async () => {
|
||||||
|
if (shas.length === 0)
|
||||||
return
|
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 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 ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0))
|
||||||
const spinner = toast(panel, 'info', `正在摘 ${shas.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 })
|
const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 })
|
||||||
try {
|
try {
|
||||||
await cherryPickToRelease({ cwd: root, branch: current, shas })
|
await cherryPickToRelease({ cwd: root, branch: current, shas: ordered })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
dismiss(panel, spinner)
|
dismiss(panel, spinner)
|
||||||
}
|
}
|
||||||
toast(panel, 'success', `已摘 ${shas.length} 个提交到 ${current}`)
|
toast(panel, 'success', `已摘 ${ordered.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()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReleaseCommitGroup } from '../git/release'
|
||||||
import type { Issue, IssueColumn } from '../gitea/types'
|
import type { Issue, IssueColumn } from '../gitea/types'
|
||||||
import type { LogEntry } from '../logging/logger'
|
import type { LogEntry } from '../logging/logger'
|
||||||
import type { ProfilesData } from '../profiles/store'
|
import type { ProfilesData } from '../profiles/store'
|
||||||
@@ -122,6 +123,7 @@ export type ExtensionToWebview
|
|||||||
running: boolean
|
running: boolean
|
||||||
jenkinsConfigured: boolean
|
jenkinsConfigured: boolean
|
||||||
}
|
}
|
||||||
|
| { type: 'release/pick-show', branch: string, groups: ReleaseCommitGroup[] }
|
||||||
| { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number }
|
| { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number }
|
||||||
| { type: 'issue/remove', issueNumber: number }
|
| { type: 'issue/remove', issueNumber: number }
|
||||||
| { type: 'profiles/show', data: ProfilesData }
|
| { type: 'profiles/show', data: ProfilesData }
|
||||||
@@ -210,6 +212,7 @@ export type WebviewToExtension
|
|||||||
| { type: 'release/check' }
|
| { type: 'release/check' }
|
||||||
| { type: 'release/cut' }
|
| { type: 'release/cut' }
|
||||||
| { type: 'release/cherry-pick' }
|
| { type: 'release/cherry-pick' }
|
||||||
|
| { type: 'release/cherry-pick-confirm', shas: string[] }
|
||||||
| { type: 'release/deploy' }
|
| { type: 'release/deploy' }
|
||||||
| { type: 'env-lock/check' }
|
| { type: 'env-lock/check' }
|
||||||
| { type: 'env-lock/toggle' }
|
| { type: 'env-lock/toggle' }
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
normalizeReleasePrefix,
|
normalizeReleasePrefix,
|
||||||
pickCurrentRelease,
|
pickCurrentRelease,
|
||||||
todayReleaseBranch,
|
todayReleaseBranch,
|
||||||
|
parsePrNumber,
|
||||||
|
groupCommitsByPr,
|
||||||
} from '../../src/git/release'
|
} from '../../src/git/release'
|
||||||
|
|
||||||
const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }
|
const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }
|
||||||
@@ -56,6 +58,38 @@ describe('纯函数', () => {
|
|||||||
expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31')
|
expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31')
|
||||||
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
|
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
it('parsePrNumber 认 Closes/Fixes/Resolves 和 (#N)', () => {
|
||||||
|
expect(parsePrNumber('feat: x (#390)')).toBe(390)
|
||||||
|
expect(parsePrNumber('fix: y\n\nCloses #12')).toBe(12)
|
||||||
|
expect(parsePrNumber('fix: y\n\nFixes #3')).toBe(3)
|
||||||
|
expect(parsePrNumber('fix: y\n\nResolves #9')).toBe(9)
|
||||||
|
expect(parsePrNumber('feat: no pr')).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('groupCommitsByPr 同 PR 收一组,无 PR 进未关联,保持新→旧', () => {
|
||||||
|
const groups = groupCommitsByPr([
|
||||||
|
{ sha: 'a', subject: 'feat: later (#2)', author: 'me', date: '2026-09-01', body: '' },
|
||||||
|
{ sha: 'b', subject: 'fix: earlier', author: 'you', date: '2026-08-31', body: 'Closes #2' },
|
||||||
|
{ sha: 'c', subject: 'chore: lone', author: 'you', date: '2026-08-30', body: '' },
|
||||||
|
])
|
||||||
|
expect(groups).toEqual([
|
||||||
|
{
|
||||||
|
pr: 2,
|
||||||
|
title: 'feat: later',
|
||||||
|
authors: ['me', 'you'],
|
||||||
|
commits: [
|
||||||
|
{ sha: 'a', subject: 'feat: later (#2)', author: 'me', date: '2026-09-01', body: '' },
|
||||||
|
{ sha: 'b', subject: 'fix: earlier', author: 'you', date: '2026-08-31', body: 'Closes #2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '未关联 PR',
|
||||||
|
authors: ['you'],
|
||||||
|
commits: [
|
||||||
|
{ sha: 'c', subject: 'chore: lone', author: 'you', date: '2026-08-30', body: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('集成', () => {
|
describe('集成', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { PastedImage } from './components/NewIssueModal'
|
|||||||
import type { Issue } from './types'
|
import type { Issue } from './types'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BottomTabs } from './components/BottomTabs'
|
import { BottomTabs } from './components/BottomTabs'
|
||||||
|
import { CherryPickModal } from './components/CherryPickModal'
|
||||||
import { HandoffModal } from './components/HandoffModal'
|
import { HandoffModal } from './components/HandoffModal'
|
||||||
import { KanbanBoard } from './components/KanbanBoard'
|
import { KanbanBoard } from './components/KanbanBoard'
|
||||||
import { LogModal } from './components/LogModal'
|
import { LogModal } from './components/LogModal'
|
||||||
@@ -69,6 +70,9 @@ export function App() {
|
|||||||
hasChanges,
|
hasChanges,
|
||||||
release,
|
release,
|
||||||
runRelease,
|
runRelease,
|
||||||
|
cherryPick,
|
||||||
|
dismissCherryPick,
|
||||||
|
confirmCherryPick,
|
||||||
envLocked,
|
envLocked,
|
||||||
envFileCount,
|
envFileCount,
|
||||||
envLockRunning,
|
envLockRunning,
|
||||||
@@ -183,6 +187,8 @@ export function App() {
|
|||||||
return
|
return
|
||||||
if (showLogs)
|
if (showLogs)
|
||||||
return
|
return
|
||||||
|
if (cherryPick !== null)
|
||||||
|
return
|
||||||
if (settingsOpen)
|
if (settingsOpen)
|
||||||
return
|
return
|
||||||
if (handoffIssue !== null || handoffSession !== null)
|
if (handoffIssue !== null || handoffSession !== null)
|
||||||
@@ -285,7 +291,7 @@ export function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [state.status, showNewIssueModal, showLogs, settingsOpen, ordered, selectedId, selectedIssue, resumeSession])
|
}, [state.status, showNewIssueModal, showLogs, settingsOpen, cherryPick, ordered, selectedId, selectedIssue, resumeSession])
|
||||||
|
|
||||||
// When the user is being forced into first-time setup, the kanban behind
|
// When the user is being forced into first-time setup, the kanban behind
|
||||||
// the modal has nothing meaningful to show — replace the spinner with a
|
// the modal has nothing meaningful to show — replace the spinner with a
|
||||||
@@ -491,6 +497,13 @@ export function App() {
|
|||||||
setHandoffSession(null)
|
setHandoffSession(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<CherryPickModal
|
||||||
|
open={cherryPick !== null}
|
||||||
|
branch={cherryPick?.branch ?? ''}
|
||||||
|
groups={cherryPick?.groups ?? []}
|
||||||
|
onCancel={dismissCherryPick}
|
||||||
|
onConfirm={confirmCherryPick}
|
||||||
|
/>
|
||||||
<LogModal
|
<LogModal
|
||||||
open={showLogs}
|
open={showLogs}
|
||||||
entries={logs}
|
entries={logs}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { ReleaseCommitGroup } from '../lib/messages'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
branch: string
|
||||||
|
groups: ReleaseCommitGroup[]
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: (shas: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommitGroup[] {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q)
|
||||||
|
return groups
|
||||||
|
const out: ReleaseCommitGroup[] = []
|
||||||
|
for (const g of groups) {
|
||||||
|
const head = `#${g.pr ?? ''} ${g.title} ${g.authors.join(' ')}`.toLowerCase()
|
||||||
|
if (head.includes(q)) {
|
||||||
|
out.push(g)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const commits = g.commits.filter(c =>
|
||||||
|
`${c.subject} ${c.author} ${c.sha} ${c.date}`.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
if (commits.length > 0)
|
||||||
|
out.push({ ...g, commits })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: Props) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open)
|
||||||
|
return
|
||||||
|
setQuery('')
|
||||||
|
setSelected(new Set())
|
||||||
|
}, [open, groups])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open)
|
||||||
|
return
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape')
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onCancel])
|
||||||
|
|
||||||
|
const visible = useMemo(() => filterGroups(groups, query), [groups, query])
|
||||||
|
|
||||||
|
if (!open)
|
||||||
|
return null
|
||||||
|
|
||||||
|
function toggleSha(sha: string): void {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(sha))
|
||||||
|
next.delete(sha)
|
||||||
|
else
|
||||||
|
next.add(sha)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(g: ReleaseCommitGroup): void {
|
||||||
|
const shas = g.commits.map(c => c.sha)
|
||||||
|
const allOn = shas.every(s => selected.has(s))
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const s of shas) {
|
||||||
|
if (allOn)
|
||||||
|
next.delete(s)
|
||||||
|
else
|
||||||
|
next.add(s)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = selected.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="cherry-pick-title"
|
||||||
|
className="flex max-h-[80vh] w-[min(640px,calc(100vw-2rem))] flex-col rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] text-sm shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-[var(--vscode-panel-border)] px-4 py-3">
|
||||||
|
<h2 id="cherry-pick-title" className="flex-1 font-medium">
|
||||||
|
{`摘修复到 ${branch}`}
|
||||||
|
</h2>
|
||||||
|
<button type="button" onClick={onCancel} aria-label="关闭" className="rounded p-1 hover:bg-white/10">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pt-3">
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索作者、PR 号或标题"
|
||||||
|
className="w-full rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] px-2 py-1.5 text-xs outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-3">
|
||||||
|
{visible.length === 0
|
||||||
|
? <div className="py-8 text-center text-xs opacity-70">没有匹配的提交</div>
|
||||||
|
: visible.map(g => (
|
||||||
|
<section
|
||||||
|
key={g.pr ?? `ungrouped-${g.commits[0]?.sha}`}
|
||||||
|
className="rounded border border-[var(--vscode-panel-border)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGroup(g)}
|
||||||
|
className="flex w-full items-center gap-2 border-b border-[var(--vscode-panel-border)] px-3 py-2 text-left hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate font-medium">
|
||||||
|
{g.pr != null ? `#${g.pr} ${g.title}` : g.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[11px] opacity-60">
|
||||||
|
{`${g.authors.join(' · ')} · ${g.commits.length} 条`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<ul className="py-1">
|
||||||
|
{g.commits.map(c => (
|
||||||
|
<li key={c.sha}>
|
||||||
|
<label className="flex cursor-pointer items-start gap-2 px-3 py-1.5 hover:bg-white/5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(c.sha)}
|
||||||
|
onChange={() => toggleSha(c.sha)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate">{c.subject}</span>
|
||||||
|
<span className="block text-[11px] opacity-60">
|
||||||
|
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-[var(--vscode-panel-border)] px-4 py-3">
|
||||||
|
<span className="mr-auto text-xs opacity-70">
|
||||||
|
{`已选 ${count}`}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onCancel} className="rounded border border-[var(--vscode-panel-border)] px-3 py-1 text-xs hover:bg-white/10">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={count === 0}
|
||||||
|
onClick={() => onConfirm([...selected])}
|
||||||
|
className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
摘过去
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ToastItem } from '../components/ToastStack'
|
import type { ToastItem } from '../components/ToastStack'
|
||||||
import type { ExtensionToWebview, LogEntry } from '../lib/messages'
|
import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages'
|
||||||
import type { Issue, IssueColumn } from '../types'
|
import type { Issue, IssueColumn } from '../types'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||||
@@ -246,6 +246,10 @@ export interface UseIssuesResult {
|
|||||||
release: ReleaseStatus | undefined
|
release: ReleaseStatus | undefined
|
||||||
/** 发布按钮三个动作;running 由扩展侧状态决定,webview 不自置。 */
|
/** 发布按钮三个动作;running 由扩展侧状态决定,webview 不自置。 */
|
||||||
runRelease: (action: ReleaseAction) => void
|
runRelease: (action: ReleaseAction) => void
|
||||||
|
/** 摘修复选单:扩展推来分组后的未发布提交。 */
|
||||||
|
cherryPick: { branch: string, groups: ReleaseCommitGroup[] } | null
|
||||||
|
dismissCherryPick: () => void
|
||||||
|
confirmCherryPick: (shas: string[]) => void
|
||||||
/**
|
/**
|
||||||
* Whether the workspace's `.env*` files are currently chmod-locked
|
* Whether the workspace's `.env*` files are currently chmod-locked
|
||||||
* according to `workspaceState`. Persisted per-workspace.
|
* according to `workspaceState`. Persisted per-workspace.
|
||||||
@@ -336,6 +340,7 @@ export function useIssues(): UseIssuesResult {
|
|||||||
const [commitRunning, setCommitRunning] = useState<boolean>(false)
|
const [commitRunning, setCommitRunning] = useState<boolean>(false)
|
||||||
const [hasChanges, setHasChanges] = useState<boolean>(false)
|
const [hasChanges, setHasChanges] = useState<boolean>(false)
|
||||||
const [release, setRelease] = useState<ReleaseStatus>()
|
const [release, setRelease] = useState<ReleaseStatus>()
|
||||||
|
const [cherryPick, setCherryPick] = useState<{ branch: string, groups: ReleaseCommitGroup[] } | null>(null)
|
||||||
const [envLocked, setEnvLocked] = useState<boolean>(false)
|
const [envLocked, setEnvLocked] = useState<boolean>(false)
|
||||||
const [envFileCount, setEnvFileCount] = useState<number>(0)
|
const [envFileCount, setEnvFileCount] = useState<number>(0)
|
||||||
const [envLockRunning, setEnvLockRunning] = useState<boolean>(false)
|
const [envLockRunning, setEnvLockRunning] = useState<boolean>(false)
|
||||||
@@ -656,6 +661,15 @@ export function useIssues(): UseIssuesResult {
|
|||||||
postMessage({ type: `release/${action}` })
|
postMessage({ type: `release/${action}` })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const dismissCherryPick = useCallback((): void => {
|
||||||
|
setCherryPick(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const confirmCherryPick = useCallback((shas: string[]): void => {
|
||||||
|
setCherryPick(null)
|
||||||
|
postMessage({ type: 'release/cherry-pick-confirm', shas })
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleEnvLock = useCallback((): void => {
|
const toggleEnvLock = useCallback((): void => {
|
||||||
// Double-click guard — the next `env-lock/status` clears the running flag.
|
// Double-click guard — the next `env-lock/status` clears the running flag.
|
||||||
if (envLockRunning)
|
if (envLockRunning)
|
||||||
@@ -845,6 +859,9 @@ export function useIssues(): UseIssuesResult {
|
|||||||
case 'release/status':
|
case 'release/status':
|
||||||
setRelease(msg)
|
setRelease(msg)
|
||||||
break
|
break
|
||||||
|
case 'release/pick-show':
|
||||||
|
setCherryPick({ branch: msg.branch, groups: msg.groups })
|
||||||
|
break
|
||||||
case 'env-lock/status': {
|
case 'env-lock/status': {
|
||||||
setEnvLocked(msg.locked)
|
setEnvLocked(msg.locked)
|
||||||
setEnvFileCount(msg.fileCount)
|
setEnvFileCount(msg.fileCount)
|
||||||
@@ -873,5 +890,5 @@ export function useIssues(): UseIssuesResult {
|
|||||||
return cleanup
|
return cleanup
|
||||||
}, [clearPrDiffSummaryRunning, markHandoffRunning])
|
}, [clearPrDiffSummaryRunning, markHandoffRunning])
|
||||||
|
|
||||||
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
|
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,21 @@ export interface PrFile {
|
|||||||
previousFilename?: string
|
previousFilename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReleaseCommit {
|
||||||
|
sha: string
|
||||||
|
subject: string
|
||||||
|
author: string
|
||||||
|
date: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseCommitGroup {
|
||||||
|
pr?: number
|
||||||
|
title: string
|
||||||
|
authors: string[]
|
||||||
|
commits: ReleaseCommit[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ToastLevel = 'info' | 'success' | 'error'
|
export type ToastLevel = 'info' | 'success' | 'error'
|
||||||
|
|
||||||
export interface SessionHandoffPendingItem {
|
export interface SessionHandoffPendingItem {
|
||||||
@@ -137,6 +152,7 @@ export type ExtensionToWebview
|
|||||||
running: boolean
|
running: boolean
|
||||||
jenkinsConfigured: boolean
|
jenkinsConfigured: boolean
|
||||||
}
|
}
|
||||||
|
| { type: 'release/pick-show', branch: string, groups: ReleaseCommitGroup[] }
|
||||||
| { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number }
|
| { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number }
|
||||||
| { type: 'issue/remove', issueNumber: number }
|
| { type: 'issue/remove', issueNumber: number }
|
||||||
| { type: 'profiles/show', data: ProfilesData }
|
| { type: 'profiles/show', data: ProfilesData }
|
||||||
@@ -225,6 +241,7 @@ export type WebviewToExtension
|
|||||||
| { type: 'release/check' }
|
| { type: 'release/check' }
|
||||||
| { type: 'release/cut' }
|
| { type: 'release/cut' }
|
||||||
| { type: 'release/cherry-pick' }
|
| { type: 'release/cherry-pick' }
|
||||||
|
| { type: 'release/cherry-pick-confirm', shas: string[] }
|
||||||
| { type: 'release/deploy' }
|
| { type: 'release/deploy' }
|
||||||
| { type: 'env-lock/check' }
|
| { type: 'env-lock/check' }
|
||||||
| { type: 'env-lock/toggle' }
|
| { type: 'env-lock/toggle' }
|
||||||
|
|||||||
Reference in new issue
Block a user