✨ feat(vscode): 看板默认显示团队工单,同事的卡只读并贴头像
看板范围默认从「只看我的」改为「团队全部」,同事移交或推进的工单不用 手动切开关就能看见。同事的卡片按 Gitea 归属(assignee 优先,无 assignee 退回创建者)判定为只读:禁止拖列、开会话、实施、合并分支、删 worktree、 删改工单,卡片右上角贴归属人头像,详情面板属性表整体消毒并显示只读徽章。 看 PR 改动、勾已查看、生成文件摘要属于本机审查动作,照常放行。 扩展侧在消息分发前统一拦截需要归属人身份的消息,兜住前端快照过期或工单 刚被重新指派的竞态;webhook 单卡上板时同步归属,避免增量卡漏进门禁。 头像由扩展侧带 token 抓回转 data URI 下发,绕开 webview CSP 对外链图片 的限制,按 URL 进程内缓存。 Claude-Session: https://claude.ai/code/session_01KSgcQwf1mWrb8qeTKFvJbV
This commit is contained in:
1 parent
e8963ac2a2
commit
9167b86708
26 files changed
+550
-34
No files matched your search
@@ -27,6 +27,7 @@ export function App() {
|
||||
profiles,
|
||||
scope,
|
||||
toggleScope,
|
||||
avatars,
|
||||
setIssues,
|
||||
refresh,
|
||||
saveSettings,
|
||||
@@ -357,6 +358,8 @@ export function App() {
|
||||
onColumnChange={changeColumn}
|
||||
onDependencySet={setDependency}
|
||||
onDependencyClear={clearDependency}
|
||||
me={me}
|
||||
avatars={avatars}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[40vh] shrink-0">
|
||||
@@ -388,6 +391,7 @@ export function App() {
|
||||
onUpdateTestProfilePath={updateIssueTestProfilePath}
|
||||
onOpenLogs={() => setShowLogs(true)}
|
||||
me={me}
|
||||
avatars={avatars}
|
||||
onStartHandoff={(n) => {
|
||||
setHandoffIssue(n)
|
||||
requestHandoffUsers(n)
|
||||
|
||||
@@ -51,6 +51,8 @@ interface BottomTabsProps {
|
||||
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||||
onOpenLogs: () => void
|
||||
me?: string
|
||||
/** Gitea login → 头像 data URI。 */
|
||||
avatars?: Record<string, string>
|
||||
onStartHandoff: (issueNumber: number) => void
|
||||
onAcceptHandoff: (issueNumber: number) => void
|
||||
isHandoffRunning: (issueNumber: number) => boolean
|
||||
@@ -147,6 +149,7 @@ export function BottomTabs(props: BottomTabsProps) {
|
||||
profiles={props.profiles}
|
||||
onOpenLogs={props.onOpenLogs}
|
||||
me={props.me}
|
||||
avatars={props.avatars}
|
||||
onStartHandoff={props.onStartHandoff}
|
||||
onAcceptHandoff={props.onAcceptHandoff}
|
||||
isHandoffRunning={props.isHandoffRunning}
|
||||
|
||||
@@ -20,12 +20,39 @@ interface IssueCardProps {
|
||||
locked?: boolean
|
||||
/** 锁定时关联的前置工单号,用于 tooltip 文案 "等待 #N 完成"。 */
|
||||
lockedPrerequisite?: number
|
||||
/** 只读态:同事的工单。禁止拖拽,并在右上角贴归属人头像。 */
|
||||
readOnly?: boolean
|
||||
/** 归属人 Gitea login(只读卡片才显示)。 */
|
||||
ownerLogin?: string
|
||||
/** 归属人头像 data URI;缺失时回退首字母圆点。 */
|
||||
ownerAvatar?: string
|
||||
}
|
||||
|
||||
/** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */
|
||||
function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) {
|
||||
return avatar
|
||||
? (
|
||||
<img
|
||||
src={avatar}
|
||||
alt={login}
|
||||
title={`${login} 的工单(只读)`}
|
||||
className="size-4 shrink-0 rounded-full ring-1 ring-[var(--vscode-panel-border)]"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
title={`${login} 的工单(只读)`}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-full bg-[var(--vscode-badge-background)] text-[8px] uppercase text-[var(--vscode-badge-foreground)]"
|
||||
>
|
||||
{login.slice(0, 1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
|
||||
({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite }, ref) => {
|
||||
({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar }, ref) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging }
|
||||
= useSortable({ id: issue.id, data: { column: issue.column } })
|
||||
= useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly })
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -52,23 +79,26 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
|
||||
onClick={() => onSelect?.(issue.id)}
|
||||
className={cn(
|
||||
'relative select-none px-3 py-2 text-xs',
|
||||
'cursor-grab active:cursor-grabbing',
|
||||
readOnly ? 'cursor-default' : 'cursor-grab active:cursor-grabbing',
|
||||
locked && 'opacity-75',
|
||||
selected
|
||||
&& 'ring-2 ring-[var(--vscode-focusBorder)] ring-offset-1 ring-offset-[var(--vscode-sideBar-background)]',
|
||||
)}
|
||||
>
|
||||
{locked
|
||||
? (
|
||||
<span
|
||||
className="absolute right-1.5 top-1.5 grid place-items-center text-[var(--vscode-disabledForeground)]"
|
||||
title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
|
||||
aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
|
||||
>
|
||||
<Lock className="size-3.5" />
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<div className="absolute right-1.5 top-1.5 flex items-center gap-1">
|
||||
{locked
|
||||
? (
|
||||
<span
|
||||
className="grid place-items-center text-[var(--vscode-disabledForeground)]"
|
||||
title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
|
||||
aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
|
||||
>
|
||||
<Lock className="size-3.5" />
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
{readOnly && ownerLogin ? <OwnerBadge login={ownerLogin} avatar={ownerAvatar} /> : null}
|
||||
</div>
|
||||
{issue.handoffAttachmentId
|
||||
? (
|
||||
<span
|
||||
@@ -79,7 +109,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<div className={cn('truncate', locked && 'pr-5')}>
|
||||
<div className={cn('truncate', (locked || (readOnly && ownerLogin)) && 'pr-5', locked && readOnly && ownerLogin && 'pr-10')}>
|
||||
<span className="font-mono opacity-60">
|
||||
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PropertyGroup, RowAction } from './property-grid'
|
||||
import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { isIssueLocked } from '../lib/dependencies'
|
||||
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
|
||||
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||
import { PropertyGrid } from './property-grid'
|
||||
|
||||
@@ -107,6 +109,8 @@ interface IssueDetailPanelProps {
|
||||
onStartHandoff: (issueNumber: number) => void
|
||||
onAcceptHandoff: (issueNumber: number) => void
|
||||
isHandoffRunning: (issueNumber: number) => boolean
|
||||
/** Gitea login → 头像 data URI;只读工单在标题旁贴归属人头像。 */
|
||||
avatars?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +153,7 @@ export function IssueDetailPanel({
|
||||
onStartHandoff,
|
||||
onAcceptHandoff,
|
||||
isHandoffRunning,
|
||||
avatars,
|
||||
}: IssueDetailPanelProps) {
|
||||
// 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮,
|
||||
// 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。
|
||||
@@ -520,6 +525,14 @@ export function IssueDetailPanel({
|
||||
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
|
||||
)
|
||||
|
||||
// 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。
|
||||
const readOnly = issue ? !isIssueMine(issue, me) : false
|
||||
const ownerLogin = issue ? issueOwnerLogin(issue) : undefined
|
||||
const effectiveSchema = useMemo(
|
||||
() => readOnly ? toReadOnlySchema(schema) : schema,
|
||||
[schema, readOnly],
|
||||
)
|
||||
|
||||
const data = useMemo<Record<string, unknown> | null>(() => {
|
||||
if (!issue)
|
||||
return null
|
||||
@@ -589,6 +602,20 @@ export function IssueDetailPanel({
|
||||
)
|
||||
: null}
|
||||
</h3>
|
||||
{readOnly && ownerLogin
|
||||
? (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-[11px] opacity-80"
|
||||
title={`${ownerLogin} 的工单,只能查看`}
|
||||
>
|
||||
{avatars?.[ownerLogin]
|
||||
? <img src={avatars[ownerLogin]} alt={ownerLogin} className="size-4 rounded-full" />
|
||||
: null}
|
||||
{ownerLogin}
|
||||
{' · 只读'}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||||
@@ -599,7 +626,7 @@ export function IssueDetailPanel({
|
||||
</button>
|
||||
{/* Close/delete write to gitea by number — hide for youtrack cards to
|
||||
avoid acting on a gitea issue of the same number. */}
|
||||
{issue.source !== 'youtrack' && (
|
||||
{issue.source !== 'youtrack' && !readOnly && (
|
||||
<>
|
||||
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
||||
<button
|
||||
@@ -691,11 +718,13 @@ export function IssueDetailPanel({
|
||||
<div className="min-h-0 flex-1">
|
||||
<PropertyGrid
|
||||
key={issue.number}
|
||||
schema={schema}
|
||||
schema={effectiveSchema}
|
||||
data={data}
|
||||
hideToolbar
|
||||
fillHeight
|
||||
onChange={(key, value) => {
|
||||
if (readOnly)
|
||||
return
|
||||
if (key === 'autoReview' && issue && typeof value === 'boolean')
|
||||
onUpdateAutoReview(issue.number, value)
|
||||
if (key === 'brainstormProfilePath' && issue && typeof value === 'string')
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isIssueMine, issueOwnerLogin } from '@/lib/issueOwnership'
|
||||
import { isIssueLocked } from '../lib/dependencies'
|
||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
|
||||
@@ -34,6 +35,10 @@ interface KanbanBoardProps {
|
||||
onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
|
||||
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
|
||||
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
|
||||
/** 当前 Gitea login;用于判定哪些卡是同事的(只读)。 */
|
||||
me?: string
|
||||
/** Gitea login → 头像 data URI。 */
|
||||
avatars?: Record<string, string>
|
||||
}
|
||||
|
||||
const kanbanCollisionDetection: CollisionDetection = (args) => {
|
||||
@@ -105,6 +110,8 @@ export function KanbanBoard({
|
||||
onColumnChange,
|
||||
onDependencySet,
|
||||
onDependencyClear,
|
||||
me,
|
||||
avatars,
|
||||
}: KanbanBoardProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
@@ -238,6 +245,8 @@ export function KanbanBoard({
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{ordered.map(({ issue, depth }) => {
|
||||
const { locked, prerequisiteNumber } = isIssueLocked(issue, issues)
|
||||
const readOnly = !isIssueMine(issue, me)
|
||||
const ownerLogin = issueOwnerLogin(issue)
|
||||
return (
|
||||
<IssueCard
|
||||
key={issue.id}
|
||||
@@ -253,6 +262,9 @@ export function KanbanBoard({
|
||||
onSelect={onSelectIssue}
|
||||
locked={locked}
|
||||
lockedPrerequisite={prerequisiteNumber}
|
||||
readOnly={readOnly}
|
||||
ownerLogin={ownerLogin}
|
||||
ownerAvatar={ownerLogin ? avatars?.[ownerLogin] : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -137,6 +137,8 @@ export interface UseIssuesResult {
|
||||
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部。 */
|
||||
scope: 'mine' | 'all'
|
||||
toggleScope: () => void
|
||||
/** Gitea login → 头像 data URI;同事工单的卡片按它贴头像。 */
|
||||
avatars: Record<string, string>
|
||||
/**
|
||||
* Latest known global `autoReview` value pushed alongside `issues/update`.
|
||||
* Used by the detail panel to display the fallback value when an issue has
|
||||
@@ -293,10 +295,15 @@ export interface UseIssuesResult {
|
||||
isHandoffRunning: (issueNumber: number) => boolean
|
||||
}
|
||||
|
||||
function isAvatarMap(raw: unknown): raw is Record<string, string> {
|
||||
return !!raw && typeof raw === 'object' && Object.values(raw).every(v => typeof v === 'string')
|
||||
}
|
||||
|
||||
function readInjectedSnapshot(): {
|
||||
issues: Issue[]
|
||||
scope: 'mine' | 'all'
|
||||
me?: string
|
||||
avatars?: Record<string, string>
|
||||
globalAutoReview: boolean
|
||||
youtrackConfigured: boolean
|
||||
} | null {
|
||||
@@ -316,6 +323,7 @@ function readInjectedSnapshot(): {
|
||||
issues: obj.issues as Issue[],
|
||||
scope: obj.scope,
|
||||
me: typeof obj.me === 'string' ? obj.me : undefined,
|
||||
avatars: isAvatarMap(obj.avatars) ? obj.avatars : undefined,
|
||||
globalAutoReview: obj.globalAutoReview === true,
|
||||
youtrackConfigured: obj.youtrackConfigured === true,
|
||||
}
|
||||
@@ -333,7 +341,8 @@ export function useIssues(): UseIssuesResult {
|
||||
const [settings, setSettings] = useState<SettingsOverlayState | null>(null)
|
||||
const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] })
|
||||
const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(injected?.globalAutoReview ?? true)
|
||||
const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'mine')
|
||||
// 默认团队全部,与扩展侧 DEFAULT_KANBAN_SCOPE 一致(两个包不共享源码)。
|
||||
const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'all')
|
||||
const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(injected?.youtrackConfigured ?? false)
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
|
||||
@@ -350,6 +359,7 @@ export function useIssues(): UseIssuesResult {
|
||||
const prDiffSummaryRunningRef = useRef<Set<number>>(new Set())
|
||||
const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState<ReadonlySet<number>>(() => new Set())
|
||||
const [me, setMe] = useState<string | undefined>(injected?.me)
|
||||
const [avatars, setAvatars] = useState<Record<string, string>>(injected?.avatars ?? {})
|
||||
const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null)
|
||||
const handoffRunningRef = useRef<Set<number>>(new Set())
|
||||
const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set())
|
||||
@@ -698,6 +708,9 @@ export function useIssues(): UseIssuesResult {
|
||||
setGlobalAutoReview(msg.globalAutoReview)
|
||||
setYoutrackConfigured(msg.youtrackConfigured)
|
||||
setMe(msg.me)
|
||||
// 头像抓取失败时下发空表,保留上一批已有的头像,避免卡片头像闪掉。
|
||||
if (msg.avatars && Object.keys(msg.avatars).length > 0)
|
||||
setAvatars(msg.avatars)
|
||||
break
|
||||
case 'issues/error':
|
||||
setState({ status: 'error', message: msg.message })
|
||||
@@ -895,5 +908,5 @@ export function useIssues(): UseIssuesResult {
|
||||
return cleanup
|
||||
}, [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, continueBrainstorm, 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 }
|
||||
return { state, settings, avatars, 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, continueBrainstorm, 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 }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isIssueMine, issueOwnerLogin } from './issueOwnership'
|
||||
|
||||
describe('isIssueMine', () => {
|
||||
it('assignee 含我 → 可写', () => {
|
||||
expect(isIssueMine({ poster: 'chehongwei', assignees: ['cruldra'] }, 'cruldra')).toBe(true)
|
||||
})
|
||||
|
||||
it('assignee 是别人 → 只读,创建者是我也不算', () => {
|
||||
expect(isIssueMine({ poster: 'cruldra', assignees: ['chehongwei'] }, 'cruldra')).toBe(false)
|
||||
})
|
||||
|
||||
it('无 assignee 时按创建者判定', () => {
|
||||
expect(isIssueMine({ poster: 'chehongwei' }, 'cruldra')).toBe(false)
|
||||
expect(isIssueMine({ poster: 'cruldra' }, 'cruldra')).toBe(true)
|
||||
})
|
||||
|
||||
it('身份未知 → 一律可写', () => {
|
||||
expect(isIssueMine({ poster: 'chehongwei' }, undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('youtrack 等无归属字段的工单 → 可写', () => {
|
||||
expect(isIssueMine({}, 'cruldra')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('issueOwnerLogin', () => {
|
||||
it('assignee 优先于创建者', () => {
|
||||
expect(issueOwnerLogin({ poster: 'cruldra', assignees: ['chehongwei'] })).toBe('chehongwei')
|
||||
})
|
||||
|
||||
it('无 assignee 时回退创建者', () => {
|
||||
expect(issueOwnerLogin({ poster: 'cruldra' })).toBe('cruldra')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 工单归属判定(扩展侧 `src/panel/issueOwnership.ts` 的镜像,两个包不共享
|
||||
* 源码,改一处要同步另一处)。
|
||||
*
|
||||
* 看板范围切到「团队全部」后同事的工单也会上板,这类卡片只读:不能拖列、
|
||||
* 不能开会话、不能合并或删除,只保留查看与接管。
|
||||
*/
|
||||
|
||||
import type { Issue } from '../types'
|
||||
|
||||
type OwnershipFacts = Pick<Issue, 'poster' | 'assignees'>
|
||||
|
||||
/** 该工单是否归当前登录者所有(可写)。身份未知时一律放行,不锁死自己的板。 */
|
||||
export function isIssueMine(issue: OwnershipFacts, me: string | undefined): boolean {
|
||||
if (me === undefined)
|
||||
return true
|
||||
const assignees = issue.assignees ?? []
|
||||
if (assignees.length > 0)
|
||||
return assignees.includes(me)
|
||||
return issue.poster === undefined || issue.poster === me
|
||||
}
|
||||
|
||||
/** 卡片头像取谁:assignee 优先(责任人),否则创建者。 */
|
||||
export function issueOwnerLogin(issue: OwnershipFacts): string | undefined {
|
||||
return (issue.assignees ?? [])[0] ?? issue.poster
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export interface ProfilesData {
|
||||
|
||||
export type ExtensionToWebview
|
||||
= | { type: 'issues/loading' }
|
||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string }
|
||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||
| { type: 'issues/error', message: string }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PropertyGroup } from '../components/property-grid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toReadOnlySchema } from './readOnlySchema'
|
||||
|
||||
function schema(): PropertyGroup[] {
|
||||
return [{
|
||||
id: 'g',
|
||||
label: '组',
|
||||
properties: [
|
||||
{ key: 'autoReview', label: '自动审查', type: 'boolean' },
|
||||
{ key: 'session', label: '规划会话', type: 'action', onAction: () => {}, secondaryActions: [{ icon: null, onClick: () => {} }] },
|
||||
{ key: 'pr', label: '合并请求', type: 'pr-link', onAction: () => {} },
|
||||
{ key: 'plan', label: '计划', type: 'file-link', onOpen: () => {}, onReload: () => {} },
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
describe('toReadOnlySchema', () => {
|
||||
it('所有字段变只读', () => {
|
||||
const out = toReadOnlySchema(schema())
|
||||
expect(out[0].properties.every(p => p.readOnly)).toBe(true)
|
||||
})
|
||||
|
||||
it('会话行的双击恢复被摘掉', () => {
|
||||
const out = toReadOnlySchema(schema())
|
||||
expect(out[0].properties.find(p => p.key === 'session')?.onAction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('打开 PR、打开文件仍可用', () => {
|
||||
const out = toReadOnlySchema(schema())
|
||||
expect(out[0].properties.find(p => p.key === 'pr')?.onAction).toBeTypeOf('function')
|
||||
expect(out[0].properties.find(p => p.key === 'plan')?.onOpen).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('右侧图标按钮全部禁用', () => {
|
||||
const out = toReadOnlySchema(schema())
|
||||
expect(out[0].properties.find(p => p.key === 'session')?.secondaryActions?.[0].disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('不改动原 schema', () => {
|
||||
const original = schema()
|
||||
toReadOnlySchema(original)
|
||||
expect(original[0].properties[0].readOnly).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 把详情面板的属性表变成只读版本:同事的工单在板上只能看。
|
||||
*
|
||||
* 一处消毒覆盖所有行,避免每加一行就得记得判断归属。放行的只有纯查看
|
||||
* 动作——双击 PR 编号跳浏览器、双击文件名打开本地文件;会改工单状态、
|
||||
* 开会话、删 worktree 的按钮一律禁用。
|
||||
*/
|
||||
|
||||
import type { PropertyGroup } from '../components/property-grid'
|
||||
|
||||
export function toReadOnlySchema(schema: PropertyGroup[]): PropertyGroup[] {
|
||||
return schema.map(group => ({
|
||||
...group,
|
||||
actions: undefined,
|
||||
properties: group.properties.map(prop => ({
|
||||
...prop,
|
||||
readOnly: true,
|
||||
// 'action' 行的双击是「在本机恢复会话」,别人的会话恢复不了也不该恢复。
|
||||
onAction: prop.type === 'action' ? undefined : prop.onAction,
|
||||
onReload: undefined,
|
||||
secondaryActions: prop.secondaryActions?.map(a => ({ ...a, disabled: true })),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@@ -83,6 +83,8 @@ export interface Issue {
|
||||
autoReview?: boolean
|
||||
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
|
||||
assignees?: string[]
|
||||
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
|
||||
poster?: string
|
||||
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
|
||||
handoffAttachmentId?: string
|
||||
/** 发起移交的 Gitea login。 */
|
||||
|
||||
Reference in new issue
Block a user