@x-otto/cli 0.0.1-alpha.49 → 0.0.1-alpha.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auth-command-D1T16ed-.js → auth-command-BYaFM76w.js} +2 -2
- package/dist/{auth-command-D1T16ed-.js.map → auth-command-BYaFM76w.js.map} +1 -1
- package/dist/backend-handler-DUafuNWw.js +3 -0
- package/dist/backend-handler-DUafuNWw.js.map +1 -0
- package/dist/build-id-DjE6eVpQ.js +3 -0
- package/dist/build-id-DjE6eVpQ.js.map +1 -0
- package/dist/bundled-extensions/plugin-feedback/otto-plugin.json +24 -0
- package/dist/bundled-extensions/plugin-feedback/panels/feedback.tsx +348 -0
- package/dist/bundled-extensions/plugin-feedback/panels/panel-host-globals.d.ts +67 -0
- package/dist/bundled-extensions/plugin-feedback/panels-dist/feedback.mjs +257 -0
- package/dist/bundled-extensions/plugin-feedback/plugin-dist/meta.json +1 -0
- package/dist/bundled-extensions/plugin-feedback/plugin-dist/plugin.cjs +11304 -0
- package/dist/bundled-extensions/plugin-feedback/plugin.ts +198 -0
- package/dist/bundled-extensions/plugin-feedback/src/coding-adapter.ts +183 -0
- package/dist/bundled-extensions/plugin-feedback/src/coding-lister.ts +26 -0
- package/dist/bundled-extensions/plugin-feedback/src/github-lister.ts +230 -0
- package/dist/bundled-extensions/plugin-feedback/src/issue-aggregator.ts +109 -0
- package/dist/bundled-extensions/plugin-feedback/src/issue-submitter.ts +241 -0
- package/dist/bundled-extensions/plugin-feedback/src/platform-resolver.ts +76 -0
- package/dist/bundled-extensions/plugin-feedback/src/pulse-survey-sink.ts +114 -0
- package/dist/bundled-extensions/plugin-feedback/src/resolved-sink.ts +163 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/meta.json +1 -1
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/plugin.cjs +605 -725
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-messages.ts +4 -22
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-completions.ts +5 -5
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-responses.ts +3 -15
- package/dist/command-dispatcher-BLT8IO4K.js +18 -0
- package/dist/command-dispatcher-BLT8IO4K.js.map +1 -0
- package/dist/create-host-app-CbJ8ptv-.js +3 -0
- package/dist/{create-host-app-BXQ8bU-B.js.map → create-host-app-CbJ8ptv-.js.map} +1 -1
- package/dist/{daemon-base-Y1XI61JY.js → daemon-base-D015ew5y.js} +1 -1
- package/dist/{daemon-base-Y1XI61JY.js.map → daemon-base-D015ew5y.js.map} +1 -1
- package/dist/{doctor-DXBLkbkP.js → doctor-EndB-ycb.js} +3 -3
- package/dist/{doctor-DXBLkbkP.js.map → doctor-EndB-ycb.js.map} +1 -1
- package/dist/{doctor-attach-DEMuyBOk.js → doctor-attach-DEoE7eFx.js} +2 -2
- package/dist/{doctor-attach-DEMuyBOk.js.map → doctor-attach-DEoE7eFx.js.map} +1 -1
- package/dist/{doctor-cdp-CnJUVNOB.js → doctor-cdp-Cj9IFpEP.js} +1 -1
- package/dist/{doctor-cdp-CnJUVNOB.js.map → doctor-cdp-Cj9IFpEP.js.map} +1 -1
- package/dist/{doctor-heap-Eu3LZj-F.js → doctor-heap-SIxiRvL8.js} +2 -2
- package/dist/{doctor-heap-Eu3LZj-F.js.map → doctor-heap-SIxiRvL8.js.map} +1 -1
- package/dist/{doctor-ps-JBgCH77n.js → doctor-ps-DNVOF6yY.js} +2 -2
- package/dist/{doctor-ps-JBgCH77n.js.map → doctor-ps-DNVOF6yY.js.map} +1 -1
- package/dist/extension-CoJqqktf.js +2 -0
- package/dist/extension-Dpn8gltG.js +179 -0
- package/dist/extension-Dpn8gltG.js.map +1 -0
- package/dist/feedback-QcONRnO3.js +30 -0
- package/dist/feedback-QcONRnO3.js.map +1 -0
- package/dist/feedback-backend-methods-BNm38ddr.js +3 -0
- package/dist/feedback-backend-methods-BNm38ddr.js.map +1 -0
- package/dist/{feishu-setup-5ZoxtH0J.js → feishu-setup-DbAq_BRH.js} +1 -1
- package/dist/feishu-setup-DbAq_BRH.js.map +1 -0
- package/dist/{ftue-doctor-recovery-8FeDBFOc.js → ftue-doctor-recovery-CKTMnxM8.js} +1 -1
- package/dist/{ftue-doctor-recovery-8FeDBFOc.js.map → ftue-doctor-recovery-CKTMnxM8.js.map} +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -11
- package/dist/index.js.map +1 -1
- package/dist/install-CEPczuet.js +11 -0
- package/dist/install-CEPczuet.js.map +1 -0
- package/dist/install-CG6b6fox.js +2 -0
- package/dist/interactive-schedule-Bv__2Kwc.js +3 -0
- package/dist/interactive-schedule-Bv__2Kwc.js.map +1 -0
- package/dist/{mcp-Cm4-OhXI.js → mcp-DjReRjuu.js} +1 -1
- package/dist/{mcp-Cm4-OhXI.js.map → mcp-DjReRjuu.js.map} +1 -1
- package/dist/{mcp-server-Dpza3yn2.js → mcp-server-Rr__aWO7.js} +3 -3
- package/dist/mcp-server-Rr__aWO7.js.map +1 -0
- package/dist/{migrate-RlBJqJnB.js → migrate-BBi3Txu2.js} +1 -1
- package/dist/{migrate-RlBJqJnB.js.map → migrate-BBi3Txu2.js.map} +1 -1
- package/dist/{observe-Dxfud-t7.js → observe-BqLCpmFD.js} +2 -2
- package/dist/{observe-Dxfud-t7.js.map → observe-BqLCpmFD.js.map} +1 -1
- package/dist/parse-cli-Db8pF_Ya.js +3 -0
- package/dist/parse-cli-Db8pF_Ya.js.map +1 -0
- package/dist/{persistence-CRFhOYx6.js → persistence-BvLRnpfi.js} +2 -2
- package/dist/{persistence-CRFhOYx6.js.map → persistence-BvLRnpfi.js.map} +1 -1
- package/dist/{plugin-assembly-check-DEozmcJ7.js → plugin-assembly-check-BntT5W6H.js} +1 -1
- package/dist/{plugin-assembly-check-DEozmcJ7.js.map → plugin-assembly-check-BntT5W6H.js.map} +1 -1
- package/dist/{proxy-evg7ZvoD.js → proxy-CFZDVIwI.js} +1 -1
- package/dist/proxy-CFZDVIwI.js.map +1 -0
- package/dist/proxy-CkSf8ziQ.js +2 -0
- package/dist/{ps-DiKCLKbs.js → ps-BVKv6rAo.js} +1 -1
- package/dist/{ps-DiKCLKbs.js.map → ps-BVKv6rAo.js.map} +1 -1
- package/dist/{registry-BdCcwyGy.js → registry-BUvu-es1.js} +4 -4
- package/dist/registry-BUvu-es1.js.map +1 -0
- package/dist/registry-DYHR-ONu.js +2 -0
- package/dist/{run-attach-DDpMk2_N.js → run-attach-X35puJ_n.js} +2 -2
- package/dist/{run-attach-DDpMk2_N.js.map → run-attach-X35puJ_n.js.map} +1 -1
- package/dist/schedule-Ke0jwatM.js +5 -0
- package/dist/schedule-Ke0jwatM.js.map +1 -0
- package/dist/serve-K6BOT5K_.js +21 -0
- package/dist/serve-K6BOT5K_.js.map +1 -0
- package/dist/{service-client-Cr4sxDVM.js → service-client-cfUUP4EE.js} +1 -1
- package/dist/{service-client-Cr4sxDVM.js.map → service-client-cfUUP4EE.js.map} +1 -1
- package/dist/{version-BrSIyY6s.js → version-DrCy32wR.js} +2 -2
- package/dist/{version-BrSIyY6s.js.map → version-DrCy32wR.js.map} +1 -1
- package/package.json +16 -13
- package/dist/build-id-DxfuzdUE.js +0 -3
- package/dist/build-id-DxfuzdUE.js.map +0 -1
- package/dist/command-dispatcher-DTOPjtZP.js +0 -21
- package/dist/command-dispatcher-DTOPjtZP.js.map +0 -1
- package/dist/create-host-app-BXQ8bU-B.js +0 -3
- package/dist/create-host-app-DG58-vRA.js +0 -2
- package/dist/extension-CTyrcdT1.js +0 -2
- package/dist/extension-DVEml5IN.js +0 -179
- package/dist/extension-DVEml5IN.js.map +0 -1
- package/dist/feedback-DvWUvSc4.js +0 -21
- package/dist/feedback-DvWUvSc4.js.map +0 -1
- package/dist/feedback-DzPLfcfN.js +0 -2
- package/dist/feishu-setup-5ZoxtH0J.js.map +0 -1
- package/dist/install-DlVJRHRT.js +0 -11
- package/dist/install-DlVJRHRT.js.map +0 -1
- package/dist/install-WCcQ9k9M.js +0 -2
- package/dist/mcp-server-Dpza3yn2.js.map +0 -1
- package/dist/parse-cli-BvcxTK88.js +0 -3
- package/dist/parse-cli-BvcxTK88.js.map +0 -1
- package/dist/proxy-BvdBsB1O.js +0 -2
- package/dist/proxy-evg7ZvoD.js.map +0 -1
- package/dist/registry-BOX7DaxS.js +0 -2
- package/dist/registry-BdCcwyGy.js.map +0 -1
- package/dist/serve-Ke0nwN9Y.js +0 -21
- package/dist/serve-Ke0nwN9Y.js.map +0 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin.ts —— plugin-feedback 代码式插件入口(RFC-209 D1 feedback 轴;RFC-430 M1
|
|
3
|
+
* 双平台反馈面板扩展;RFC-440 /feedback 斜杠命令插件化)。
|
|
4
|
+
*
|
|
5
|
+
* 承载 RFC-208(满意度脉冲调查记录)+ RFC-207(GitHub issue 提交)+ RFC-430 M1
|
|
6
|
+
* (github/coding 双源 issue 列表聚合 + 平台判定链 + 本地「已解决」标记)+ RFC-440
|
|
7
|
+
* (TUI 斜杠命令 /feedback 的插件实现)的业务契约实现。不含任何 TUI/web-ui 渲染
|
|
8
|
+
* 代码——核心包通过 `FeedbackRegistry` 接口调用本插件导出的方法。
|
|
9
|
+
*
|
|
10
|
+
* settings 读取经宿主注入端口(RFC-430 M1:`PluginContext.host.getSettings` 窄
|
|
11
|
+
* 端口,additive 于 `PluginHostCapabilities`;宿主未注入时静默落默认——平台判定
|
|
12
|
+
* 链 fail-soft 到 'github',coding 源报 unavailable),插件不直读配置文件。
|
|
13
|
+
*/
|
|
14
|
+
import { definePlugin } from '@x-otto/plugin'
|
|
15
|
+
import { openBrowser } from '@x-otto/shared'
|
|
16
|
+
import { SDK_VERSION } from '@x-otto/env'
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { platform, arch, release } from 'node:os'
|
|
19
|
+
import { resolvePulseSurveySink } from './src/pulse-survey-sink'
|
|
20
|
+
import { submitIssueViaGh, buildIssueUrl } from './src/issue-submitter'
|
|
21
|
+
import { resolveFeedbackPlatform, type FeedbackSettingsPort } from './src/platform-resolver'
|
|
22
|
+
import { createCodingAdapter } from './src/coding-adapter'
|
|
23
|
+
import { listGithubIssues } from './src/github-lister'
|
|
24
|
+
import { listCodingIssues } from './src/coding-lister'
|
|
25
|
+
import { aggregateIssueLists } from './src/issue-aggregator'
|
|
26
|
+
import { LocalJsonlResolvedMarksSink } from './src/resolved-sink'
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
FeedbackIssueInput,
|
|
30
|
+
PulseSurveyEntry,
|
|
31
|
+
PulseSurveyResolvedConfig,
|
|
32
|
+
PluginSlashCommandHandler,
|
|
33
|
+
} from '@x-otto/plugin'
|
|
34
|
+
|
|
35
|
+
/** RFC-207 §3.4:目标仓库解析优先级——settings `feedback_repo` > git remote origin > `--repo` 参数。 */
|
|
36
|
+
function resolveGitRemoteRepo(cwd: string): Promise<string | undefined> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = spawn('git', ['remote', 'get-url', 'origin'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
39
|
+
let out = ''
|
|
40
|
+
child.stdout?.on('data', (chunk) => { out += String(chunk) })
|
|
41
|
+
child.on('error', () => resolve(undefined))
|
|
42
|
+
child.on('exit', (code) => {
|
|
43
|
+
if (code !== 0) return resolve(undefined)
|
|
44
|
+
const url = out.trim()
|
|
45
|
+
// ssh: git@host:owner/repo.git / https: https://host/owner/repo.git → owner/repo
|
|
46
|
+
const m = url.match(/(?:[:/])([^/:]+)\/([^/]+?)(?:\.git)?$/)
|
|
47
|
+
resolve(m ? `${m[1]}/${m[2]}` : undefined)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** RFC-207 §3.1 环境信息行(脱敏——只含版本/运行时/OS/模型,不含路径/环境变量)。 */
|
|
53
|
+
function buildEnvInfo(modelId: string | undefined): string {
|
|
54
|
+
return [
|
|
55
|
+
`- otto version: ${SDK_VERSION}`,
|
|
56
|
+
`- Node: ${process.version}`,
|
|
57
|
+
`- OS: ${platform()} ${arch()} ${release()}`,
|
|
58
|
+
`- Model: ${modelId ?? '(none configured)'}`,
|
|
59
|
+
].join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* RFC-440:TUI 斜杠命令 /feedback 实现(自 RFC-207/209/430 的 cli handler 迁移——
|
|
64
|
+
* 编排留在插件:面板打开 → openInput 链 → 通道选择 submitIssueViaGh → 降级浏览器)。
|
|
65
|
+
* 只消费宿主注入的窄桥(ctx.tui/ctx.app),不依赖 cli 包任何符号。
|
|
66
|
+
* 具名导出供插件包单测直接调用(mock PluginSlashCommandContext)。
|
|
67
|
+
*/
|
|
68
|
+
export const handleFeedbackCommand: PluginSlashCommandHandler = async (ctx) => {
|
|
69
|
+
const args = ctx.args
|
|
70
|
+
// RFC-430 M3:裸 /feedback(无参数)→ 打开面板;带 --repo/--title/--browser → 走 openInput 提交。
|
|
71
|
+
const hasPanelArgs = args.some((a) => a === '--repo' || a === '--title' || a === '--browser')
|
|
72
|
+
if (!hasPanelArgs) {
|
|
73
|
+
const panel = ctx.app.pluginPanels.find((p) => p.pluginId === 'plugin-feedback')
|
|
74
|
+
if (panel) {
|
|
75
|
+
await ctx.tui.openPluginPanel({
|
|
76
|
+
title: panel.title,
|
|
77
|
+
modulePath: panel.modulePath,
|
|
78
|
+
exportName: panel.exportName,
|
|
79
|
+
pluginId: panel.pluginId,
|
|
80
|
+
})
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── openInput 提交流程(面板未注册或显式带参数时降级走此路径)──
|
|
86
|
+
// --repo <owner/repo> 命令行参数(RFC-207 §3.4 解析优先级的第三档)。
|
|
87
|
+
const repoArgIndex = args.indexOf('--repo')
|
|
88
|
+
const cliRepoArg = repoArgIndex >= 0 ? args[repoArgIndex + 1] : undefined
|
|
89
|
+
|
|
90
|
+
const configuredRepo = ctx.app.settings.get('feedback_repo')
|
|
91
|
+
const repo =
|
|
92
|
+
(typeof configuredRepo === 'string' && configuredRepo.length > 0 && configuredRepo) ||
|
|
93
|
+
(await resolveGitRemoteRepo(ctx.app.workspaceDir)) ||
|
|
94
|
+
cliRepoArg
|
|
95
|
+
if (!repo) {
|
|
96
|
+
ctx.tui.showToast('Feedback', '无法解析目标仓库——请在 .otto/config.json 中配置 "feedback_repo"')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const title = await ctx.tui.openInput({ title: 'Issue 标题' })
|
|
101
|
+
if (!title || title.trim().length === 0) return // 标题必填,取消/空输入即中止
|
|
102
|
+
|
|
103
|
+
const reproduce = (await ctx.tui.openInput({ title: '复现步骤' })) ?? undefined
|
|
104
|
+
const expected = (await ctx.tui.openInput({ title: '期望行为' })) ?? undefined
|
|
105
|
+
const actual = (await ctx.tui.openInput({ title: '实际行为' })) ?? undefined
|
|
106
|
+
|
|
107
|
+
const envInfo = buildEnvInfo(ctx.app.modelId)
|
|
108
|
+
const input: FeedbackIssueInput = { title: title.trim(), reproduce, expected, actual, envInfo, repo }
|
|
109
|
+
|
|
110
|
+
const bodyPreview = [reproduce, expected, actual].filter(Boolean).join('\n').slice(0, 300)
|
|
111
|
+
const confirmed = await ctx.tui.confirm('提交反馈?', `${input.title}\n\n${bodyPreview}`)
|
|
112
|
+
if (!confirmed) return
|
|
113
|
+
|
|
114
|
+
ctx.tui.showToast('Feedback', '正在提交…')
|
|
115
|
+
|
|
116
|
+
// 通道选择(RFC-207 §2.7/RFC-209 §2.4):优先 submitIssueViaGh,失败降级 buildIssueUrl + openBrowser。
|
|
117
|
+
const result = await submitIssueViaGh(input)
|
|
118
|
+
if (result.ok) {
|
|
119
|
+
ctx.tui.showToast('Feedback', `已创建: ${result.url}`)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const url = buildIssueUrl(input)
|
|
123
|
+
if (url) {
|
|
124
|
+
openBrowser(url)
|
|
125
|
+
ctx.tui.showToast('Feedback', `已打开: ${url}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default definePlugin((ctx) => {
|
|
130
|
+
// RFC-185 D3:日志走宿主注入端口(ctx.logger),不自建 createLogger——esbuild 会把
|
|
131
|
+
// @x-otto/shared logger bundle 成第二份 pino 单例直写 stderr 破坏 TUI。
|
|
132
|
+
const logger = { warn: (msg: string) => ctx.logger.warn(msg) }
|
|
133
|
+
const sink = resolvePulseSurveySink(undefined, logger)
|
|
134
|
+
const resolvedSink = new LocalJsonlResolvedMarksSink(logger)
|
|
135
|
+
|
|
136
|
+
// RFC-430 M1:settings 读取端口(宿主注入;未注入时全部 undefined —— 判定链落
|
|
137
|
+
// github、coding 源报 not configured,全部 fail-soft)。settings 值面是宿主权威,
|
|
138
|
+
// 插件只做窄读取,不缓存(每次调用现读,settings.changed 后自然生效)。
|
|
139
|
+
// `host.getSettings` 按契约返回 `unknown`(宿主不做类型保证)——插件侧自行运行时
|
|
140
|
+
// 收窄,非法值落 undefined(fail-soft,判定链据此降级)。
|
|
141
|
+
const readStringSetting = (key: string): string | undefined => {
|
|
142
|
+
const v = ctx.host?.getSettings?.(key)
|
|
143
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined
|
|
144
|
+
}
|
|
145
|
+
const settingsPort: FeedbackSettingsPort = {
|
|
146
|
+
getFeedbackRepo: () => readStringSetting('feedback_repo'),
|
|
147
|
+
getFeedbackPlatform: () => {
|
|
148
|
+
const v = ctx.host?.getSettings?.('feedback_platform')
|
|
149
|
+
return v === 'auto' || v === 'github' || v === 'coding' ? v : undefined
|
|
150
|
+
},
|
|
151
|
+
getFeedbackCodingBaseUrl: () => readStringSetting('feedback_coding_base_url'),
|
|
152
|
+
getFeedbackCodingRepo: () => readStringSetting('feedback_coding_repo'),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Coding 适配器单实例(listIssues 与 buildCodingIssueUrl 共享同一份配置快照语义——
|
|
156
|
+
// 每次调用时读取 settings 现值,配置变更无需重载插件)。
|
|
157
|
+
const codingAdapter = createCodingAdapter({
|
|
158
|
+
get baseUrl() {
|
|
159
|
+
return settingsPort.getFeedbackCodingBaseUrl()
|
|
160
|
+
},
|
|
161
|
+
get repo() {
|
|
162
|
+
return settingsPort.getFeedbackCodingRepo()
|
|
163
|
+
},
|
|
164
|
+
logger,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
// RFC-440:TUI 斜杠命令 /feedback(宿主注入窄桥执行,命令名须与
|
|
169
|
+
// contributes.slashCommands[].name 对应)。
|
|
170
|
+
slashCommands: { feedback: handleFeedbackCommand },
|
|
171
|
+
feedbackProviders: {
|
|
172
|
+
recordPulseSurvey: (entry: PulseSurveyEntry) => sink.record(entry),
|
|
173
|
+
resolvePulseSurveyConfig: (raw: unknown): PulseSurveyResolvedConfig => {
|
|
174
|
+
const cfg = (raw ?? {}) as { enabled?: boolean; probability?: number; min_turn_gap?: number }
|
|
175
|
+
return {
|
|
176
|
+
enabled: cfg.enabled ?? true,
|
|
177
|
+
probability: cfg.probability ?? 0.05,
|
|
178
|
+
minTurnGap: cfg.min_turn_gap ?? 20,
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
submitIssueViaGh: (input: FeedbackIssueInput) => submitIssueViaGh(input, logger),
|
|
182
|
+
buildIssueUrl,
|
|
183
|
+
|
|
184
|
+
// ── RFC-430 M1:双平台反馈面板方法面 ──
|
|
185
|
+
listIssues: (opts) =>
|
|
186
|
+
aggregateIssueLists(opts, {
|
|
187
|
+
github: (o) => listGithubIssues(settingsPort.getFeedbackRepo(), o, { logger }),
|
|
188
|
+
coding: (o) => listCodingIssues(o, { adapter: codingAdapter }),
|
|
189
|
+
}),
|
|
190
|
+
resolveFeedbackPlatform: () => resolveFeedbackPlatform(settingsPort, logger),
|
|
191
|
+
markIssueResolved: (mark) => Promise.resolve(resolvedSink.mark(mark)),
|
|
192
|
+
unmarkIssueResolved: (source, platformIssueId) =>
|
|
193
|
+
Promise.resolve(resolvedSink.unmark(source, platformIssueId)),
|
|
194
|
+
listResolvedMarks: () => Promise.resolve(resolvedSink.list()),
|
|
195
|
+
buildCodingIssueUrl: (input: FeedbackIssueInput) => codingAdapter.buildCodingIssueUrl(input),
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coding-adapter.ts — RFC-430 §2.6:Coding 平台(企业内网代码托管)适配器。
|
|
3
|
+
*
|
|
4
|
+
* 配置驱动(全部运行时注入,公网源码零内网域名硬编码——scan-public-sensitive.mjs 门禁):
|
|
5
|
+
* - baseUrl:settings `feedback_coding_base_url`(如企业自建托管的 https 地址);
|
|
6
|
+
* - repo:settings `feedback_coding_repo`;
|
|
7
|
+
* - token:`OTTO_FEEDBACK_CODING_TOKEN` 环境变量(**绝不落 settings 明文、绝不进日志**)。
|
|
8
|
+
*
|
|
9
|
+
* fail-soft 纪律(与 pulse-survey-sink 一致):所有失败路径(未配置/无 token/网络错误/
|
|
10
|
+
* 非 2xx/JSON 解析失败)返回 `{ ok: false, reason }`,**不抛出、不穿透**;reason 只携带
|
|
11
|
+
* HTTP 状态码与失败类别,不回显 URL 以外的任何服务端响应体(防 token/内部信息经日志外泄)。
|
|
12
|
+
*/
|
|
13
|
+
import type { FeedbackIssueInput, FeedbackIssueItem } from '@x-otto/plugin'
|
|
14
|
+
import type { FeedbackLoggerPort } from './pulse-survey-sink'
|
|
15
|
+
|
|
16
|
+
/** token 环境变量名(集中定义,杜绝散写)。 */
|
|
17
|
+
export const CODING_TOKEN_ENV = 'OTTO_FEEDBACK_CODING_TOKEN'
|
|
18
|
+
|
|
19
|
+
const noopLogger: FeedbackLoggerPort = { warn: () => {} }
|
|
20
|
+
|
|
21
|
+
/** fetch 注入点(测试 mock;生产缺省 globalThis.fetch)。 */
|
|
22
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
|
|
23
|
+
|
|
24
|
+
export interface CodingAdapterOptions {
|
|
25
|
+
baseUrl?: string | undefined
|
|
26
|
+
repo?: string | undefined
|
|
27
|
+
logger?: FeedbackLoggerPort
|
|
28
|
+
fetchImpl?: FetchLike
|
|
29
|
+
/** 单次请求超时(ms),缺省 8000。 */
|
|
30
|
+
timeoutMs?: number
|
|
31
|
+
/** 环境变量读取注入点(测试),缺省 process.env。 */
|
|
32
|
+
env?: Record<string, string | undefined>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CodingAdapter {
|
|
36
|
+
/** 拉取 issue 列表并归一化为 FeedbackIssueItem[];任何失败 fail-soft。 */
|
|
37
|
+
listIssues(opts?: { state?: 'open' | 'closed' | 'all'; limit?: number }): Promise<
|
|
38
|
+
{ ok: true; items: FeedbackIssueItem[] } | { ok: false; reason: string }
|
|
39
|
+
>
|
|
40
|
+
/** 构造新建 issue 预填 URL(纯函数);未配置 baseUrl/repo 返回 null。 */
|
|
41
|
+
buildCodingIssueUrl(input: FeedbackIssueInput): string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 归一化 baseUrl:去尾斜杠;非法值返回 undefined。 */
|
|
45
|
+
function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
|
|
46
|
+
if (!baseUrl) return undefined
|
|
47
|
+
const trimmed = baseUrl.trim().replace(/\/+$/, '')
|
|
48
|
+
return /^https?:\/\//.test(trimmed) ? trimmed : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Coding 列表端点(best-effort 通用形态,具体企业部署可在 M2 按真实 API 对齐)。 */
|
|
52
|
+
function codingIssuesUrl(baseUrl: string, repo: string, state: 'open' | 'closed' | 'all', limit: number): string {
|
|
53
|
+
const params = new URLSearchParams({ state, per_page: String(limit) })
|
|
54
|
+
return `${baseUrl}/api/v1/repos/${encodeURIComponent(repo)}/issues?${params.toString()}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 宽容归一化:Coding 列表条目字段名按常见托管 API 形态尽力映射,缺字段降级
|
|
59
|
+
* undefined,绝不抛(坏条目整条跳过——一条脏数据不应拖垮整个列表)。
|
|
60
|
+
*/
|
|
61
|
+
function normalizeCodingEntry(raw: unknown, baseUrl: string, repo: string): FeedbackIssueItem | undefined {
|
|
62
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
63
|
+
const r = raw as Record<string, unknown>
|
|
64
|
+
const idRaw = r['number'] ?? r['id'] ?? r['iid'] ?? r['code']
|
|
65
|
+
const titleRaw = r['title'] ?? r['name']
|
|
66
|
+
if (idRaw === undefined || idRaw === null || typeof titleRaw !== 'string') return undefined
|
|
67
|
+
const stateRaw = typeof r['state'] === 'string' ? r['state'].toLowerCase() : ''
|
|
68
|
+
const state: 'open' | 'closed' =
|
|
69
|
+
stateRaw === 'closed' || stateRaw === 'resolved' || stateRaw === 'completed' ? 'closed' : 'open'
|
|
70
|
+
const url =
|
|
71
|
+
typeof r['url'] === 'string' ? r['url']
|
|
72
|
+
: typeof r['html_url'] === 'string' ? r['html_url']
|
|
73
|
+
: typeof r['web_url'] === 'string' ? r['web_url']
|
|
74
|
+
: `${baseUrl}/${repo}/issues/${String(idRaw)}`
|
|
75
|
+
const authorRaw = r['author'] ?? r['creator'] ?? r['user']
|
|
76
|
+
const author =
|
|
77
|
+
typeof authorRaw === 'string' ? authorRaw
|
|
78
|
+
: typeof authorRaw === 'object' && authorRaw !== null
|
|
79
|
+
? (() => {
|
|
80
|
+
const a = authorRaw as Record<string, unknown>
|
|
81
|
+
return typeof a['login'] === 'string' ? a['login'] : typeof a['name'] === 'string' ? a['name'] : undefined
|
|
82
|
+
})()
|
|
83
|
+
: undefined
|
|
84
|
+
const labelsRaw = r['labels']
|
|
85
|
+
const labels = Array.isArray(labelsRaw)
|
|
86
|
+
? labelsRaw
|
|
87
|
+
.map((l) => (typeof l === 'string' ? l : typeof l === 'object' && l !== null ? (l as Record<string, unknown>)['name'] : undefined))
|
|
88
|
+
.filter((l): l is string => typeof l === 'string')
|
|
89
|
+
: undefined
|
|
90
|
+
const body = typeof r['body'] === 'string' ? r['body'] : typeof r['description'] === 'string' ? r['description'] : undefined
|
|
91
|
+
return {
|
|
92
|
+
source: 'coding',
|
|
93
|
+
platformIssueId: String(idRaw),
|
|
94
|
+
title: titleRaw,
|
|
95
|
+
state,
|
|
96
|
+
url,
|
|
97
|
+
...(author ? { author } : {}),
|
|
98
|
+
...(typeof r['created_at'] === 'string' ? { createdAt: r['created_at'] } : {}),
|
|
99
|
+
...(typeof r['updated_at'] === 'string' ? { updatedAt: r['updated_at'] } : {}),
|
|
100
|
+
kind: 'issue',
|
|
101
|
+
...(body ? { excerpt: body.slice(0, 140) } : {}),
|
|
102
|
+
...(labels && labels.length > 0 ? { labels } : {}),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createCodingAdapter(options: CodingAdapterOptions): CodingAdapter {
|
|
107
|
+
const logger = options.logger ?? noopLogger
|
|
108
|
+
const env = options.env ?? process.env
|
|
109
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl)
|
|
110
|
+
const repo = options.repo?.trim() || undefined
|
|
111
|
+
const timeoutMs = options.timeoutMs ?? 8000
|
|
112
|
+
|
|
113
|
+
const fetchImpl: FetchLike | undefined =
|
|
114
|
+
options.fetchImpl ??
|
|
115
|
+
(typeof globalThis.fetch === 'function' ? (globalThis.fetch as unknown as FetchLike) : undefined)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
async listIssues(opts) {
|
|
119
|
+
if (!baseUrl || !repo) return { ok: false, reason: 'coding not configured' }
|
|
120
|
+
const token = env[CODING_TOKEN_ENV]
|
|
121
|
+
if (!token) return { ok: false, reason: `token missing (${CODING_TOKEN_ENV})` }
|
|
122
|
+
if (!fetchImpl) return { ok: false, reason: 'fetch unavailable' }
|
|
123
|
+
|
|
124
|
+
const state = opts?.state ?? 'open'
|
|
125
|
+
const limit = opts?.limit ?? 20
|
|
126
|
+
const controller = new AbortController()
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetchImpl(codingIssuesUrl(baseUrl, repo, state, limit), {
|
|
130
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
131
|
+
signal: controller.signal,
|
|
132
|
+
})
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
// 只带 HTTP 状态码——响应体可能含内部信息,不进 reason/日志。
|
|
135
|
+
return { ok: false, reason: `http ${res.status}` }
|
|
136
|
+
}
|
|
137
|
+
let data: unknown
|
|
138
|
+
try {
|
|
139
|
+
data = await res.json()
|
|
140
|
+
} catch {
|
|
141
|
+
return { ok: false, reason: 'invalid json' }
|
|
142
|
+
}
|
|
143
|
+
const entries = Array.isArray(data)
|
|
144
|
+
? data
|
|
145
|
+
: typeof data === 'object' && data !== null && Array.isArray((data as Record<string, unknown>)['items'])
|
|
146
|
+
? ((data as Record<string, unknown>)['items'] as unknown[])
|
|
147
|
+
: []
|
|
148
|
+
const items = entries
|
|
149
|
+
.map((e) => normalizeCodingEntry(e, baseUrl, repo))
|
|
150
|
+
.filter((e): e is FeedbackIssueItem => e !== undefined)
|
|
151
|
+
return { ok: true, items }
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const reason = controller.signal.aborted
|
|
154
|
+
? `timeout after ${timeoutMs}ms`
|
|
155
|
+
: `network error: ${error instanceof Error ? error.name : 'unknown'}`
|
|
156
|
+
logger.warn(`coding listIssues failed (fail-soft): ${reason}`)
|
|
157
|
+
return { ok: false, reason }
|
|
158
|
+
} finally {
|
|
159
|
+
clearTimeout(timer)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
buildCodingIssueUrl(input) {
|
|
164
|
+
if (!baseUrl || !repo) return null
|
|
165
|
+
const title = `[otto] ${input.title}`
|
|
166
|
+
const body = [
|
|
167
|
+
'## Reproduction Steps',
|
|
168
|
+
input.reproduce || '*(not provided)*',
|
|
169
|
+
'',
|
|
170
|
+
'## Expected Behavior',
|
|
171
|
+
input.expected || '*(not provided)*',
|
|
172
|
+
'',
|
|
173
|
+
'## Actual Behavior',
|
|
174
|
+
input.actual || '*(not provided)*',
|
|
175
|
+
'',
|
|
176
|
+
'## Environment',
|
|
177
|
+
input.envInfo,
|
|
178
|
+
].join('\\n')
|
|
179
|
+
const params = new URLSearchParams({ title, body })
|
|
180
|
+
return `${baseUrl}/${repo}/issues/new?${params.toString()}`
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coding-lister.ts — RFC-430 §2.6:Coding 源列表拉取的归一化出口。
|
|
3
|
+
*
|
|
4
|
+
* 薄封装层:经 `createCodingAdapter`(coding-adapter.ts,配置/凭据/容错全部内聚在
|
|
5
|
+
* 适配器里)拉取并归一化为 `FeedbackIssueItem[]`。独立成文件是为了让
|
|
6
|
+
* issue-aggregator 对两个源(github-lister/coding-lister)看到同构的
|
|
7
|
+
* `(opts) => Promise<{ok, items|reason}>` 形状,聚合层无需感知适配器细节。
|
|
8
|
+
*/
|
|
9
|
+
import type { FeedbackIssueItem } from '@x-otto/plugin'
|
|
10
|
+
import { createCodingAdapter, type CodingAdapter, type CodingAdapterOptions } from './coding-adapter'
|
|
11
|
+
|
|
12
|
+
export type CodingListResult = { ok: true; items: FeedbackIssueItem[] } | { ok: false; reason: string }
|
|
13
|
+
|
|
14
|
+
export interface CodingListerOptions extends CodingAdapterOptions {
|
|
15
|
+
/** 复用外部已构造的 adapter(plugin.ts 单实例共享 buildCodingIssueUrl);缺省内部构造。 */
|
|
16
|
+
adapter?: CodingAdapter
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Coding 列表主入口(与 listGithubIssues 同构的失败语义:fail-soft 不抛)。 */
|
|
20
|
+
export async function listCodingIssues(
|
|
21
|
+
opts: { state?: 'open' | 'closed' | 'all'; limit?: number } = {},
|
|
22
|
+
options: CodingListerOptions = {},
|
|
23
|
+
): Promise<CodingListResult> {
|
|
24
|
+
const adapter = options.adapter ?? createCodingAdapter(options)
|
|
25
|
+
return adapter.listIssues(opts)
|
|
26
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* github-lister.ts — RFC-430 §2.5:GitHub issue 列表双通道拉取。
|
|
3
|
+
*
|
|
4
|
+
* 通道优先级:
|
|
5
|
+
* 1. `gh issue list --json … --repo <repo>`(spawn 数组传参 + buildAllowedEnv +
|
|
6
|
+
* 超时,gh 探测复用 issue-submitter 的 gh 探测纪律)——本机已认证
|
|
7
|
+
* gh 的用户零额外配置;
|
|
8
|
+
* 2. gh 不可用(未安装/未登录/超时)→ 回落 REST
|
|
9
|
+
* `GET https://api.github.com/repos/{repo}/issues`(`OTTO_FEEDBACK_GITHUB_TOKEN`
|
|
10
|
+
* env 存在时带 Authorization;缺 token 时匿名请求可能被限流,仍尽力尝试)。
|
|
11
|
+
*
|
|
12
|
+
* fail-soft:任何失败返回 `{ ok: false, reason }` 不抛出;JSON 解析容错(缺字段降级
|
|
13
|
+
* undefined,坏条目跳过)。token 只经 env 注入,不进日志(reason 只带 HTTP 状态码)。
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
|
|
17
|
+
import { buildAllowedEnv } from '@x-otto/shared'
|
|
18
|
+
|
|
19
|
+
import type { FeedbackIssueItem } from '@x-otto/plugin'
|
|
20
|
+
import type { FeedbackLoggerPort } from './pulse-survey-sink'
|
|
21
|
+
|
|
22
|
+
/** token 环境变量名(集中定义,杜绝散写)。 */
|
|
23
|
+
export const GITHUB_TOKEN_ENV = 'OTTO_FEEDBACK_GITHUB_TOKEN'
|
|
24
|
+
|
|
25
|
+
/** gh 探测/列表子进程超时(对齐 issue-submitter 的 GH_AUTH_STATUS_TIMEOUT_MS 纪律)。 */
|
|
26
|
+
const GH_TIMEOUT_MS = 5000
|
|
27
|
+
|
|
28
|
+
const noopLogger: FeedbackLoggerPort = { warn: () => {} }
|
|
29
|
+
|
|
30
|
+
export type GhListResult = { ok: true; items: FeedbackIssueItem[] } | { ok: false; reason: string }
|
|
31
|
+
|
|
32
|
+
export interface GithubListerDeps {
|
|
33
|
+
logger?: FeedbackLoggerPort
|
|
34
|
+
/** fetch 注入点(测试 mock);缺省 globalThis.fetch。 */
|
|
35
|
+
fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>
|
|
36
|
+
/** spawn 注入点(测试 mock);缺省 node:child_process spawn。 */
|
|
37
|
+
spawnImpl?: typeof spawn
|
|
38
|
+
/** REST 单次请求超时(ms),缺省 8000。 */
|
|
39
|
+
restTimeoutMs?: number
|
|
40
|
+
env?: Record<string, string | undefined>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface GhJsonEntry {
|
|
44
|
+
number?: number
|
|
45
|
+
title?: string
|
|
46
|
+
state?: string
|
|
47
|
+
url?: string
|
|
48
|
+
author?: { login?: string }
|
|
49
|
+
createdAt?: string
|
|
50
|
+
updatedAt?: string
|
|
51
|
+
labels?: Array<{ name?: string }>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeGhEntry(raw: GhJsonEntry, repo: string): FeedbackIssueItem | undefined {
|
|
55
|
+
if (typeof raw.number !== 'number' || typeof raw.title !== 'string') return undefined
|
|
56
|
+
const state: 'open' | 'closed' = raw.state?.toLowerCase() === 'closed' ? 'closed' : 'open'
|
|
57
|
+
return {
|
|
58
|
+
source: 'github',
|
|
59
|
+
platformIssueId: String(raw.number),
|
|
60
|
+
title: raw.title,
|
|
61
|
+
state,
|
|
62
|
+
url: typeof raw.url === 'string' ? raw.url : `https://github.com/${repo}/issues/${raw.number}`,
|
|
63
|
+
...(typeof raw.author?.login === 'string' ? { author: raw.author.login } : {}),
|
|
64
|
+
...(typeof raw.createdAt === 'string' ? { createdAt: raw.createdAt } : {}),
|
|
65
|
+
...(typeof raw.updatedAt === 'string' ? { updatedAt: raw.updatedAt } : {}),
|
|
66
|
+
kind: 'issue',
|
|
67
|
+
...(Array.isArray(raw.labels)
|
|
68
|
+
? { labels: raw.labels.map((l) => l?.name).filter((n): n is string => typeof n === 'string') }
|
|
69
|
+
: {}),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runGhIssueList(
|
|
74
|
+
repo: string,
|
|
75
|
+
state: 'open' | 'closed' | 'all',
|
|
76
|
+
limit: number,
|
|
77
|
+
spawnImpl: typeof spawn,
|
|
78
|
+
): Promise<{ ok: true; stdout: string } | { ok: false; reason: string }> {
|
|
79
|
+
return new Promise((resolvePromise) => {
|
|
80
|
+
let settled = false
|
|
81
|
+
const settle = (v: { ok: true; stdout: string } | { ok: false; reason: string }): void => {
|
|
82
|
+
if (settled) return
|
|
83
|
+
settled = true
|
|
84
|
+
resolvePromise(v)
|
|
85
|
+
}
|
|
86
|
+
let child: ReturnType<typeof spawn>
|
|
87
|
+
try {
|
|
88
|
+
child = spawnImpl(
|
|
89
|
+
'gh',
|
|
90
|
+
[
|
|
91
|
+
'issue', 'list',
|
|
92
|
+
'--repo', repo,
|
|
93
|
+
'--state', state,
|
|
94
|
+
'--limit', String(limit),
|
|
95
|
+
'--json', 'number,title,state,url,author,createdAt,updatedAt,labels',
|
|
96
|
+
],
|
|
97
|
+
{ env: buildAllowedEnv() },
|
|
98
|
+
)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
settle({ ok: false, reason: `gh spawn failed: ${String(error)}` })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
let stdout = ''
|
|
104
|
+
let stderr = ''
|
|
105
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk) })
|
|
106
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
child.kill('SIGTERM')
|
|
109
|
+
settle({ ok: false, reason: `gh timeout after ${GH_TIMEOUT_MS}ms` })
|
|
110
|
+
}, GH_TIMEOUT_MS)
|
|
111
|
+
child.on('error', (err) => {
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
settle({ ok: false, reason: `gh unavailable: ${String(err)}` })
|
|
114
|
+
})
|
|
115
|
+
child.on('exit', (code) => {
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
if (code === 0) settle({ ok: true, stdout })
|
|
118
|
+
// stderr 可能含远端信息——reason 只带 exit code,不回显 stderr 全文。
|
|
119
|
+
else settle({ ok: false, reason: `gh exit ${String(code)}: ${stderr.slice(0, 120)}` })
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** REST 回落通道:api.github.com/repos/{repo}/issues(PR 条目经 pull_request 字段剔除)。 */
|
|
125
|
+
async function listViaRest(
|
|
126
|
+
repo: string,
|
|
127
|
+
state: 'open' | 'closed' | 'all',
|
|
128
|
+
limit: number,
|
|
129
|
+
deps: GithubListerDeps,
|
|
130
|
+
): Promise<GhListResult> {
|
|
131
|
+
const fetchImpl =
|
|
132
|
+
deps.fetchImpl ??
|
|
133
|
+
(typeof globalThis.fetch === 'function' ? (globalThis.fetch as unknown as GithubListerDeps['fetchImpl']) : undefined)
|
|
134
|
+
if (!fetchImpl) return { ok: false, reason: 'fetch unavailable' }
|
|
135
|
+
const env = deps.env ?? process.env
|
|
136
|
+
const token = env[GITHUB_TOKEN_ENV]
|
|
137
|
+
const timeoutMs = deps.restTimeoutMs ?? 8000
|
|
138
|
+
const params = new URLSearchParams({ state, per_page: String(limit) })
|
|
139
|
+
const controller = new AbortController()
|
|
140
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetchImpl(`https://api.github.com/repos/${repo}/issues?${params.toString()}`, {
|
|
143
|
+
headers: {
|
|
144
|
+
Accept: 'application/vnd.github+json',
|
|
145
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
146
|
+
},
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
})
|
|
149
|
+
if (!res.ok) return { ok: false, reason: `http ${res.status}` }
|
|
150
|
+
let data: unknown
|
|
151
|
+
try {
|
|
152
|
+
data = await res.json()
|
|
153
|
+
} catch {
|
|
154
|
+
return { ok: false, reason: 'invalid json' }
|
|
155
|
+
}
|
|
156
|
+
if (!Array.isArray(data)) return { ok: false, reason: 'unexpected payload shape' }
|
|
157
|
+
const items: FeedbackIssueItem[] = []
|
|
158
|
+
for (const raw of data) {
|
|
159
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
160
|
+
const r = raw as Record<string, unknown>
|
|
161
|
+
// GitHub /issues 端点混入 PR——kind 词表无 'pr',跳过。
|
|
162
|
+
if (r['pull_request'] !== undefined) continue
|
|
163
|
+
const labelsRaw = r['labels']
|
|
164
|
+
const item: FeedbackIssueItem = {
|
|
165
|
+
source: 'github',
|
|
166
|
+
platformIssueId: String(r['number'] ?? ''),
|
|
167
|
+
title: typeof r['title'] === 'string' ? r['title'] : '',
|
|
168
|
+
state: r['state'] === 'closed' ? 'closed' : 'open',
|
|
169
|
+
url: typeof r['html_url'] === 'string' ? r['html_url'] : `https://github.com/${repo}/issues/${String(r['number'] ?? '')}`,
|
|
170
|
+
kind: 'issue',
|
|
171
|
+
}
|
|
172
|
+
if (!item.platformIssueId || !item.title) continue
|
|
173
|
+
const user = r['user']
|
|
174
|
+
if (typeof user === 'object' && user !== null && typeof (user as Record<string, unknown>)['login'] === 'string') {
|
|
175
|
+
item.author = (user as Record<string, unknown>)['login'] as string
|
|
176
|
+
}
|
|
177
|
+
if (typeof r['created_at'] === 'string') item.createdAt = r['created_at']
|
|
178
|
+
if (typeof r['updated_at'] === 'string') item.updatedAt = r['updated_at']
|
|
179
|
+
if (typeof r['body'] === 'string' && r['body']) item.excerpt = r['body'].slice(0, 140)
|
|
180
|
+
if (Array.isArray(labelsRaw)) {
|
|
181
|
+
item.labels = labelsRaw
|
|
182
|
+
.map((l) => (typeof l === 'object' && l !== null ? (l as Record<string, unknown>)['name'] : undefined))
|
|
183
|
+
.filter((n): n is string => typeof n === 'string')
|
|
184
|
+
}
|
|
185
|
+
items.push(item)
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, items }
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const reason = controller.signal.aborted
|
|
190
|
+
? `timeout after ${timeoutMs}ms`
|
|
191
|
+
: `network error: ${error instanceof Error ? error.name : 'unknown'}`
|
|
192
|
+
return { ok: false, reason }
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(timer)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* GitHub 列表主入口:gh CLI 优先,gh 不可用时回落 REST。`repo` 为空(未配置
|
|
200
|
+
* feedback_repo 且宿主未解析出 git remote)时直接失败,不发起任何调用。
|
|
201
|
+
*/
|
|
202
|
+
export async function listGithubIssues(
|
|
203
|
+
repo: string | undefined,
|
|
204
|
+
opts: { state?: 'open' | 'closed' | 'all'; limit?: number } = {},
|
|
205
|
+
deps: GithubListerDeps = {},
|
|
206
|
+
): Promise<GhListResult> {
|
|
207
|
+
const logger = deps.logger ?? noopLogger
|
|
208
|
+
if (!repo) return { ok: false, reason: 'feedback_repo not configured' }
|
|
209
|
+
const state = opts.state ?? 'open'
|
|
210
|
+
const limit = opts.limit ?? 20
|
|
211
|
+
const spawnImpl = deps.spawnImpl ?? spawn
|
|
212
|
+
|
|
213
|
+
const ghResult = await runGhIssueList(repo, state, limit, spawnImpl)
|
|
214
|
+
if (ghResult.ok) {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(ghResult.stdout) as unknown
|
|
217
|
+
if (!Array.isArray(parsed)) return { ok: false, reason: 'gh output not a json array' }
|
|
218
|
+
const items = parsed
|
|
219
|
+
.map((e) => normalizeGhEntry(e as GhJsonEntry, repo))
|
|
220
|
+
.filter((e): e is FeedbackIssueItem => e !== undefined)
|
|
221
|
+
return { ok: true, items }
|
|
222
|
+
} catch {
|
|
223
|
+
return { ok: false, reason: 'gh output invalid json' }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// gh 不可用 → REST 回落(token 存在时带 Authorization,缺省匿名尽力尝试)。
|
|
228
|
+
logger.warn(`gh issue list unavailable, falling back to REST: ${ghResult.reason}`)
|
|
229
|
+
return listViaRest(repo, state, limit, deps)
|
|
230
|
+
}
|