11
This commit is contained in:
commit
e6f1776d4f
264 files changed
+54215
No files matched your search
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Fetch-based YouTrack REST client.
|
||||
*
|
||||
* Mirrors the role of `src/gitea/api.ts` for the second issue source. All
|
||||
* requests authenticate with a YouTrack permanent token via
|
||||
* `Authorization: Bearer <token>`. Non-2xx responses surface as
|
||||
* `YouTrackApiError` so callers can distinguish 401 (token invalid) from other
|
||||
* failures, matching the Gitea client's contract.
|
||||
*/
|
||||
|
||||
export class YouTrackApiError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'YouTrackApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export interface YouTrackProject {
|
||||
id: string
|
||||
name: string
|
||||
shortName: string
|
||||
}
|
||||
|
||||
export interface YouTrackAttachment {
|
||||
name: string
|
||||
/** Absolute URL (baseUrl + the relative, pre-signed path YouTrack returns). */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface YouTrackComment {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface YouTrackIssue {
|
||||
idReadable: string
|
||||
summary: string
|
||||
description: string
|
||||
/** Resolve timestamp in ms, or null when the issue is unresolved/open. */
|
||||
resolved: number | null
|
||||
attachments: YouTrackAttachment[]
|
||||
comments: YouTrackComment[]
|
||||
}
|
||||
|
||||
/** Strip trailing slashes so we can append `/api/...` uniformly. */
|
||||
function normalizeBase(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOk(res: Response): Promise<void> {
|
||||
if (res.ok)
|
||||
return
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new YouTrackApiError(res.status, body || res.statusText)
|
||||
}
|
||||
|
||||
export interface YouTrackAuth {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
/** Browser URL to an issue's page — the detail-panel "在 YouTrack 打开" target. */
|
||||
export function issueHtmlUrl(baseUrl: string, idReadable: string): string {
|
||||
return `${normalizeBase(baseUrl)}/issue/${idReadable}`
|
||||
}
|
||||
|
||||
/** Lists projects the token can see — feeds the settings project dropdown. */
|
||||
export async function listProjects(auth: YouTrackAuth): Promise<YouTrackProject[]> {
|
||||
const url = new URL(`${normalizeBase(auth.baseUrl)}/api/admin/projects`)
|
||||
url.searchParams.set('fields', 'id,name,shortName')
|
||||
url.searchParams.set('$top', '500')
|
||||
const res = await fetch(url.toString(), { headers: authHeaders(auth.token) })
|
||||
await ensureOk(res)
|
||||
const data = await res.json() as Array<{ id?: unknown, name?: unknown, shortName?: unknown }>
|
||||
if (!Array.isArray(data))
|
||||
return []
|
||||
return data
|
||||
.map(p => ({
|
||||
id: typeof p.id === 'string' ? p.id : '',
|
||||
name: typeof p.name === 'string' ? p.name : '',
|
||||
shortName: typeof p.shortName === 'string' ? p.shortName : '',
|
||||
}))
|
||||
.filter(p => p.shortName)
|
||||
}
|
||||
|
||||
const ISSUE_FIELDS = [
|
||||
'idReadable',
|
||||
'summary',
|
||||
'description',
|
||||
'resolved',
|
||||
'attachments(name,url)',
|
||||
'comments(id,text)',
|
||||
].join(',')
|
||||
|
||||
/**
|
||||
* Lists all issues in a project (newest first). Single page of up to 500 —
|
||||
* ponytail: no pagination, the panel tracks a personal project, not a 10k-issue
|
||||
* firehose. Upgrade to `$skip`/`$top` paging if a project ever outgrows it.
|
||||
*/
|
||||
export async function listIssues(auth: YouTrackAuth, projectShortName: string): Promise<YouTrackIssue[]> {
|
||||
const url = new URL(`${normalizeBase(auth.baseUrl)}/api/issues`)
|
||||
url.searchParams.set('query', `project: ${projectShortName}`)
|
||||
url.searchParams.set('fields', ISSUE_FIELDS)
|
||||
url.searchParams.set('$top', '500')
|
||||
const res = await fetch(url.toString(), { headers: authHeaders(auth.token) })
|
||||
await ensureOk(res)
|
||||
const data = await res.json() as unknown
|
||||
if (!Array.isArray(data))
|
||||
return []
|
||||
const base = normalizeBase(auth.baseUrl)
|
||||
return data.map((raw) => {
|
||||
const it = raw as Record<string, unknown>
|
||||
const attachments = Array.isArray(it.attachments)
|
||||
? (it.attachments as Array<Record<string, unknown>>).map(a => ({
|
||||
name: typeof a.name === 'string' ? a.name : '',
|
||||
url: typeof a.url === 'string' ? (a.url.startsWith('http') ? a.url : base + a.url) : '',
|
||||
})).filter(a => a.url)
|
||||
: []
|
||||
const comments = Array.isArray(it.comments)
|
||||
? (it.comments as Array<Record<string, unknown>>).map(c => ({
|
||||
id: typeof c.id === 'string' ? c.id : '',
|
||||
text: typeof c.text === 'string' ? c.text : '',
|
||||
}))
|
||||
: []
|
||||
return {
|
||||
idReadable: typeof it.idReadable === 'string' ? it.idReadable : '',
|
||||
summary: typeof it.summary === 'string' ? it.summary : '',
|
||||
description: typeof it.description === 'string' ? it.description : '',
|
||||
resolved: typeof it.resolved === 'number' ? it.resolved : null,
|
||||
attachments,
|
||||
comments,
|
||||
}
|
||||
}).filter(it => it.idReadable)
|
||||
}
|
||||
|
||||
/** Fresh read of one issue's comments — used right before mutating state. */
|
||||
export async function listComments(auth: YouTrackAuth, issueId: string): Promise<YouTrackComment[]> {
|
||||
const url = new URL(`${normalizeBase(auth.baseUrl)}/api/issues/${issueId}/comments`)
|
||||
url.searchParams.set('fields', 'id,text')
|
||||
url.searchParams.set('$top', '500')
|
||||
const res = await fetch(url.toString(), { headers: authHeaders(auth.token) })
|
||||
await ensureOk(res)
|
||||
const data = await res.json() as Array<{ id?: unknown, text?: unknown }>
|
||||
if (!Array.isArray(data))
|
||||
return []
|
||||
return data.map(c => ({
|
||||
id: typeof c.id === 'string' ? c.id : '',
|
||||
text: typeof c.text === 'string' ? c.text : '',
|
||||
}))
|
||||
}
|
||||
|
||||
export async function addComment(auth: YouTrackAuth, issueId: string, text: string): Promise<void> {
|
||||
const url = new URL(`${normalizeBase(auth.baseUrl)}/api/issues/${issueId}/comments`)
|
||||
url.searchParams.set('fields', 'id')
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(auth.token), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
await ensureOk(res)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a YouTrack command (e.g. `State Fixed`) to one issue via the commands
|
||||
* API. Used to resolve the issue when its card lands in the 完成 column.
|
||||
*/
|
||||
export async function applyCommand(auth: YouTrackAuth, issueId: string, command: string): Promise<void> {
|
||||
const url = `${normalizeBase(auth.baseUrl)}/api/commands`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(auth.token), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: command, issues: [{ idReadable: issueId }] }),
|
||||
})
|
||||
await ensureOk(res)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a "resolve this issue" command by inspecting its State-type custom
|
||||
* field: finds the field's bundle element flagged `isResolved` and returns
|
||||
* `<FieldName> <ResolvedValue>`. Returns null when the issue has no resolvable
|
||||
* state field (caller then logs and skips). Lets close-sync work without the
|
||||
* user hard-coding a command for their project's state naming.
|
||||
*/
|
||||
export async function resolvedStateCommand(auth: YouTrackAuth, issueId: string): Promise<string | null> {
|
||||
const url = new URL(`${normalizeBase(auth.baseUrl)}/api/issues/${issueId}`)
|
||||
url.searchParams.set(
|
||||
'fields',
|
||||
'customFields(name,$type,projectCustomField(field(name),bundle(values(name,isResolved))))',
|
||||
)
|
||||
const res = await fetch(url.toString(), { headers: authHeaders(auth.token) })
|
||||
await ensureOk(res)
|
||||
const data = await res.json() as { customFields?: Array<Record<string, any>> }
|
||||
const fields = Array.isArray(data.customFields) ? data.customFields : []
|
||||
for (const f of fields) {
|
||||
if (typeof f?.$type === 'string' && f.$type.includes('State')) {
|
||||
const fieldName = f?.projectCustomField?.field?.name ?? f?.name
|
||||
const values = f?.projectCustomField?.bundle?.values
|
||||
if (typeof fieldName === 'string' && Array.isArray(values)) {
|
||||
const resolvedValue = values.find((v: any) => v?.isResolved === true)
|
||||
if (resolvedValue && typeof resolvedValue.name === 'string')
|
||||
return `${fieldName} ${resolvedValue.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Workspace 级「YouTrack 已导入工单集」持久化。
|
||||
*
|
||||
* 只把用户主动勾选导入的工单镜像到看板(不再加载即全量同步),落盘到
|
||||
* `<workspace>/.spx/youtrack-imported.json`。形状是
|
||||
* `Record<项目 shortName, idReadable[]>`,便于一个工作区配多个项目。
|
||||
* 文件不存在或解析失败时该项目返回空集;首次写入时按需 `mkdir -p .spx`。
|
||||
*/
|
||||
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
function importsFile(workspaceRoot: string): string {
|
||||
return path.join(workspaceRoot, '.spx', 'youtrack-imported.json')
|
||||
}
|
||||
|
||||
/** 读取整个 `Record<project, idReadable[]>`。文件缺失/解析失败/形状不符均返回 `{}`。 */
|
||||
async function readAll(workspaceRoot: string): Promise<Record<string, string[]>> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fsp.readFile(importsFile(workspaceRoot), 'utf8')
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (parsed === null || typeof parsed !== 'object')
|
||||
return {}
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (Array.isArray(value))
|
||||
out[key] = value.filter((v): v is string => typeof v === 'string')
|
||||
}
|
||||
return out
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取某项目已导入的 idReadable 列表。无记录返回 `[]`。 */
|
||||
export async function readImportedIds(workspaceRoot: string, project: string): Promise<string[]> {
|
||||
const all = await readAll(workspaceRoot)
|
||||
return all[project] ?? []
|
||||
}
|
||||
|
||||
/** 写入某项目的已导入 idReadable 列表(去重)。空列表则删除该 key。父目录按需创建。 */
|
||||
export async function writeImportedIds(workspaceRoot: string, project: string, ids: string[]): Promise<void> {
|
||||
const all = await readAll(workspaceRoot)
|
||||
if (ids.length > 0)
|
||||
all[project] = [...new Set(ids)]
|
||||
else
|
||||
delete all[project]
|
||||
const dir = path.join(workspaceRoot, '.spx')
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
await fsp.writeFile(importsFile(workspaceRoot), `${JSON.stringify(all, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Loads YouTrack-sourced issues into the panel's `Issue` shape — the YouTrack
|
||||
* counterpart of `src/gitea/issueLoader.ts`.
|
||||
*
|
||||
* Returns `[]` (not an error) when YouTrack isn't configured, so the panel can
|
||||
* always call this alongside the Gitea loader and just merge the results.
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from 'vscode'
|
||||
import { workspace } from 'vscode'
|
||||
import type { Issue, IssueColumn } from '../gitea/types'
|
||||
import { getYouTrackToken } from '../auth/secrets'
|
||||
import { getSettings } from '../settings/store'
|
||||
import type { YouTrackIssue } from './api'
|
||||
import { issueHtmlUrl, listIssues } from './api'
|
||||
import { readImportedIds } from './importStore'
|
||||
import { findLatestState } from './stateComment'
|
||||
|
||||
const VALID_COLUMNS = new Set<IssueColumn>(['todo', 'in-progress', 'review', 'done'])
|
||||
|
||||
/** Host part of the configured base URL — the SecretStorage token key. */
|
||||
export function youtrackHost(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl).host
|
||||
}
|
||||
catch {
|
||||
return baseUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset added to a YouTrack issue's numeric id so its board `number` never
|
||||
* collides with a gitea issue number (which is the routing key for
|
||||
* webview↔extension messages and the `number → IssueRef` map).
|
||||
* ponytail: assumes gitea numbers < 1,000,000 and a single mirrored project
|
||||
* (readable ids are unique within one project). Widen / hash if either breaks.
|
||||
*/
|
||||
const YOUTRACK_NUMBER_OFFSET = 1_000_000
|
||||
|
||||
/** `LXF-12` → `1_000_012`; falls back to the offset when there's no numeric tail. */
|
||||
function syntheticNumber(idReadable: string): number {
|
||||
const m = idReadable.match(/-(\d+)$/)
|
||||
return YOUTRACK_NUMBER_OFFSET + (m ? Number(m[1]) : 0)
|
||||
}
|
||||
|
||||
function pickColumn(rawColumn: unknown, resolved: number | null): IssueColumn {
|
||||
if (typeof rawColumn === 'string' && VALID_COLUMNS.has(rawColumn as IssueColumn))
|
||||
return rawColumn as IssueColumn
|
||||
return resolved != null ? 'done' : 'todo'
|
||||
}
|
||||
|
||||
function toIssue(baseUrl: string, it: YouTrackIssue): Issue {
|
||||
const state = findLatestState(it.comments) ?? {}
|
||||
return {
|
||||
id: `youtrack:${it.idReadable}`,
|
||||
externalId: it.idReadable,
|
||||
source: 'youtrack',
|
||||
number: syntheticNumber(it.idReadable),
|
||||
title: it.summary,
|
||||
column: pickColumn(state.column, it.resolved),
|
||||
htmlUrl: issueHtmlUrl(baseUrl, it.idReadable),
|
||||
attachments: it.attachments,
|
||||
sessionId: str(state.sessionId),
|
||||
implementSessionId: str(state.implementSessionId),
|
||||
reviewSessionId: str(state.reviewSessionId),
|
||||
testSessionId: str(state.testSessionId),
|
||||
profilePath: str(state.profilePath),
|
||||
testProfilePath: str(state.testProfilePath),
|
||||
specFile: str(state.specFile),
|
||||
planFile: str(state.planFile),
|
||||
prDiffFile: str(state.prDiffFile),
|
||||
pr: str(state.pr),
|
||||
branch: str(state.branch),
|
||||
worktreePath: str(state.worktreePath),
|
||||
color: str(state.color),
|
||||
implementStatus: state.implementStatus === 'running' || state.implementStatus === 'done' || state.implementStatus === 'failed'
|
||||
? state.implementStatus
|
||||
: undefined,
|
||||
autoReview: typeof state.autoReview === 'boolean' ? state.autoReview : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadYouTrackIssues(ctx: ExtensionContext): Promise<Issue[]> {
|
||||
const settings = getSettings(ctx)
|
||||
const baseUrl = settings.youtrackBaseUrl.trim()
|
||||
const project = settings.youtrackProjectShortName.trim()
|
||||
if (!baseUrl || !project)
|
||||
return []
|
||||
const token = await getYouTrackToken(ctx, youtrackHost(baseUrl))
|
||||
if (!token)
|
||||
return []
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot)
|
||||
return []
|
||||
// 只镜像用户主动勾选导入的工单(见 importStore),不再加载即全量同步。
|
||||
// import 集为空 ⇒ 返回 []。
|
||||
const importedSet = new Set(await readImportedIds(workspaceRoot, project))
|
||||
if (importedSet.size === 0)
|
||||
return []
|
||||
const raw = await listIssues({ baseUrl, token }, project)
|
||||
return raw
|
||||
.filter(it => importedSet.has(it.idReadable))
|
||||
.map(it => toIssue(baseUrl, it))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* YouTrack equivalent of `src/gitea/stateJson.ts`.
|
||||
*
|
||||
* Workflow state (column, sessionId, specFile, …) for a YouTrack-sourced issue
|
||||
* is persisted as a comment on the issue. The comment body is a marker line
|
||||
* followed by the JSON blob:
|
||||
*
|
||||
* <!--spx-state-->
|
||||
* {"column":"in-progress","sessionId":"..."}
|
||||
*
|
||||
* The marker makes the internal blob identifiable in the customer-facing
|
||||
* tracker (the user accepted that this state lives in comments). We scan
|
||||
* comments from the tail and pick the latest one carrying the marker, so
|
||||
* ordinary customer replies posted afterwards never shadow the real state.
|
||||
*/
|
||||
|
||||
import type { YouTrackAuth, YouTrackComment } from './api'
|
||||
import { addComment, listComments } from './api'
|
||||
|
||||
export const STATE_MARKER = '<!--spx-state-->'
|
||||
|
||||
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const
|
||||
|
||||
/**
|
||||
* Parse a comment body into a state object, or null if it isn't a state
|
||||
* comment. Accepts either a marker-prefixed body or a bare JSON object that
|
||||
* carries a known state field (tolerant of older blobs written without the
|
||||
* marker).
|
||||
*/
|
||||
export function parseStateComment(body: string | undefined | null): Record<string, unknown> | null {
|
||||
let text = (body ?? '').trim()
|
||||
if (!text)
|
||||
return null
|
||||
if (text.startsWith(STATE_MARKER))
|
||||
text = text.slice(STATE_MARKER.length).trim()
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
return null
|
||||
const obj = parsed as Record<string, unknown>
|
||||
return KNOWN_STATE_FIELDS.some(f => f in obj) ? obj : null
|
||||
}
|
||||
|
||||
/** Latest state blob across a comment list (tail-first), or null. */
|
||||
export function findLatestState(comments: YouTrackComment[]): Record<string, unknown> | null {
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const parsed = parseStateComment(comments[i].text)
|
||||
if (parsed)
|
||||
return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function serialize(state: Record<string, unknown>): string {
|
||||
return `${STATE_MARKER}\n${JSON.stringify(state)}`
|
||||
}
|
||||
|
||||
/** Read the issue's latest state blob, or `{}` when none exists. */
|
||||
export async function readStateComment(
|
||||
auth: YouTrackAuth,
|
||||
issueId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const comments = await listComments(auth, issueId)
|
||||
return findLatestState(comments) ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `extra` into the issue's latest state blob and post a new state
|
||||
* comment. Re-reads comments first so we never merge onto a stale blob.
|
||||
*/
|
||||
export async function mergeStateComment(
|
||||
auth: YouTrackAuth,
|
||||
issueId: string,
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const comments = await listComments(auth, issueId)
|
||||
const current = findLatestState(comments) ?? {}
|
||||
await addComment(auth, issueId, serialize({ ...current, ...extra }))
|
||||
}
|
||||
Reference in new issue
Block a user