@yannelli/paseo-linear-plugin 0.1.0 → 0.4.0
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/NOTICE +8 -4
- package/OVERVIEW.md +23 -13
- package/README.md +138 -29
- package/client/agent-options.ts +200 -0
- package/client/autosave.ts +77 -0
- package/client/breadcrumbs.tsx +110 -0
- package/client/browser.tsx +275 -0
- package/client/compat.ts +107 -0
- package/client/connect.tsx +136 -0
- package/client/create-issue.tsx +291 -0
- package/client/glyphs.tsx +117 -0
- package/client/issue-description.tsx +206 -0
- package/client/issue-detail.tsx +358 -0
- package/client/issue-list.tsx +373 -0
- package/client/issue-row.tsx +152 -0
- package/client/issue-sections.tsx +325 -0
- package/client/issue-tree.ts +132 -0
- package/client/key-scope.tsx +23 -0
- package/client/launch-choices.ts +125 -0
- package/client/launch-fields.tsx +152 -0
- package/client/launch-plan.ts +227 -0
- package/client/launch.tsx +484 -0
- package/client/markdown.tsx +302 -0
- package/client/model-browser.tsx +296 -0
- package/client/pickers.tsx +238 -0
- package/client/provider-icon.tsx +78 -0
- package/client/queries.ts +242 -0
- package/client/settings-connection.tsx +109 -0
- package/client/settings-fields.tsx +173 -0
- package/client/settings-keys.tsx +117 -0
- package/client/settings-projects.tsx +281 -0
- package/client/settings-prompts.tsx +198 -0
- package/client/store.ts +71 -0
- package/client/timeline-card.tsx +63 -0
- package/client/ui.tsx +352 -0
- package/index.client.tsx +111 -0
- package/index.server.ts +7 -3
- package/package.json +4 -3
- package/paseo-plugin.json +2 -2
- package/server/cache.ts +68 -0
- package/server/credentials.ts +101 -0
- package/server/graphql.ts +68 -0
- package/server/handlers.ts +172 -0
- package/server/linear.ts +11 -64
- package/server/queries.ts +280 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +461 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
- package/index.client.ts +0 -7
- package/server/issues.ts +0 -10
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { type ReactNode, useCallback, useMemo } from "react";
|
|
3
|
+
import { Pressable, type PressableStateCallbackType, Switch, Text, View } from "react-native";
|
|
4
|
+
import { type Theme, usePressableStyle } from "./ui";
|
|
5
|
+
|
|
6
|
+
// Matches the controls under Paseo's agent composer: an icon, the value, and a chevron.
|
|
7
|
+
export function Chip(props: {
|
|
8
|
+
theme: Theme;
|
|
9
|
+
icon: ReactNode;
|
|
10
|
+
value: string;
|
|
11
|
+
accessibilityLabel: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
onPress(): void;
|
|
14
|
+
}) {
|
|
15
|
+
const { colors } = props.theme;
|
|
16
|
+
const styles = useMemo(() => {
|
|
17
|
+
const idle = {
|
|
18
|
+
height: 28,
|
|
19
|
+
paddingHorizontal: 8,
|
|
20
|
+
borderRadius: 16,
|
|
21
|
+
flexDirection: "row" as const,
|
|
22
|
+
alignItems: "center" as const,
|
|
23
|
+
gap: 4,
|
|
24
|
+
minWidth: 0,
|
|
25
|
+
maxWidth: 260,
|
|
26
|
+
opacity: props.disabled ? 0.5 : 1,
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
idle,
|
|
30
|
+
hovered: { ...idle, backgroundColor: colors.surface2 },
|
|
31
|
+
pressed: { ...idle, backgroundColor: colors.surface0 },
|
|
32
|
+
icon: {
|
|
33
|
+
width: 16,
|
|
34
|
+
height: 16,
|
|
35
|
+
alignItems: "center" as const,
|
|
36
|
+
justifyContent: "center" as const,
|
|
37
|
+
},
|
|
38
|
+
value: { color: colors.foregroundMuted, fontSize: 14, flexShrink: 1, minWidth: 0 },
|
|
39
|
+
chevron: { transform: [{ translateY: 1 }] },
|
|
40
|
+
};
|
|
41
|
+
}, [colors, props.disabled]);
|
|
42
|
+
const style = useCallback(
|
|
43
|
+
(state: PressableStateCallbackType) =>
|
|
44
|
+
state.pressed
|
|
45
|
+
? styles.pressed
|
|
46
|
+
: (state as { hovered?: boolean }).hovered
|
|
47
|
+
? styles.hovered
|
|
48
|
+
: styles.idle,
|
|
49
|
+
[styles],
|
|
50
|
+
);
|
|
51
|
+
return (
|
|
52
|
+
<Pressable
|
|
53
|
+
accessibilityRole="button"
|
|
54
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
55
|
+
disabled={props.disabled}
|
|
56
|
+
onPress={props.onPress}
|
|
57
|
+
style={style}
|
|
58
|
+
>
|
|
59
|
+
<View style={styles.icon}>{props.icon}</View>
|
|
60
|
+
<Text numberOfLines={1} style={styles.value}>
|
|
61
|
+
{props.value}
|
|
62
|
+
</Text>
|
|
63
|
+
<View style={styles.chevron}>
|
|
64
|
+
<Icon name="ChevronDown" size={14} color={colors.foregroundMuted} />
|
|
65
|
+
</View>
|
|
66
|
+
</Pressable>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function Field(props: {
|
|
71
|
+
theme: Theme;
|
|
72
|
+
label: string;
|
|
73
|
+
icon: string;
|
|
74
|
+
value: string;
|
|
75
|
+
detail?: string;
|
|
76
|
+
onPress(): void;
|
|
77
|
+
}) {
|
|
78
|
+
const { colors } = props.theme;
|
|
79
|
+
const styles = useMemo(() => {
|
|
80
|
+
const idle = {
|
|
81
|
+
minHeight: 44,
|
|
82
|
+
paddingHorizontal: 12,
|
|
83
|
+
paddingVertical: 8,
|
|
84
|
+
borderRadius: 8,
|
|
85
|
+
borderWidth: 1,
|
|
86
|
+
borderColor: colors.border,
|
|
87
|
+
flexDirection: "row" as const,
|
|
88
|
+
alignItems: "center" as const,
|
|
89
|
+
gap: 10,
|
|
90
|
+
backgroundColor: colors.surface1,
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
idle,
|
|
94
|
+
active: { ...idle, backgroundColor: colors.surface2 },
|
|
95
|
+
label: { color: colors.foregroundMuted, fontSize: 13, width: 64 },
|
|
96
|
+
body: { flex: 1, minWidth: 0 },
|
|
97
|
+
value: { color: colors.foreground, fontSize: 14 },
|
|
98
|
+
detail: { color: colors.foregroundMuted, fontSize: 12 },
|
|
99
|
+
};
|
|
100
|
+
}, [colors]);
|
|
101
|
+
const style = usePressableStyle(styles.idle, styles.active);
|
|
102
|
+
return (
|
|
103
|
+
<Pressable
|
|
104
|
+
accessibilityRole="button"
|
|
105
|
+
accessibilityLabel={`${props.label}: ${props.value}`}
|
|
106
|
+
onPress={props.onPress}
|
|
107
|
+
style={style}
|
|
108
|
+
>
|
|
109
|
+
<Icon name={props.icon} size={16} color={colors.foregroundMuted} />
|
|
110
|
+
<Text style={styles.label}>{props.label}</Text>
|
|
111
|
+
<View style={styles.body}>
|
|
112
|
+
<Text numberOfLines={1} style={styles.value}>
|
|
113
|
+
{props.value}
|
|
114
|
+
</Text>
|
|
115
|
+
{props.detail ? (
|
|
116
|
+
<Text numberOfLines={1} style={styles.detail}>
|
|
117
|
+
{props.detail}
|
|
118
|
+
</Text>
|
|
119
|
+
) : null}
|
|
120
|
+
</View>
|
|
121
|
+
<Icon name="ChevronDown" size={14} color={colors.foregroundMuted} />
|
|
122
|
+
</Pressable>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function Toggle(props: {
|
|
127
|
+
theme: Theme;
|
|
128
|
+
label: string;
|
|
129
|
+
value: boolean;
|
|
130
|
+
onChange(value: boolean): void;
|
|
131
|
+
}) {
|
|
132
|
+
const { colors } = props.theme;
|
|
133
|
+
const styles = useMemo(
|
|
134
|
+
() => ({
|
|
135
|
+
row: { minHeight: 36, flexDirection: "row" as const, alignItems: "center" as const, gap: 10 },
|
|
136
|
+
label: { flex: 1, color: colors.foreground, fontSize: 14 },
|
|
137
|
+
track: { true: colors.accent, false: colors.surface2 },
|
|
138
|
+
}),
|
|
139
|
+
[colors],
|
|
140
|
+
);
|
|
141
|
+
return (
|
|
142
|
+
<View style={styles.row}>
|
|
143
|
+
<Text style={styles.label}>{props.label}</Text>
|
|
144
|
+
<Switch
|
|
145
|
+
accessibilityLabel={props.label}
|
|
146
|
+
value={props.value}
|
|
147
|
+
onValueChange={props.onChange}
|
|
148
|
+
trackColor={styles.track}
|
|
149
|
+
/>
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useMutation } from "@tanstack/react-query";
|
|
3
|
+
import {
|
|
4
|
+
attachIssueCardRpc,
|
|
5
|
+
type IssueDetail,
|
|
6
|
+
type IssuePatch,
|
|
7
|
+
updateIssueRpc,
|
|
8
|
+
type WorkflowState,
|
|
9
|
+
} from "../shared/linear";
|
|
10
|
+
import { AGENT_LABELS, agentTitle } from "../shared/prompts";
|
|
11
|
+
import type { AgentAction, Isolation, LinearSettings } from "../shared/settings";
|
|
12
|
+
import { type AgentSelection, agentConfig } from "./agent-options";
|
|
13
|
+
import type { PaseoProjectOption } from "./queries";
|
|
14
|
+
import { errorMessage } from "./ui";
|
|
15
|
+
|
|
16
|
+
export type Placement = "worktree" | "pull-request" | "branch" | "agent-workspace" | "workspace";
|
|
17
|
+
|
|
18
|
+
export interface PlacementOption {
|
|
19
|
+
value: Placement;
|
|
20
|
+
label: string;
|
|
21
|
+
detail: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TargetWorkspace {
|
|
25
|
+
id: string;
|
|
26
|
+
projectId: string;
|
|
27
|
+
name: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lastProjectByTeam = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
export function pullRequestNumber(issue: IssueDetail): number | null {
|
|
33
|
+
for (const attachment of issue.attachments) {
|
|
34
|
+
const match = /github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/.exec(attachment.url);
|
|
35
|
+
if (match?.[1]) return Number(match[1]);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function defaultProjectId(input: {
|
|
41
|
+
target: TargetWorkspace | null;
|
|
42
|
+
teamId: string;
|
|
43
|
+
settings: LinearSettings;
|
|
44
|
+
projects: readonly PaseoProjectOption[] | undefined;
|
|
45
|
+
}): string | null {
|
|
46
|
+
if (input.target) return input.target.projectId;
|
|
47
|
+
const remembered = lastProjectByTeam.get(input.teamId);
|
|
48
|
+
if (remembered) return remembered;
|
|
49
|
+
const mapped = input.settings.projects.find((entry) => entry.teamIds.includes(input.teamId));
|
|
50
|
+
return mapped?.projectId ?? input.projects?.[0]?.projectId ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function placementOptions(input: {
|
|
54
|
+
action: AgentAction;
|
|
55
|
+
prNumber: number | null;
|
|
56
|
+
project: PaseoProjectOption | null;
|
|
57
|
+
branchName: string;
|
|
58
|
+
target: TargetWorkspace | null;
|
|
59
|
+
agentWorkspace: TargetWorkspace | null;
|
|
60
|
+
}): PlacementOption[] {
|
|
61
|
+
const options: PlacementOption[] = [];
|
|
62
|
+
const git = input.project?.kind === "git";
|
|
63
|
+
const review = input.action === "review";
|
|
64
|
+
if (review && git && input.prNumber !== null) {
|
|
65
|
+
options.push({
|
|
66
|
+
value: "pull-request",
|
|
67
|
+
label: `Check out PR #${input.prNumber}`,
|
|
68
|
+
detail: "New worktree on the pull request branch",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (input.agentWorkspace) {
|
|
72
|
+
options.push({
|
|
73
|
+
value: "agent-workspace",
|
|
74
|
+
label: review ? "Implementation workspace" : "Existing agent workspace",
|
|
75
|
+
detail: input.agentWorkspace.name,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (git) {
|
|
79
|
+
options.push(
|
|
80
|
+
review
|
|
81
|
+
? { value: "branch", label: "Check out issue branch", detail: input.branchName }
|
|
82
|
+
: { value: "worktree", label: "New worktree", detail: input.branchName },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const here = input.target !== null && input.target.projectId === input.project?.projectId;
|
|
86
|
+
options.push(
|
|
87
|
+
here && input.target
|
|
88
|
+
? { value: "workspace", label: "This workspace", detail: input.target.name }
|
|
89
|
+
: { value: "workspace", label: "Project folder", detail: input.project?.rootPath ?? "" },
|
|
90
|
+
);
|
|
91
|
+
return options;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Reviews look at existing work: the pull request, then the implementing agent's workspace.
|
|
95
|
+
function preferredPlacement(action: AgentAction, isolation: Isolation): Placement[] {
|
|
96
|
+
if (action === "review") return ["pull-request", "agent-workspace", "workspace"];
|
|
97
|
+
return isolation === "worktree" ? ["worktree", "workspace"] : ["workspace"];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolvePlacement(
|
|
101
|
+
options: readonly PlacementOption[],
|
|
102
|
+
choice: Placement | null,
|
|
103
|
+
action: AgentAction,
|
|
104
|
+
isolation: Isolation,
|
|
105
|
+
): PlacementOption | null {
|
|
106
|
+
const wanted = choice ? [choice] : preferredPlacement(action, isolation);
|
|
107
|
+
for (const value of wanted) {
|
|
108
|
+
const match = options.find((option) => option.value === value);
|
|
109
|
+
if (match) return match;
|
|
110
|
+
}
|
|
111
|
+
return options[0] ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function linearPatch(input: {
|
|
115
|
+
started: WorkflowState | null;
|
|
116
|
+
moveToStarted: boolean;
|
|
117
|
+
viewerId: string | null;
|
|
118
|
+
assignToMe: boolean;
|
|
119
|
+
}): IssuePatch {
|
|
120
|
+
const patch: IssuePatch = {};
|
|
121
|
+
if (input.moveToStarted && input.started) patch.stateId = input.started.id;
|
|
122
|
+
if (input.assignToMe && input.viewerId) patch.assigneeId = input.viewerId;
|
|
123
|
+
return patch;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface LaunchRequest {
|
|
127
|
+
action: AgentAction;
|
|
128
|
+
issue: IssueDetail;
|
|
129
|
+
project: PaseoProjectOption;
|
|
130
|
+
agent: AgentSelection;
|
|
131
|
+
placement: Placement;
|
|
132
|
+
prNumber: number | null;
|
|
133
|
+
target: TargetWorkspace | null;
|
|
134
|
+
agentWorkspace: TargetWorkspace | null;
|
|
135
|
+
prompt: string;
|
|
136
|
+
patch: IssuePatch;
|
|
137
|
+
started: WorkflowState | null;
|
|
138
|
+
/** The Paseo project whose Linear key loaded the issue. */
|
|
139
|
+
keyScope: string | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type Paseo = ReturnType<typeof usePaseo>;
|
|
143
|
+
|
|
144
|
+
async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
|
|
145
|
+
const { issue, project } = request;
|
|
146
|
+
if (request.placement === "worktree") {
|
|
147
|
+
return paseo.workspaces.create({
|
|
148
|
+
title: `${issue.identifier} ${issue.title}`,
|
|
149
|
+
source: {
|
|
150
|
+
kind: "worktree",
|
|
151
|
+
cwd: project.rootPath,
|
|
152
|
+
projectId: project.projectId,
|
|
153
|
+
action: "branch-off",
|
|
154
|
+
branchName: issue.branchName,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (request.placement === "pull-request" && request.prNumber !== null) {
|
|
159
|
+
return paseo.workspaces.create({
|
|
160
|
+
title: `Review ${issue.identifier}`,
|
|
161
|
+
source: {
|
|
162
|
+
kind: "worktree",
|
|
163
|
+
cwd: project.rootPath,
|
|
164
|
+
projectId: project.projectId,
|
|
165
|
+
action: "checkout",
|
|
166
|
+
checkoutSource: { kind: "change_request", forge: "github", number: request.prNumber },
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (request.placement === "branch") {
|
|
171
|
+
return paseo.workspaces.create({
|
|
172
|
+
title: `Review ${issue.identifier}`,
|
|
173
|
+
source: {
|
|
174
|
+
kind: "worktree",
|
|
175
|
+
cwd: project.rootPath,
|
|
176
|
+
projectId: project.projectId,
|
|
177
|
+
action: "checkout",
|
|
178
|
+
refName: issue.branchName,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (request.placement === "agent-workspace" && request.agentWorkspace) {
|
|
183
|
+
return paseo.workspaces.ref(request.agentWorkspace.id);
|
|
184
|
+
}
|
|
185
|
+
if (request.target && request.target.projectId === project.projectId) {
|
|
186
|
+
return paseo.workspaces.ref(request.target.id);
|
|
187
|
+
}
|
|
188
|
+
return paseo.workspaces.open(project.rootPath);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function useLaunchAgent() {
|
|
192
|
+
const paseo = usePaseo();
|
|
193
|
+
const updateIssue = useRpc(updateIssueRpc);
|
|
194
|
+
const attachCard = useRpc(attachIssueCardRpc);
|
|
195
|
+
return useMutation({
|
|
196
|
+
mutationFn: async (request: LaunchRequest) => {
|
|
197
|
+
const { issue, action } = request;
|
|
198
|
+
const workspace = await openWorkspace(paseo, request);
|
|
199
|
+
const agent = await workspace.agents.create({
|
|
200
|
+
config: agentConfig(request.agent),
|
|
201
|
+
title: agentTitle(action, issue),
|
|
202
|
+
prompt: request.prompt,
|
|
203
|
+
labels: { [AGENT_LABELS.issue]: issue.identifier, [AGENT_LABELS.action]: action },
|
|
204
|
+
});
|
|
205
|
+
lastProjectByTeam.set(issue.team.id, request.project.projectId);
|
|
206
|
+
let warning: string | null = null;
|
|
207
|
+
if (Object.keys(request.patch).length > 0) {
|
|
208
|
+
try {
|
|
209
|
+
await updateIssue({ id: issue.id, patch: request.patch, projectId: request.keyScope });
|
|
210
|
+
} catch (error) {
|
|
211
|
+
warning = errorMessage(error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const state = request.patch.stateId && request.started ? request.started : issue.state;
|
|
215
|
+
const card = {
|
|
216
|
+
identifier: issue.identifier,
|
|
217
|
+
title: issue.title,
|
|
218
|
+
url: issue.url,
|
|
219
|
+
stateName: state.name,
|
|
220
|
+
stateColor: state.color,
|
|
221
|
+
action,
|
|
222
|
+
};
|
|
223
|
+
void attachCard({ agentId: agent.id, card }).catch(() => undefined);
|
|
224
|
+
return { agentId: agent.id, warning };
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|