✨ feat(vscode): 会话交接改走阿里云 OSS 收件箱、换 xz 压缩,另加从 env 文件配置移交存储的 URI

- 会话包传到 OSS 上收件人的收件箱 spx-handoff/<owner>/<repo>/session/<收件人>/<sid>!<发送人>!<会话名>.tar.xz,对象私有;owner/repo/收件人转小写
- 待接收列表就是列自己的收件箱;同一会话再交给同一人先删旧包;接收后删包,包已被别处收走时提示并刷新
- 打包换成 xz 1 级,和工单移交共用 packHandoff / unpackHandoff,删掉 gzip 版
- 删掉邮箱工单整套(mailboxIssue、MAILBOX_*、isMailboxIssue 及看板 / webhook 里的过滤、附件名编解码)和不再用的 Gitea 附件、标签、建工单接口
- session-handoff/accept 与待接收项改用 OSS 对象 key 标识
- 没配移交存储时发送、接收、刷新都提示去配,刷新回空列表
- 进度提示照工单移交:同一条提示依次显示打包、上传、下载多少 MB、解包、装会话
- 新增 URI configure-handoff-store?envFile=<绝对路径>:读 OSS_ENDPOINT / OSS_BUCKET_NAME / OSS_ACCESS_KEY / OSS_SECRET_KEY 保存,和命令共用 saveHandoffStore;URI 入口按 path 统一分发,未知 path 报错
This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 81fe14f6dc
commit fcee4143d4
26 files changed
+542 -515

No files matched your search

-155
View File
@@ -9,8 +9,6 @@
* distinguish 401 (token invalid) from other failure modes.
*/
import { Buffer } from 'node:buffer'
import { logger } from '../logging/logger'
export const PAGE_SIZE = 50
@@ -100,8 +98,6 @@ export async function listIssuesByFilter(opts: {
/** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */
filter?: 'assigned_by' | 'created_by'
user?: string
/** 逗号分隔的 label 名。label 必须已存在,否则 Gitea 会丢掉过滤条件。 */
labels?: string
}): Promise<GiteaIssue[]> {
const out: GiteaIssue[] = []
let page = 1
@@ -113,8 +109,6 @@ export async function listIssuesByFilter(opts: {
url.searchParams.set('state', 'all')
if (opts.filter && opts.user)
url.searchParams.set(opts.filter, opts.user)
if (opts.labels)
url.searchParams.set('labels', opts.labels)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
@@ -131,77 +125,6 @@ export async function listIssuesByFilter(opts: {
return out
}
export interface GiteaLabel {
id: number
name: string
color: string
}
export async function listRepoLabels(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<GiteaLabel[]> {
const out: GiteaLabel[] = []
let page = 1
while (true) {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaLabel[]
if (!Array.isArray(batch) || batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
export async function createRepoLabel(opts: {
host: string
token: string
owner: string
repo: string
name: string
color: string
description?: string
}): Promise<GiteaLabel> {
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`, {
method: 'POST',
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: opts.name, color: opts.color, description: opts.description ?? '' }),
})
await ensureOk(res)
return (await res.json()) as GiteaLabel
}
export async function createIssue(opts: {
host: string
token: string
owner: string
repo: string
title: string
body: string
labels?: number[]
}): Promise<GiteaIssue> {
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`, {
method: 'POST',
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
body: JSON.stringify({
title: opts.title,
body: opts.body,
...(opts.labels && opts.labels.length > 0 ? { labels: opts.labels } : {}),
}),
})
await ensureOk(res)
return (await res.json()) as GiteaIssue
}
/**
* Fetches the firehose `/repos/{owner}/{repo}/issues/comments` endpoint, which
* returns every comment in the repo across all issues. Paginated until a short
@@ -713,15 +636,6 @@ export async function getRawFile(opts: {
return res.text()
}
export interface GiteaAttachment {
id: number
name: string
size: number
uuid: string
browser_download_url: string
created_at: string
}
/** 可被指派的用户(仓库协作者 + owner),移交选人用。 */
export async function listRepoAssignees(opts: {
host: string
@@ -736,72 +650,3 @@ export async function listRepoAssignees(opts: {
await ensureOk(res)
return (await res.json()) as GiteaUser[]
}
export async function listIssueAttachments(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaAttachment[]> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
return (await res.json()) as GiteaAttachment[]
}
export async function uploadIssueAttachment(opts: {
host: string
token: string
owner: string
repo: string
index: number
name: string
data: Buffer
}): Promise<GiteaAttachment> {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`)
url.searchParams.set('name', opts.name)
const form = new FormData()
// Buffer 的底层 ArrayBufferLike 包含 SharedArrayBuffer,BlobPart 只认 ArrayBuffer;
// 用 Uint8Array 包一层拷贝到普通 ArrayBuffer 上,绕开这条 TS 类型不兼容。
form.append('attachment', new Blob([new Uint8Array(opts.data)], { type: 'application/gzip' }), opts.name)
// 不能带 Content-Type:multipart boundary 由 fetch 按 FormData 自动生成。
const res = await fetch(url.toString(), {
method: 'POST',
headers: { Authorization: `token ${opts.token}`, Accept: 'application/json' },
body: form,
})
await ensureOk(res)
return (await res.json()) as GiteaAttachment
}
/**
* `browser_download_url` 走的是 web 路由(/attachments/<uuid>),Gitea 的 web
* 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
* 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。
*/
export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer> {
const res = await fetch(opts.url, { headers: { Authorization: `token ${opts.token}` }, redirect: 'follow' })
await ensureOk(res)
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('text/html'))
throw new GiteaApiError(res.status, `附件下载被重定向到页面(鉴权失败?):${opts.url}`)
return Buffer.from(await res.arrayBuffer())
}
export async function deleteIssueAttachment(opts: {
host: string
token: string
owner: string
repo: string
index: number
attachmentId: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`,
{ method: 'DELETE', headers: authHeaders(opts.token) },
)
await ensureOk(res)
}