paseo-req-kit 1.8.0 → 1.9.0-beta.2
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/CHANGELOG.md +38 -0
- package/README.md +2 -0
- package/bin/req-plugin.mjs +226 -0
- package/bin/req.mjs +27 -0
- package/contracts/README.md +2 -0
- package/contracts/cli-json.md +66 -0
- package/contracts/usage-record.md +48 -0
- package/docs/adr/0026-machine-records-and-api.md +23 -0
- package/docs/adr/0027-paseo-plugin.md +23 -0
- package/docs/adr/README.md +2 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +26 -0
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +3 -1
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +10 -0
- package/engine/src/api.ts +397 -0
- package/engine/src/control.ts +2 -2
- package/engine/src/eval.ts +2 -0
- package/engine/src/index-cmd.ts +33 -0
- package/engine/src/main.ts +26 -0
- package/engine/src/records.ts +271 -0
- package/engine/src/state.ts +3 -0
- package/engine/src/stats.ts +479 -0
- package/install.mjs +3 -0
- package/package.json +3 -2
- package/paseo-plugin/OVERVIEW.md +7 -0
- package/paseo-plugin/client/data.ts +55 -0
- package/paseo-plugin/client/detail.tsx +277 -0
- package/paseo-plugin/client/format.ts +101 -0
- package/paseo-plugin/client/live.ts +161 -0
- package/paseo-plugin/client/overview.tsx +89 -0
- package/paseo-plugin/client/panel.tsx +56 -0
- package/paseo-plugin/client/row.tsx +40 -0
- package/paseo-plugin/client/settings.tsx +90 -0
- package/paseo-plugin/client/sidebar.tsx +27 -0
- package/paseo-plugin/client/stats.tsx +80 -0
- package/paseo-plugin/client/ui.tsx +111 -0
- package/paseo-plugin/index.client.tsx +52 -0
- package/paseo-plugin/index.server.ts +41 -0
- package/paseo-plugin/package.json +22 -0
- package/paseo-plugin/paseo-plugin.json +6 -0
- package/paseo-plugin/server/handlers.ts +45 -0
- package/paseo-plugin/server/req.ts +100 -0
- package/paseo-plugin/shared/contracts.ts +165 -0
- package/paseo-plugin/tsconfig.json +16 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// One requirement in a list: title, facts, stage, what is running, what needs a person.
|
|
2
|
+
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Pressable, Text, View } from "react-native";
|
|
4
|
+
import type { Requirement } from "../shared/contracts";
|
|
5
|
+
import { ago, ATTENTION_LABELS, facts } from "./format";
|
|
6
|
+
import { Badge, useStyles } from "./ui";
|
|
7
|
+
|
|
8
|
+
export function RequirementRow({
|
|
9
|
+
theme,
|
|
10
|
+
layout,
|
|
11
|
+
item,
|
|
12
|
+
onPress,
|
|
13
|
+
}: Pick<PluginHostProps, "theme" | "layout"> & { item: Requirement; onPress(): void }) {
|
|
14
|
+
const s = useStyles(theme, layout.compact);
|
|
15
|
+
return (
|
|
16
|
+
<Pressable accessibilityRole="button" accessibilityLabel={item.title} onPress={onPress} style={s.card}>
|
|
17
|
+
<View style={s.row}>
|
|
18
|
+
<Text style={[s.text, { fontWeight: "600", flexShrink: 1 }]}>{item.title}</Text>
|
|
19
|
+
{item.attention ? (
|
|
20
|
+
<Badge theme={theme} tone={item.attention === "approval" || item.attention === "business" ? "warning" : "danger"}>
|
|
21
|
+
{ATTENTION_LABELS[item.attention]}
|
|
22
|
+
</Badge>
|
|
23
|
+
) : item.running ? (
|
|
24
|
+
<Badge theme={theme} tone="accent">
|
|
25
|
+
运行中
|
|
26
|
+
</Badge>
|
|
27
|
+
) : null}
|
|
28
|
+
{!item.present ? (
|
|
29
|
+
<Badge theme={theme} tone="muted">
|
|
30
|
+
目录已不在
|
|
31
|
+
</Badge>
|
|
32
|
+
) : null}
|
|
33
|
+
</View>
|
|
34
|
+
<Text style={s.muted}>{facts(item)}</Text>
|
|
35
|
+
<Text style={s.text}>{item.stage}</Text>
|
|
36
|
+
{item.current.length > 0 ? <Text style={s.muted}>正在:{item.current.map((c) => c.title).join("、")}</Text> : null}
|
|
37
|
+
<Text style={s.muted}>更新于 {ago(item.updatedAt)}</Text>
|
|
38
|
+
</Pressable>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Settings → Plugins → req-kit: what the plugin found, read-only mode, refresh interval.
|
|
2
|
+
import { type PluginSurfaceProps, type SettingsState, useSettings } from "@getpaseo/plugin/client";
|
|
3
|
+
import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSelect, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Text } from "react-native";
|
|
6
|
+
import { preferences } from "../shared/contracts";
|
|
7
|
+
import { useKitInfo } from "./data";
|
|
8
|
+
|
|
9
|
+
type Ready = Extract<SettingsState<typeof preferences.schema>, { status: "ready" }>;
|
|
10
|
+
|
|
11
|
+
const REFRESH = [
|
|
12
|
+
{ label: "5 秒", value: "5" },
|
|
13
|
+
{ label: "10 秒", value: "10" },
|
|
14
|
+
{ label: "30 秒", value: "30" },
|
|
15
|
+
{ label: "1 分钟", value: "60" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function Controls({ settings, theme }: { settings: Ready; theme: PluginSurfaceProps["theme"] }) {
|
|
19
|
+
const [kitHome, setKitHome] = useState(settings.values.kitHome);
|
|
20
|
+
const info = useKitInfo();
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<SettingsSection title="req-kit">
|
|
24
|
+
<SettingsCard>
|
|
25
|
+
{info.data ? (
|
|
26
|
+
<>
|
|
27
|
+
<SettingsRow label="版本" hint={info.data.kitRoot}>
|
|
28
|
+
<Text style={{ color: theme.colors.foreground }}>{info.data.kitVersion}</Text>
|
|
29
|
+
</SettingsRow>
|
|
30
|
+
<SettingsRow label="支持的 Paseo" hint={`本机配置目录:${info.data.kitHome}`}>
|
|
31
|
+
<Text style={{ color: theme.colors.foreground }}>{info.data.paseo ?? "-"}</Text>
|
|
32
|
+
</SettingsRow>
|
|
33
|
+
</>
|
|
34
|
+
) : (
|
|
35
|
+
<SettingsRow label="状态" error={info.error?.message ?? undefined} hint={info.isPending ? "正在读取…" : undefined} />
|
|
36
|
+
)}
|
|
37
|
+
<SettingsInput
|
|
38
|
+
label="req-kit 的本机配置目录"
|
|
39
|
+
hint="通常不用填:默认是运行 Paseo 的用户的 ~/.req-kit。设置了 REQ_KIT_HOME 时填那个目录。"
|
|
40
|
+
initialValue={settings.values.kitHome}
|
|
41
|
+
placeholder="~/.req-kit"
|
|
42
|
+
onChangeText={setKitHome}
|
|
43
|
+
disabled={settings.saving}
|
|
44
|
+
error={settings.saveError ?? undefined}
|
|
45
|
+
/>
|
|
46
|
+
<SettingsAction
|
|
47
|
+
label="保存配置目录"
|
|
48
|
+
actionLabel="保存"
|
|
49
|
+
disabled={settings.saving || kitHome === settings.values.kitHome}
|
|
50
|
+
onPress={() => void settings.save({ ...settings.values, kitHome: kitHome.trim() }, settings.revision).then(() => info.refetch())}
|
|
51
|
+
/>
|
|
52
|
+
</SettingsCard>
|
|
53
|
+
</SettingsSection>
|
|
54
|
+
<SettingsSection title="显示与操作">
|
|
55
|
+
<SettingsCard>
|
|
56
|
+
<SettingsSwitch
|
|
57
|
+
label="只读"
|
|
58
|
+
hint="只显示需求和文档,不提供确认方案、停止、继续、开始测试验证。能连上这台 daemon 的人都能看到这个插件。"
|
|
59
|
+
value={settings.values.readOnly}
|
|
60
|
+
disabled={settings.saving}
|
|
61
|
+
onValueChange={(readOnly) => void settings.save({ ...settings.values, readOnly }, settings.revision)}
|
|
62
|
+
/>
|
|
63
|
+
<SettingsSelect
|
|
64
|
+
label="刷新间隔"
|
|
65
|
+
value={String(settings.values.refreshSeconds)}
|
|
66
|
+
options={REFRESH}
|
|
67
|
+
disabled={settings.saving}
|
|
68
|
+
onValueChange={(v) => void settings.save({ ...settings.values, refreshSeconds: Number(v) }, settings.revision)}
|
|
69
|
+
/>
|
|
70
|
+
</SettingsCard>
|
|
71
|
+
</SettingsSection>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function SettingsScreen({ theme }: PluginSurfaceProps) {
|
|
77
|
+
const settings = useSettings(preferences);
|
|
78
|
+
if (settings.status === "loading") return <Text style={{ color: theme.colors.foregroundMuted }}>正在读取…</Text>;
|
|
79
|
+
if (settings.status !== "ready") {
|
|
80
|
+
return (
|
|
81
|
+
<SettingsSection title="设置">
|
|
82
|
+
<SettingsCard>
|
|
83
|
+
<SettingsRow label="设置读取失败" error={settings.error} />
|
|
84
|
+
<SettingsAction label="恢复默认设置" actionLabel="恢复" onPress={() => void settings.reset()} />
|
|
85
|
+
</SettingsCard>
|
|
86
|
+
</SettingsSection>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return <Controls settings={settings} theme={theme} />;
|
|
90
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The "需求" row in the sidebar, with how many requirements need a person.
|
|
2
|
+
import type { PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import { useRequirements } from "./data";
|
|
6
|
+
|
|
7
|
+
export function RequirementsItem({ theme, currentScreen, openScreen }: PluginSidebarItemProps) {
|
|
8
|
+
const query = useRequirements();
|
|
9
|
+
const waiting = (query.data ?? []).filter((r) => r.attention).length;
|
|
10
|
+
return (
|
|
11
|
+
<SidebarRow
|
|
12
|
+
icon="ClipboardList"
|
|
13
|
+
label="需求"
|
|
14
|
+
active={currentScreen !== null}
|
|
15
|
+
onPress={() => openScreen({ screenId: "overview" })}
|
|
16
|
+
trailing={
|
|
17
|
+
waiting > 0 ? (
|
|
18
|
+
<View style={{ backgroundColor: theme.colors.statusWarning, borderRadius: 999, minWidth: 18, paddingHorizontal: 5, alignItems: "center" }}>
|
|
19
|
+
<Text accessibilityLabel={`${waiting} 个需求需要处理`} style={{ color: theme.colors.surface0, fontSize: 11, fontWeight: "600" }}>
|
|
20
|
+
{waiting}
|
|
21
|
+
</Text>
|
|
22
|
+
</View>
|
|
23
|
+
) : undefined
|
|
24
|
+
}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Usage statistics (req stats): for whoever reports on how requirements went.
|
|
2
|
+
import type { PluginScreenProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Text, View } from "react-native";
|
|
6
|
+
import { useStats } from "./data";
|
|
7
|
+
import { minutes, tokens, TYPE_LABELS } from "./format";
|
|
8
|
+
import { Chip, Status, useStyles } from "./ui";
|
|
9
|
+
|
|
10
|
+
function month(offset: number, now = new Date()): string {
|
|
11
|
+
const d = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
|
12
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OUTCOME: Record<string, string> = { completed: "已完成", completed_with_issues: "有遗留问题", not_completed: "未完成", unknown: "结论未记录" };
|
|
16
|
+
|
|
17
|
+
export function StatsScreen({ theme, layout }: PluginScreenProps) {
|
|
18
|
+
const s = useStyles(theme, layout.compact);
|
|
19
|
+
const periods = [
|
|
20
|
+
{ label: "最近 30 天", value: { since: "30d" } },
|
|
21
|
+
{ label: "本月", value: { month: month(0) } },
|
|
22
|
+
{ label: "上月", value: { month: month(-1) } },
|
|
23
|
+
];
|
|
24
|
+
const [pick, setPick] = useState(0);
|
|
25
|
+
const query = useStats(periods[pick].value);
|
|
26
|
+
const d = query.data;
|
|
27
|
+
const card = (label: string, value: string, note?: string) => (
|
|
28
|
+
<View key={label} style={[s.card, { minWidth: layout.compact ? "45%" : 160, flexGrow: 1 }]}>
|
|
29
|
+
<Text style={s.muted}>{label}</Text>
|
|
30
|
+
<Text style={[s.text, { fontSize: 22, fontWeight: "600" }]}>{value}</Text>
|
|
31
|
+
{note ? <Text style={s.muted}>{note}</Text> : null}
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
return (
|
|
35
|
+
<View style={s.screen}>
|
|
36
|
+
<ScrollView contentContainerStyle={s.body}>
|
|
37
|
+
<View style={s.row}>
|
|
38
|
+
{periods.map((p, i) => (
|
|
39
|
+
<Chip key={p.label} theme={theme} label={p.label} active={i === pick} onPress={() => setPick(i)} />
|
|
40
|
+
))}
|
|
41
|
+
</View>
|
|
42
|
+
<Status theme={theme} loading={query.isPending} error={query.error} empty={d && d.active === 0 ? "这段时间没有流水线运行记录。" : null} />
|
|
43
|
+
{d && d.active > 0 ? (
|
|
44
|
+
<>
|
|
45
|
+
<View style={[s.row, { alignItems: "stretch" }]}>
|
|
46
|
+
{card("有进展的需求", String(d.active), Object.entries(d.byType).map(([k, v]) => `${TYPE_LABELS[k] ?? "普通"} ${v}`).join(","))}
|
|
47
|
+
{card("完成", String(d.completed.total), Object.entries(d.completed.byOutcome).map(([k, v]) => `${OUTCOME[k] ?? k} ${v}`).join(","))}
|
|
48
|
+
{card("交付周期中位数", d.cycleHours.median === null ? "-" : `${d.cycleHours.median} 小时`, "首次开始到完成")}
|
|
49
|
+
{card("平均评审轮数", d.rework.averageReviewRounds === null ? "-" : String(d.rework.averageReviewRounds), d.rework.withDefectRounds === null ? undefined : `${Math.round(d.rework.withDefectRounds * 100)}% 需要缺陷修复`)}
|
|
50
|
+
{card("agent 合计用时", minutes(d.usage.agentMinutes), `输入 ${tokens(d.usage.inputTokens)},输出 ${tokens(d.usage.outputTokens)} tokens`)}
|
|
51
|
+
{d.usage.costUsd !== null ? card("费用", `$${d.usage.costUsd.toFixed(2)}`) : null}
|
|
52
|
+
</View>
|
|
53
|
+
{d.waits.length > 0 ? (
|
|
54
|
+
<View style={s.card}>
|
|
55
|
+
<Text style={s.heading}>等待</Text>
|
|
56
|
+
{d.waits.map((w) => (
|
|
57
|
+
<Text key={w.kind} style={s.text}>
|
|
58
|
+
{w.label}:{w.requirements} 个需求,平均 {minutes(w.averageMinutes)}
|
|
59
|
+
</Text>
|
|
60
|
+
))}
|
|
61
|
+
</View>
|
|
62
|
+
) : null}
|
|
63
|
+
<View style={s.card}>
|
|
64
|
+
<Text style={s.heading}>各步骤用时</Text>
|
|
65
|
+
{d.steps.map((st) => (
|
|
66
|
+
<Text key={st.stepId} style={s.text}>
|
|
67
|
+
{st.title}:{st.count} 次,平均 {minutes(st.averageMinutes)},合计 {minutes(st.totalMinutes)}
|
|
68
|
+
</Text>
|
|
69
|
+
))}
|
|
70
|
+
<Text style={s.muted}>
|
|
71
|
+
质量门运行 {d.rework.gateRuns} 次,发现新失败 {d.rework.gateFailed} 次。机器:{d.machines.join("、")}
|
|
72
|
+
</Text>
|
|
73
|
+
</View>
|
|
74
|
+
<Text style={s.muted}>给管理方的汇报文件:在终端运行 req stats --report --month {month(0)};几台机器一起汇报见 req stats --export 和 --merge。</Text>
|
|
75
|
+
</>
|
|
76
|
+
) : null}
|
|
77
|
+
</ScrollView>
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Small building blocks in Paseo's theme. Every color comes from theme.colors.
|
|
2
|
+
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { type ReactNode, useMemo } from "react";
|
|
4
|
+
import { Pressable, Text, View } from "react-native";
|
|
5
|
+
|
|
6
|
+
type Theme = PluginHostProps["theme"];
|
|
7
|
+
|
|
8
|
+
export function useStyles(theme: Theme, compact: boolean) {
|
|
9
|
+
return useMemo(
|
|
10
|
+
() => ({
|
|
11
|
+
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
|
12
|
+
body: { padding: compact ? 16 : 24, gap: compact ? 16 : 20 },
|
|
13
|
+
title: { color: theme.colors.foreground, fontSize: compact ? 18 : 20, fontWeight: "600" as const },
|
|
14
|
+
heading: { color: theme.colors.foregroundMuted, fontSize: 13, fontWeight: "600" as const, marginBottom: 6 },
|
|
15
|
+
text: { color: theme.colors.foreground, fontSize: 14 },
|
|
16
|
+
muted: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
|
17
|
+
danger: { color: theme.colors.statusDanger, fontSize: 13 },
|
|
18
|
+
card: {
|
|
19
|
+
backgroundColor: theme.colors.surface1,
|
|
20
|
+
borderColor: theme.colors.border,
|
|
21
|
+
borderWidth: 1,
|
|
22
|
+
borderRadius: 10,
|
|
23
|
+
padding: compact ? 12 : 14,
|
|
24
|
+
gap: 4,
|
|
25
|
+
},
|
|
26
|
+
row: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8, flexWrap: "wrap" as const },
|
|
27
|
+
divider: { height: 1, backgroundColor: theme.colors.border },
|
|
28
|
+
}),
|
|
29
|
+
[theme, compact],
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Badge({ theme, tone, children }: { theme: Theme; tone: "warning" | "danger" | "success" | "accent" | "muted"; children: ReactNode }) {
|
|
34
|
+
const color =
|
|
35
|
+
tone === "warning"
|
|
36
|
+
? theme.colors.statusWarning
|
|
37
|
+
: tone === "danger"
|
|
38
|
+
? theme.colors.statusDanger
|
|
39
|
+
: tone === "success"
|
|
40
|
+
? theme.colors.statusSuccess
|
|
41
|
+
: tone === "accent"
|
|
42
|
+
? theme.colors.accent
|
|
43
|
+
: theme.colors.foregroundMuted;
|
|
44
|
+
return (
|
|
45
|
+
<View style={{ borderColor: color, borderWidth: 1, borderRadius: 999, paddingHorizontal: 8, paddingVertical: 2 }}>
|
|
46
|
+
<Text style={{ color, fontSize: 12 }}>{children}</Text>
|
|
47
|
+
</View>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Button({
|
|
52
|
+
theme,
|
|
53
|
+
label,
|
|
54
|
+
onPress,
|
|
55
|
+
primary,
|
|
56
|
+
disabled,
|
|
57
|
+
}: {
|
|
58
|
+
theme: Theme;
|
|
59
|
+
label: string;
|
|
60
|
+
onPress(): void;
|
|
61
|
+
primary?: boolean;
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
}) {
|
|
64
|
+
return (
|
|
65
|
+
<Pressable
|
|
66
|
+
accessibilityRole="button"
|
|
67
|
+
accessibilityLabel={label}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
onPress={onPress}
|
|
70
|
+
style={{
|
|
71
|
+
paddingHorizontal: 14,
|
|
72
|
+
paddingVertical: 8,
|
|
73
|
+
borderRadius: 8,
|
|
74
|
+
opacity: disabled ? 0.5 : 1,
|
|
75
|
+
backgroundColor: primary ? theme.colors.accent : theme.colors.surface2,
|
|
76
|
+
borderColor: theme.colors.border,
|
|
77
|
+
borderWidth: primary ? 0 : 1,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<Text style={{ color: primary ? theme.colors.accentForeground : theme.colors.foreground, fontSize: 14 }}>{label}</Text>
|
|
81
|
+
</Pressable>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Chip({ theme, label, active, onPress }: { theme: Theme; label: string; active: boolean; onPress(): void }) {
|
|
86
|
+
return (
|
|
87
|
+
<Pressable
|
|
88
|
+
accessibilityRole="button"
|
|
89
|
+
accessibilityState={{ selected: active }}
|
|
90
|
+
onPress={onPress}
|
|
91
|
+
style={{
|
|
92
|
+
paddingHorizontal: 10,
|
|
93
|
+
paddingVertical: 4,
|
|
94
|
+
borderRadius: 999,
|
|
95
|
+
backgroundColor: active ? theme.colors.accent : theme.colors.surface1,
|
|
96
|
+
borderColor: theme.colors.border,
|
|
97
|
+
borderWidth: active ? 0 : 1,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<Text style={{ color: active ? theme.colors.accentForeground : theme.colors.foreground, fontSize: 13 }}>{label}</Text>
|
|
101
|
+
</Pressable>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Loading, error and empty states in one place. */
|
|
106
|
+
export function Status({ theme, loading, error, empty }: { theme: Theme; loading?: boolean; error?: Error | null; empty?: string | null }) {
|
|
107
|
+
if (error) return <Text style={{ color: theme.colors.statusDanger, fontSize: 14 }}>{error.message}</Text>;
|
|
108
|
+
if (loading) return <Text style={{ color: theme.colors.foregroundMuted, fontSize: 14 }}>正在读取…</Text>;
|
|
109
|
+
if (empty) return <Text style={{ color: theme.colors.foregroundMuted, fontSize: 14 }}>{empty}</Text>;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginScreenProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { RequirementScreen } from "./client/detail";
|
|
3
|
+
import { contributeLive } from "./client/live";
|
|
4
|
+
import { OverviewScreen } from "./client/overview";
|
|
5
|
+
import { WorkspacePanel } from "./client/panel";
|
|
6
|
+
import { SettingsScreen } from "./client/settings";
|
|
7
|
+
import { RequirementsItem } from "./client/sidebar";
|
|
8
|
+
import { StatsScreen } from "./client/stats";
|
|
9
|
+
|
|
10
|
+
export default function contribute(client: PluginClientContext) {
|
|
11
|
+
const openScreen = (input: { screenId: string; params?: Record<string, string> }) => client.openScreen(input);
|
|
12
|
+
const Overview = (props: PluginScreenProps) => <OverviewScreen {...props} openScreen={openScreen} />;
|
|
13
|
+
|
|
14
|
+
client.addScreen({ id: "overview", title: "需求", Component: Overview });
|
|
15
|
+
client.addScreen({ id: "requirement", title: (params) => params.title || params.name || "需求", Component: RequirementScreen });
|
|
16
|
+
client.addScreen({ id: "stats", title: "需求统计", Component: StatsScreen });
|
|
17
|
+
client.addSidebarHeaderItem({ id: "requirements", title: "需求(req-kit)", Component: RequirementsItem });
|
|
18
|
+
client.addWorkspacePanel({
|
|
19
|
+
id: "requirements",
|
|
20
|
+
title: "需求",
|
|
21
|
+
icon: "ClipboardList",
|
|
22
|
+
context: "workspace",
|
|
23
|
+
locations: ["workspace", "explorer"],
|
|
24
|
+
Component: WorkspacePanel,
|
|
25
|
+
});
|
|
26
|
+
client.addSettingsScreen({ id: "preferences", title: "req-kit", icon: "ClipboardList", Component: SettingsScreen });
|
|
27
|
+
client.addCommandCenterItem({
|
|
28
|
+
id: "open-overview",
|
|
29
|
+
title: "需求总览(req-kit)",
|
|
30
|
+
icon: "ClipboardList",
|
|
31
|
+
keywords: ["req", "requirement", "需求", "流水线"],
|
|
32
|
+
context: "global",
|
|
33
|
+
onSelect: ({ openScreen: open }) => open({ screenId: "overview" }),
|
|
34
|
+
});
|
|
35
|
+
client.addCommandCenterItem({
|
|
36
|
+
id: "open-stats",
|
|
37
|
+
title: "需求统计(req-kit)",
|
|
38
|
+
icon: "ChartColumn",
|
|
39
|
+
keywords: ["req", "stats", "统计", "用量"],
|
|
40
|
+
context: "global",
|
|
41
|
+
onSelect: ({ openScreen: open }) => open({ screenId: "stats" }),
|
|
42
|
+
});
|
|
43
|
+
client.addCommandCenterItem({
|
|
44
|
+
id: "open-panel",
|
|
45
|
+
title: "这个工作区的需求(req-kit)",
|
|
46
|
+
icon: "ClipboardList",
|
|
47
|
+
keywords: ["req", "需求"],
|
|
48
|
+
context: "workspace",
|
|
49
|
+
onSelect: ({ openPanel }) => openPanel("requirements"),
|
|
50
|
+
});
|
|
51
|
+
return contributeLive(client);
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { fileRpc, listRpc, preferences, runRpc, statsRpc, statusRpc, versionRpc } from "./shared/contracts";
|
|
3
|
+
import { createHandlers } from "./server/handlers";
|
|
4
|
+
import { kitHomeOf, reqApi } from "./server/req";
|
|
5
|
+
|
|
6
|
+
export default function contribute(server: PluginServerContext) {
|
|
7
|
+
const settings = server.registerSettings(preferences);
|
|
8
|
+
let kitHomeSetting = "";
|
|
9
|
+
let readOnly = false;
|
|
10
|
+
const load = async () => {
|
|
11
|
+
const state = await settings.read();
|
|
12
|
+
if (state.status === "ready") {
|
|
13
|
+
kitHomeSetting = state.values.kitHome;
|
|
14
|
+
readOnly = state.values.readOnly;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
void load();
|
|
18
|
+
const unsubscribe = settings.subscribe((state) => {
|
|
19
|
+
if (state.status !== "ready") return;
|
|
20
|
+
kitHomeSetting = state.values.kitHome;
|
|
21
|
+
readOnly = state.values.readOnly;
|
|
22
|
+
});
|
|
23
|
+
const kitHome = () => kitHomeOf(kitHomeSetting);
|
|
24
|
+
const handlers = createHandlers({
|
|
25
|
+
req: reqApi(kitHome),
|
|
26
|
+
kitHome,
|
|
27
|
+
readOnly: async () => {
|
|
28
|
+
await load();
|
|
29
|
+
return readOnly;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
server.handle(versionRpc, () => handlers.version());
|
|
33
|
+
server.handle(listRpc, () => handlers.list());
|
|
34
|
+
server.handle(statusRpc, (input) => handlers.status(input));
|
|
35
|
+
server.handle(fileRpc, (input) => handlers.file(input));
|
|
36
|
+
server.handle(statsRpc, (input) => handlers.stats(input));
|
|
37
|
+
server.handle(runRpc, (input) => handlers.run(input));
|
|
38
|
+
return () => {
|
|
39
|
+
unsubscribe();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "req-kit-paseo-plugin",
|
|
3
|
+
"version": "1.9.0-beta.2",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "The req-kit plugin for Paseo. Ships inside the paseo-req-kit package; installed with req plugin install.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@getpaseo/client": "0.11.2",
|
|
12
|
+
"@getpaseo/plugin": "0.11.2",
|
|
13
|
+
"@getpaseo/protocol": "0.11.2",
|
|
14
|
+
"@tanstack/react-query": "^5.90.0",
|
|
15
|
+
"@types/node": "^22.0.0",
|
|
16
|
+
"@types/react": "~19.2.0",
|
|
17
|
+
"react": "19.1.0",
|
|
18
|
+
"react-native": "0.81.5",
|
|
19
|
+
"typescript": "^5.9.3",
|
|
20
|
+
"zod": "^4.4.3"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The daemon side: each RPC is one `req api` call. Management actions are refused here too
|
|
2
|
+
// when the plugin is set to read-only, not only hidden in the app.
|
|
3
|
+
import type { RpcInput } from "@getpaseo/plugin";
|
|
4
|
+
import type { fileRpc, KitInfo, Requirement, RequirementDetail, runRpc, statsRpc, StatsSummary, statusRpc } from "../shared/contracts";
|
|
5
|
+
import { coalesce, type ReqRunner } from "./req";
|
|
6
|
+
|
|
7
|
+
export interface Handlers {
|
|
8
|
+
version(): Promise<KitInfo>;
|
|
9
|
+
list(): Promise<Requirement[]>;
|
|
10
|
+
status(input: RpcInput<typeof statusRpc>): Promise<RequirementDetail>;
|
|
11
|
+
file(input: RpcInput<typeof fileRpc>): Promise<{ file: string; size: number; truncated: boolean; content: string }>;
|
|
12
|
+
stats(input: RpcInput<typeof statsRpc>): Promise<StatsSummary>;
|
|
13
|
+
run(input: RpcInput<typeof runRpc>): Promise<{ ok: boolean; message: string }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createHandlers(input: { req: ReqRunner; kitHome: () => string; readOnly: () => Promise<boolean> }): Handlers {
|
|
17
|
+
const { req } = input;
|
|
18
|
+
// Every open screen polls the list; one `req api list` serves them all for a moment.
|
|
19
|
+
const list = coalesce(2_000, () => req(["list", "--all"]) as Promise<Requirement[]>);
|
|
20
|
+
return {
|
|
21
|
+
async version() {
|
|
22
|
+
const info = (await req(["version"])) as Omit<KitInfo, "kitHome">;
|
|
23
|
+
return { ...info, kitHome: input.kitHome() };
|
|
24
|
+
},
|
|
25
|
+
list,
|
|
26
|
+
async status({ repo, featureDir }) {
|
|
27
|
+
return (await req(["status", featureDir, "--repo", repo])) as RequirementDetail;
|
|
28
|
+
},
|
|
29
|
+
async file({ repo, featureDir, file }) {
|
|
30
|
+
return (await req(["file", featureDir, file, "--repo", repo])) as { file: string; size: number; truncated: boolean; content: string };
|
|
31
|
+
},
|
|
32
|
+
async stats({ since, month }) {
|
|
33
|
+
const args = ["stats"];
|
|
34
|
+
if (month) args.push("--month", month);
|
|
35
|
+
else if (since) args.push("--since", since);
|
|
36
|
+
return (await req(args)) as StatsSummary;
|
|
37
|
+
},
|
|
38
|
+
async run({ action, repo, featureDir, verifyEnv }) {
|
|
39
|
+
if (await input.readOnly()) return { ok: false, message: "插件设置为只读:不能在这里操作需求。可以在插件设置中关闭只读,或在终端用 req 命令。" };
|
|
40
|
+
const extra = action === "verify" && verifyEnv ? ["--verify-env", verifyEnv] : [];
|
|
41
|
+
const result = (await req(["run", action, featureDir, "--repo", repo, ...extra], { timeoutMs: 200_000 })) as { ok: boolean; message: string };
|
|
42
|
+
return { ok: result.ok, message: result.message };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Runs `req api …` with the req-kit version this machine has active, and parses its one line
|
|
2
|
+
// of JSON (contracts/cli-json.md). The plugin never reads or writes req-kit's files itself.
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
export class ReqError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
readonly code: string,
|
|
11
|
+
message: string,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function kitHomeOf(configured: string): string {
|
|
18
|
+
if (configured.trim()) return path.resolve(configured.trim().replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
19
|
+
const env = process.env.REQ_KIT_HOME?.trim();
|
|
20
|
+
return env ? path.resolve(env) : path.join(os.homedir(), ".req-kit");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The active version's req.mjs, from <kit home>/active.json. */
|
|
24
|
+
export function activeReq(kitHome: string): string {
|
|
25
|
+
const file = path.join(kitHome, "active.json");
|
|
26
|
+
let active: { root?: string; version?: string } | null = null;
|
|
27
|
+
try {
|
|
28
|
+
active = JSON.parse(fs.readFileSync(file, "utf8")) as { root?: string };
|
|
29
|
+
} catch {
|
|
30
|
+
throw new ReqError("no_kit", `这台机器上没有找到 req-kit(${file})。请先安装 req-kit 并运行 req setup。`);
|
|
31
|
+
}
|
|
32
|
+
const req = active?.root ? path.join(active.root, "bin", "req.mjs") : "";
|
|
33
|
+
if (!req || !fs.existsSync(req)) throw new ReqError("no_kit", `req-kit 当前启用的版本不在了(${active?.root ?? "未知"})。请运行 req versions 和 req use。`);
|
|
34
|
+
return req;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A node to run req with: this process's when it is node, else the one on PATH. */
|
|
38
|
+
function nodeBinary(): string {
|
|
39
|
+
const base = path.basename(process.execPath).toLowerCase();
|
|
40
|
+
return base === "node" || base === "node.exe" ? process.execPath : "node";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReqRunner {
|
|
44
|
+
(args: string[], options?: { timeoutMs?: number }): Promise<unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Runs `req api <args>` and returns its data, or throws ReqError with req's message. */
|
|
48
|
+
export function reqApi(kitHome: () => string): ReqRunner {
|
|
49
|
+
return (args, options = {}) =>
|
|
50
|
+
new Promise((resolve, reject) => {
|
|
51
|
+
let req: string;
|
|
52
|
+
const home = kitHome();
|
|
53
|
+
try {
|
|
54
|
+
req = activeReq(home);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reject(error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const env: NodeJS.ProcessEnv = { ...process.env, REQ_KIT_HOME: home };
|
|
60
|
+
// Not a chat: the run keeps reporting to the chat that started it.
|
|
61
|
+
delete env.PASEO_AGENT_ID;
|
|
62
|
+
delete env.PASEO_AGENT_CWD;
|
|
63
|
+
execFile(
|
|
64
|
+
nodeBinary(),
|
|
65
|
+
[req, "api", ...args],
|
|
66
|
+
{ env, timeout: options.timeoutMs ?? 60_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true },
|
|
67
|
+
(error, stdout, stderr) => {
|
|
68
|
+
const line = stdout.trim().split("\n").pop() ?? "";
|
|
69
|
+
let parsed: { schema?: number; data?: unknown; error?: { code: string; message: string } } | null = null;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(line);
|
|
72
|
+
} catch {
|
|
73
|
+
parsed = null;
|
|
74
|
+
}
|
|
75
|
+
if (parsed?.error) return reject(new ReqError(parsed.error.code, parsed.error.message));
|
|
76
|
+
if (parsed && "data" in parsed) return resolve(parsed.data);
|
|
77
|
+
const why = (stderr || error?.message || "没有输出").trim().split("\n").slice(-3).join(" ");
|
|
78
|
+
if (/没有 "api" 这个命令|not.*api/i.test(stdout + stderr)) {
|
|
79
|
+
return reject(new ReqError("old_kit", "本机的 req-kit 太旧,不支持插件(需要 1.9 或更新的版本)。请运行 req upgrade。"));
|
|
80
|
+
}
|
|
81
|
+
reject(new ReqError("failed", `req api ${args[0] ?? ""} 失败:${why}`));
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The same call made twice within `ms` shares one run (several open screens, one daemon). */
|
|
88
|
+
export function coalesce<T>(ms: number, run: () => Promise<T>): () => Promise<T> {
|
|
89
|
+
let last: { at: number; value: Promise<T> } | null = null;
|
|
90
|
+
return () => {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
if (last && now - last.at < ms) return last.value;
|
|
93
|
+
const value = run();
|
|
94
|
+
last = { at: now, value };
|
|
95
|
+
value.catch(() => {
|
|
96
|
+
if (last?.value === value) last = null;
|
|
97
|
+
});
|
|
98
|
+
return value;
|
|
99
|
+
};
|
|
100
|
+
}
|