@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,358 @@
|
|
|
1
|
+
import { openExternalUrl, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { ScrollView, Text, View } from "react-native";
|
|
5
|
+
import type { IssueDetail, IssuePatch, LinearTeam, LinearUser } from "../shared/linear";
|
|
6
|
+
import { ACTION_LABELS } from "../shared/prompts";
|
|
7
|
+
import type { AgentAction } from "../shared/settings";
|
|
8
|
+
import { IssueBreadcrumbs } from "./breadcrumbs";
|
|
9
|
+
import { IssueDescription } from "./issue-description";
|
|
10
|
+
import { Comments, IssueLinks, IssueRelations, LinkedAgents } from "./issue-sections";
|
|
11
|
+
import type { LaunchTarget } from "./launch";
|
|
12
|
+
import {
|
|
13
|
+
assigneeOptions,
|
|
14
|
+
PickerModal,
|
|
15
|
+
priorityOptions,
|
|
16
|
+
PropertyChip,
|
|
17
|
+
stateOptions,
|
|
18
|
+
UNASSIGNED,
|
|
19
|
+
} from "./pickers";
|
|
20
|
+
import { useIssue, useUpdateIssue } from "./queries";
|
|
21
|
+
import {
|
|
22
|
+
Avatar,
|
|
23
|
+
Button,
|
|
24
|
+
EmptyState,
|
|
25
|
+
errorMessage,
|
|
26
|
+
IconButton,
|
|
27
|
+
LabelPill,
|
|
28
|
+
priorityMeta,
|
|
29
|
+
PriorityIcon,
|
|
30
|
+
StateIcon,
|
|
31
|
+
type Theme,
|
|
32
|
+
} from "./ui";
|
|
33
|
+
|
|
34
|
+
type PickerKind = "state" | "priority" | "assignee";
|
|
35
|
+
type Picker = PickerKind | null;
|
|
36
|
+
|
|
37
|
+
const PICKER_META: Record<PickerKind, { title: string; icon: string }> = {
|
|
38
|
+
state: { title: "Status", icon: "CircleDot" },
|
|
39
|
+
priority: { title: "Priority", icon: "SignalHigh" },
|
|
40
|
+
assignee: { title: "Assignee", icon: "UserRound" },
|
|
41
|
+
};
|
|
42
|
+
const ROW_WRAP_STYLE = { flexDirection: "row", flexWrap: "wrap", gap: 8 } as const;
|
|
43
|
+
const LABELS_STYLE = { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: -8 } as const;
|
|
44
|
+
const ROOT_STYLE = { flex: 1, minHeight: 0 } as const;
|
|
45
|
+
const SCROLL_STYLE = { flex: 1 } as const;
|
|
46
|
+
|
|
47
|
+
function pickerValue(picker: Picker, issue: IssueDetail): string {
|
|
48
|
+
if (picker === "state") return issue.state.id;
|
|
49
|
+
if (picker === "priority") return String(issue.priority);
|
|
50
|
+
return issue.assignee?.id ?? UNASSIGNED;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pickerPatch(picker: Picker, value: string): IssuePatch {
|
|
54
|
+
if (picker === "state") return { stateId: value };
|
|
55
|
+
if (picker === "priority") return { priority: Number(value) };
|
|
56
|
+
return { assigneeId: value === UNASSIGNED ? null : value };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface IssueDetailProps {
|
|
60
|
+
theme: Theme;
|
|
61
|
+
compact: boolean;
|
|
62
|
+
issueId: string;
|
|
63
|
+
teams: readonly LinearTeam[];
|
|
64
|
+
users: readonly LinearUser[];
|
|
65
|
+
viewerId: string | null;
|
|
66
|
+
target: LaunchTarget;
|
|
67
|
+
navigation: PluginSurfaceProps["navigation"];
|
|
68
|
+
onBack(): void;
|
|
69
|
+
onOpenIssue(issueId: string): void;
|
|
70
|
+
onLaunch(action: AgentAction): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function IssueDetailView(props: IssueDetailProps) {
|
|
74
|
+
const { theme } = props;
|
|
75
|
+
const issue = useIssue(props.issueId);
|
|
76
|
+
const { refetch } = issue;
|
|
77
|
+
const reload = useCallback(() => void refetch(), [refetch]);
|
|
78
|
+
if (issue.isPending) return <EmptyState theme={theme} icon="RefreshCw" title="Loading issue" />;
|
|
79
|
+
if (issue.isError) {
|
|
80
|
+
return (
|
|
81
|
+
<EmptyState
|
|
82
|
+
theme={theme}
|
|
83
|
+
icon="TriangleAlert"
|
|
84
|
+
tone="danger"
|
|
85
|
+
title="Could not load the issue"
|
|
86
|
+
detail={errorMessage(issue.error)}
|
|
87
|
+
>
|
|
88
|
+
<Button theme={theme} label="Back to issues" icon="ChevronLeft" onPress={props.onBack} />
|
|
89
|
+
</EmptyState>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
// Keyed so that switching issues never carries a draft or a pending save across.
|
|
93
|
+
return <LoadedIssue key={issue.data.id} {...props} issue={issue.data} refetch={reload} />;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch(): void }) {
|
|
97
|
+
const { theme, issue, compact } = props;
|
|
98
|
+
const { colors } = theme;
|
|
99
|
+
const [picker, setPicker] = useState<Picker>(null);
|
|
100
|
+
const update = useUpdateIssue(issue.id);
|
|
101
|
+
const styles = useMemo(() => {
|
|
102
|
+
const padding = compact ? 16 : 24;
|
|
103
|
+
return {
|
|
104
|
+
content: { padding, gap: 20, paddingBottom: 48 },
|
|
105
|
+
title: {
|
|
106
|
+
color: colors.foreground,
|
|
107
|
+
fontSize: compact ? 18 : 20,
|
|
108
|
+
fontWeight: "500",
|
|
109
|
+
lineHeight: compact ? 24 : 28,
|
|
110
|
+
},
|
|
111
|
+
} as const;
|
|
112
|
+
}, [colors, compact]);
|
|
113
|
+
return (
|
|
114
|
+
<View style={ROOT_STYLE}>
|
|
115
|
+
<IssueHeader
|
|
116
|
+
theme={theme}
|
|
117
|
+
compact={compact}
|
|
118
|
+
issue={issue}
|
|
119
|
+
onBack={props.onBack}
|
|
120
|
+
onRefresh={props.refetch}
|
|
121
|
+
onOpenIssue={props.onOpenIssue}
|
|
122
|
+
/>
|
|
123
|
+
<ScrollView style={SCROLL_STYLE} contentContainerStyle={styles.content}>
|
|
124
|
+
<Text selectable style={styles.title}>
|
|
125
|
+
{issue.title}
|
|
126
|
+
</Text>
|
|
127
|
+
<IssueProperties
|
|
128
|
+
theme={theme}
|
|
129
|
+
issue={issue}
|
|
130
|
+
disabled={update.isPending}
|
|
131
|
+
onPick={setPicker}
|
|
132
|
+
/>
|
|
133
|
+
{issue.labels.length > 0 ? (
|
|
134
|
+
<View style={LABELS_STYLE}>
|
|
135
|
+
{issue.labels.map((label) => (
|
|
136
|
+
<LabelPill key={label.id} theme={theme} name={label.name} color={label.color} />
|
|
137
|
+
))}
|
|
138
|
+
</View>
|
|
139
|
+
) : null}
|
|
140
|
+
<LaunchButtons theme={theme} onLaunch={props.onLaunch} />
|
|
141
|
+
<LinkedAgents theme={theme} identifier={issue.identifier} navigation={props.navigation} />
|
|
142
|
+
<IssueDescription theme={theme} compact={compact} issue={issue} />
|
|
143
|
+
<IssueRelations theme={theme} issue={issue} onOpenIssue={props.onOpenIssue} />
|
|
144
|
+
<IssueLinks theme={theme} issue={issue} />
|
|
145
|
+
<Comments theme={theme} issue={issue} />
|
|
146
|
+
</ScrollView>
|
|
147
|
+
<IssuePicker
|
|
148
|
+
theme={theme}
|
|
149
|
+
issue={issue}
|
|
150
|
+
picker={picker}
|
|
151
|
+
teams={props.teams}
|
|
152
|
+
users={props.users}
|
|
153
|
+
viewerId={props.viewerId}
|
|
154
|
+
onUpdate={update.mutate}
|
|
155
|
+
onClose={setPicker}
|
|
156
|
+
/>
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function IssueHeader(props: {
|
|
162
|
+
theme: Theme;
|
|
163
|
+
compact: boolean;
|
|
164
|
+
issue: IssueDetail;
|
|
165
|
+
onBack(): void;
|
|
166
|
+
onRefresh(): void;
|
|
167
|
+
onOpenIssue(issueId: string): void;
|
|
168
|
+
}) {
|
|
169
|
+
const { theme, compact, issue } = props;
|
|
170
|
+
const { colors } = theme;
|
|
171
|
+
const toast = useToast();
|
|
172
|
+
const { branchName, url } = issue;
|
|
173
|
+
const styles = useMemo(
|
|
174
|
+
() =>
|
|
175
|
+
({
|
|
176
|
+
bar: {
|
|
177
|
+
height: 48,
|
|
178
|
+
paddingHorizontal: compact ? 8 : 12,
|
|
179
|
+
flexDirection: "row",
|
|
180
|
+
alignItems: "center",
|
|
181
|
+
gap: 4,
|
|
182
|
+
borderBottomWidth: 1,
|
|
183
|
+
borderBottomColor: colors.border,
|
|
184
|
+
},
|
|
185
|
+
}) as const,
|
|
186
|
+
[colors, compact],
|
|
187
|
+
);
|
|
188
|
+
const copyBranch = useCallback(async () => {
|
|
189
|
+
try {
|
|
190
|
+
await copyText(branchName);
|
|
191
|
+
toast.show(`Copied ${branchName}`, { variant: "success" });
|
|
192
|
+
} catch {
|
|
193
|
+
toast.error("Could not copy the branch name");
|
|
194
|
+
}
|
|
195
|
+
}, [branchName, toast]);
|
|
196
|
+
const pressCopy = useCallback(() => void copyBranch(), [copyBranch]);
|
|
197
|
+
const openInLinear = useCallback(() => void openExternalUrl(url), [url]);
|
|
198
|
+
return (
|
|
199
|
+
<View style={styles.bar}>
|
|
200
|
+
<IconButton
|
|
201
|
+
theme={theme}
|
|
202
|
+
icon={compact ? "ChevronLeft" : "X"}
|
|
203
|
+
label="Close issue"
|
|
204
|
+
onPress={props.onBack}
|
|
205
|
+
/>
|
|
206
|
+
<IssueBreadcrumbs
|
|
207
|
+
theme={theme}
|
|
208
|
+
compact={compact}
|
|
209
|
+
issue={issue}
|
|
210
|
+
onOpenIssue={props.onOpenIssue}
|
|
211
|
+
/>
|
|
212
|
+
<IconButton theme={theme} icon="RefreshCw" label="Refresh issue" onPress={props.onRefresh} />
|
|
213
|
+
<IconButton theme={theme} icon="GitBranch" label="Copy branch name" onPress={pressCopy} />
|
|
214
|
+
<IconButton theme={theme} icon="ExternalLink" label="Open in Linear" onPress={openInLinear} />
|
|
215
|
+
</View>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function IssueProperties(props: {
|
|
220
|
+
theme: Theme;
|
|
221
|
+
issue: IssueDetail;
|
|
222
|
+
disabled: boolean;
|
|
223
|
+
onPick(picker: PickerKind): void;
|
|
224
|
+
}) {
|
|
225
|
+
const { theme, issue, onPick } = props;
|
|
226
|
+
const { colors } = theme;
|
|
227
|
+
const assigneeName = issue.assignee?.displayName ?? null;
|
|
228
|
+
const stateIcon = useMemo(() => <StateIcon state={issue.state} size={14} />, [issue.state]);
|
|
229
|
+
const priorityIcon = useMemo(
|
|
230
|
+
() => <PriorityIcon theme={theme} priority={issue.priority} size={14} />,
|
|
231
|
+
[theme, issue.priority],
|
|
232
|
+
);
|
|
233
|
+
const avatar = useMemo(
|
|
234
|
+
() => <Avatar theme={theme} name={assigneeName} size={16} />,
|
|
235
|
+
[theme, assigneeName],
|
|
236
|
+
);
|
|
237
|
+
const projectStyles = useMemo(
|
|
238
|
+
() =>
|
|
239
|
+
({
|
|
240
|
+
row: {
|
|
241
|
+
height: 30,
|
|
242
|
+
flexDirection: "row",
|
|
243
|
+
alignItems: "center",
|
|
244
|
+
gap: 6,
|
|
245
|
+
paddingHorizontal: 4,
|
|
246
|
+
},
|
|
247
|
+
name: { color: colors.foregroundMuted, fontSize: 13 },
|
|
248
|
+
}) as const,
|
|
249
|
+
[colors],
|
|
250
|
+
);
|
|
251
|
+
const pickState = useCallback(() => onPick("state"), [onPick]);
|
|
252
|
+
const pickPriority = useCallback(() => onPick("priority"), [onPick]);
|
|
253
|
+
const pickAssignee = useCallback(() => onPick("assignee"), [onPick]);
|
|
254
|
+
return (
|
|
255
|
+
<View style={ROW_WRAP_STYLE}>
|
|
256
|
+
<PropertyChip
|
|
257
|
+
theme={theme}
|
|
258
|
+
label={issue.state.name}
|
|
259
|
+
leading={stateIcon}
|
|
260
|
+
accessibilityLabel={`Status: ${issue.state.name}. Change status`}
|
|
261
|
+
disabled={props.disabled}
|
|
262
|
+
onPress={pickState}
|
|
263
|
+
/>
|
|
264
|
+
<PropertyChip
|
|
265
|
+
theme={theme}
|
|
266
|
+
label={priorityMeta(issue.priority).label}
|
|
267
|
+
leading={priorityIcon}
|
|
268
|
+
accessibilityLabel={`Priority: ${issue.priorityLabel}. Change priority`}
|
|
269
|
+
disabled={props.disabled}
|
|
270
|
+
onPress={pickPriority}
|
|
271
|
+
/>
|
|
272
|
+
<PropertyChip
|
|
273
|
+
theme={theme}
|
|
274
|
+
label={assigneeName ?? "Unassigned"}
|
|
275
|
+
leading={avatar}
|
|
276
|
+
accessibilityLabel={`Assignee: ${assigneeName ?? "nobody"}. Change assignee`}
|
|
277
|
+
disabled={props.disabled}
|
|
278
|
+
onPress={pickAssignee}
|
|
279
|
+
/>
|
|
280
|
+
{issue.project ? (
|
|
281
|
+
<View style={projectStyles.row}>
|
|
282
|
+
<Icon name="FolderGit2" size={14} color={colors.foregroundMuted} />
|
|
283
|
+
<Text style={projectStyles.name}>{issue.project.name}</Text>
|
|
284
|
+
</View>
|
|
285
|
+
) : null}
|
|
286
|
+
</View>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function LaunchButtons({ theme, onLaunch }: { theme: Theme; onLaunch(action: AgentAction): void }) {
|
|
291
|
+
const implement = useCallback(() => onLaunch("implement"), [onLaunch]);
|
|
292
|
+
const review = useCallback(() => onLaunch("review"), [onLaunch]);
|
|
293
|
+
return (
|
|
294
|
+
<View style={ROW_WRAP_STYLE}>
|
|
295
|
+
<Button
|
|
296
|
+
theme={theme}
|
|
297
|
+
variant="primary"
|
|
298
|
+
size="md"
|
|
299
|
+
icon={ACTION_LABELS.implement.icon}
|
|
300
|
+
label={ACTION_LABELS.implement.verb}
|
|
301
|
+
onPress={implement}
|
|
302
|
+
/>
|
|
303
|
+
<Button
|
|
304
|
+
theme={theme}
|
|
305
|
+
variant="secondary"
|
|
306
|
+
size="md"
|
|
307
|
+
icon={ACTION_LABELS.review.icon}
|
|
308
|
+
label={ACTION_LABELS.review.verb}
|
|
309
|
+
onPress={review}
|
|
310
|
+
/>
|
|
311
|
+
</View>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function IssuePicker(props: {
|
|
316
|
+
theme: Theme;
|
|
317
|
+
issue: IssueDetail;
|
|
318
|
+
picker: Picker;
|
|
319
|
+
teams: readonly LinearTeam[];
|
|
320
|
+
users: readonly LinearUser[];
|
|
321
|
+
viewerId: string | null;
|
|
322
|
+
onUpdate: ReturnType<typeof useUpdateIssue>["mutate"];
|
|
323
|
+
onClose(picker: null): void;
|
|
324
|
+
}) {
|
|
325
|
+
const { theme, issue, picker, users, viewerId, onUpdate, onClose } = props;
|
|
326
|
+
const toast = useToast();
|
|
327
|
+
const team = props.teams.find((entry) => entry.id === issue.team.id) ?? null;
|
|
328
|
+
const options = useMemo(() => {
|
|
329
|
+
if (picker === "state") return stateOptions(team?.states ?? []);
|
|
330
|
+
if (picker === "priority") return priorityOptions(theme);
|
|
331
|
+
if (picker === "assignee") return assigneeOptions(theme, users, viewerId);
|
|
332
|
+
return [];
|
|
333
|
+
}, [picker, team, theme, users, viewerId]);
|
|
334
|
+
const close = useCallback(() => onClose(null), [onClose]);
|
|
335
|
+
const choose = useCallback(
|
|
336
|
+
(value: string) => {
|
|
337
|
+
onClose(null);
|
|
338
|
+
onUpdate(pickerPatch(picker, value), {
|
|
339
|
+
onError: (error) => toast.error(errorMessage(error)),
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
[picker, onClose, onUpdate, toast],
|
|
343
|
+
);
|
|
344
|
+
const meta = PICKER_META[picker ?? "assignee"];
|
|
345
|
+
return (
|
|
346
|
+
<PickerModal
|
|
347
|
+
theme={theme}
|
|
348
|
+
title={meta.title}
|
|
349
|
+
icon={meta.icon}
|
|
350
|
+
open={picker !== null}
|
|
351
|
+
options={options}
|
|
352
|
+
value={pickerValue(picker, issue)}
|
|
353
|
+
searchable={picker === "assignee"}
|
|
354
|
+
onClose={close}
|
|
355
|
+
onSelect={choose}
|
|
356
|
+
/>
|
|
357
|
+
);
|
|
358
|
+
}
|